enabled build once and launch multiple isolated tauri app

This commit is contained in:
Lifei Zhou
2026-04-14 19:17:50 +10:00
parent a4fa1d9931
commit 1cedbf0758
15 changed files with 349 additions and 70 deletions
+1 -2
View File
@@ -26,8 +26,7 @@ project_dir="$(pwd)"
tauri_config=$(
PROJECT_DIR="$project_dir" \
VITE_PORT="$vite_port" \
GOOSE2_PROFILE="$profile" \
python3 -c $'import json, os\nprofile = os.environ["GOOSE2_PROFILE"]\nconfig = {\n "build": {\n "devUrl": f"http://localhost:{os.environ[\"VITE_PORT\"]}",\n "beforeDevCommand": {\n "script": f"cd {os.environ[\"PROJECT_DIR\"]} && exec pnpm exec vite --port {os.environ[\"VITE_PORT\"]} --strictPort",\n "cwd": ".",\n "wait": False,\n },\n },\n}\nif profile:\n config["identifier"] = f"com.goose.app.dev.{profile}"\n config["productName"] = f"Goose Dev ({profile})"\nprint(json.dumps(config))'
python3 -c $'import json, os\nconfig = {\n "build": {\n "devUrl": f"http://localhost:{os.environ[\"VITE_PORT\"]}",\n "beforeDevCommand": {\n "script": f"cd {os.environ[\"PROJECT_DIR\"]} && exec pnpm exec vite --port {os.environ[\"VITE_PORT\"]} --strictPort",\n "cwd": ".",\n "wait": False,\n },\n },\n}\nprint(json.dumps(config))'
)
if git rev-parse --is-inside-work-tree &>/dev/null; then
+6 -6
View File
@@ -1217,7 +1217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3782,7 +3782,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4663,9 +4663,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809"
dependencies = [
"log",
"raw-window-handle",
@@ -4872,7 +4872,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -5742,7 +5742,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
+1 -1
View File
@@ -20,7 +20,7 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-app-test-driver = { path = "plugins/app-test-driver" }
tauri-plugin-opener = "2"
tauri-plugin-dialog = ">=2,<2.7"
tauri-plugin-dialog = "2"
tauri-plugin-window-state = "2"
tauri-plugin-log = "2"
serde = { version = "1", features = ["derive"] }
File diff suppressed because one or more lines are too long
@@ -2331,22 +2331,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2367,16 +2367,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
@@ -2331,22 +2331,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2367,16 +2367,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
@@ -146,24 +146,88 @@ fn build_js(cmd: &TestCommand) -> String {
.to_string(),
"click" => {
let sel = cmd.selector.as_deref().unwrap_or("body");
with_wait_for(sel, r#"el.click(); return "clicked";"#, timeout_ms)
with_wait_for(
sel,
r#"if (el instanceof HTMLButtonElement && el.disabled) {
return "ERROR: element is disabled";
}
if (el instanceof HTMLInputElement && el.disabled) {
return "ERROR: element is disabled";
}
if (el instanceof HTMLTextAreaElement && el.disabled) {
return "ERROR: element is disabled";
}
if (el.getAttribute('aria-disabled') === 'true') {
return "ERROR: element is aria-disabled";
}
el.click();
return "clicked";"#,
timeout_ms,
)
}
"fill" => {
let sel = cmd.selector.as_deref().unwrap_or("input");
let val = escape_js_string(cmd.value.as_deref().unwrap_or(""));
with_wait_for(
sel,
&format!(
r#"const proto = el instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
setter.call(el, "{val}");
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
return "filled";"#
),
timeout_ms,
let escaped_sel = escape_js_string(sel);
format!(
r#"(async function() {{
const sel = "{escaped_sel}";
const start = Date.now();
while (Date.now() - start < {timeout_ms}) {{
const el = document.querySelector(sel);
if (el) {{
if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) {{
return "ERROR: element is not fillable";
}}
if (el.disabled) {{
return "ERROR: element is disabled";
}}
el.focus();
const previousValue = el.value ?? "";
const proto = el instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (!setter) {{
return "ERROR: value setter not found";
}}
el.dispatchEvent(new InputEvent('beforeinput', {{
bubbles: true,
cancelable: true,
data: "{val}",
inputType: 'insertText'
}}));
setter.call(el, "{val}");
if (typeof el.setSelectionRange === 'function') {{
el.setSelectionRange(el.value.length, el.value.length);
}}
const tracker = el._valueTracker;
if (tracker) {{
tracker.setValue(previousValue);
}}
el.dispatchEvent(new InputEvent('input', {{
bubbles: true,
cancelable: true,
data: "{val}",
inputType: 'insertText'
}}));
el.dispatchEvent(new Event('change', {{ bubbles: true }}));
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
if (el.value !== "{val}") {{
continue;
}}
return "filled";
}}
await new Promise(r => setTimeout(r, 100));
}}
return "ERROR: timeout waiting for fillable element: " + sel;
}})()"#
)
}
"keypress" => {
@@ -331,17 +395,27 @@ fn start_server<R: Runtime>(app_handle: AppHandle<R>) {
log::info!("[app-test-driver] Received: {cmd:?}");
let window = match app.get_webview_window("main") {
Some(w) => w,
None => {
let resp = TestResult {
success: false,
data: None,
error: Some("Main window not found".into()),
};
let _ = writeln!(stream, "{}", serde_json::to_string(&resp).unwrap());
continue;
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(5);
let window = loop {
if let Some(window) = app.get_webview_window("main") {
break Some(window);
}
if std::time::Instant::now() >= deadline {
break None;
}
std::thread::sleep(std::time::Duration::from_millis(100));
};
let Some(window) = window else {
let resp = TestResult {
success: false,
data: None,
error: Some("Main window not found".into()),
};
let _ = writeln!(stream, "{}", serde_json::to_string(&resp).unwrap());
continue;
};
let state = app.state::<DriverState>();
+10 -1
View File
@@ -104,7 +104,16 @@ pub fn run() {
commands::system::list_files_for_mentions,
])
.setup(|_app| Ok(()))
.build(tauri::generate_context!())
.build({
#[allow(unused_mut)]
let mut context = tauri::generate_context!();
#[cfg(feature = "app-test-driver")]
if let Ok(profile) = std::env::var("GOOSE2_PROFILE") {
context.config_mut().identifier =
format!("com.goose.app.e2e.{profile}");
}
context
})
.expect("error while building tauri application")
.run(move |_app, event| {
if let tauri::RunEvent::Exit = event {
@@ -441,6 +441,7 @@ export function ChatInput({
<PopoverAnchor asChild>
<textarea
ref={textareaRef}
data-testid="chat-input"
value={text}
onChange={handleInput}
onKeyDown={handleKeyDown}
@@ -280,6 +280,7 @@ export function ChatInputToolbar({
onClick={onSend}
disabled={!canSend}
size="icon-sm"
data-testid="chat-send"
className={cn(
"rounded-full",
"shadow-none",
+4 -6
View File
@@ -2,17 +2,15 @@ import { describe, it, expect } from "vitest";
import { useTestDriver } from "./lib/setup";
describe("Chat", () => {
const testDriver = useTestDriver();
const testDriver = useTestDriver("chat");
it("returns formatted date when asked", async () => {
await new Promise((resolve) => setTimeout(resolve, 5000));
await testDriver.fill(
'textarea[placeholder*="Message Goose"]',
'[data-testid="chat-input"]',
'Show me the date of Jan 26 2025 in format of "dd-mm-yyyy"',
);
await testDriver.keypress(
'textarea[placeholder*="Message Goose"]',
"Enter",
);
await testDriver.click('[data-testid="chat-send"]');
const bodyText = await testDriver.waitForText("26-01-2025", {
timeout: 30000,
+187
View File
@@ -0,0 +1,187 @@
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
const STARTUP_TIMEOUT_MS = 60000;
const RETRY_INTERVAL_MS = 250;
const pad = (value: number): string => value.toString().padStart(2, "0");
const createRunId = (): string => {
const now = new Date();
return [
now.getFullYear().toString(),
pad(now.getMonth() + 1),
pad(now.getDate()),
pad(now.getHours()),
pad(now.getMinutes()),
pad(now.getSeconds()),
].join("");
};
export interface AppRuntime {
port: number;
profile: string;
goosePathRoot: string;
close: () => void;
}
const canConnect = (port: number): Promise<boolean> => {
return new Promise((resolve) => {
const socket = net.createConnection({ host: "127.0.0.1", port });
const finish = (connected: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(connected);
};
socket.once("connect", () => finish(true));
socket.once("error", () => finish(false));
});
};
const wait = (delayMs: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, delayMs));
const waitForDriver = async (
port: number,
timeoutMs: number,
): Promise<void> => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await canConnect(port)) {
return;
}
await wait(RETRY_INTERVAL_MS);
}
throw new Error(
`Timed out waiting for app test driver on port ${port} after ${timeoutMs}ms`,
);
};
const reservePort = async (): Promise<number> => {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to reserve app test port")));
return;
}
const { port } = address;
server.close((closeError) => {
if (closeError) {
reject(closeError);
return;
}
resolve(port);
});
});
});
};
const stopChild = (child: ChildProcess) => {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
const killTarget =
child.pid && child.spawnargs.length > 0 ? -child.pid : child.pid;
if (!killTarget) {
return;
}
try {
process.kill(killTarget, "SIGTERM");
} catch {
return;
}
setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
try {
process.kill(killTarget, "SIGKILL");
} catch {
return;
}
}
}, 5000).unref();
};
export const startAppForTestFile = async (
testFileId: string,
): Promise<AppRuntime> => {
const port = await reservePort();
const runId = createRunId();
const profile = `app-e2e-${testFileId}-${runId}`;
const goosePathRoot = path.join("/tmp", profile);
// Seed a minimal config so the app has a provider/model configured.
const configDir = path.join(goosePathRoot, "config");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, "config.yaml"),
[
`GOOSE_PROVIDER: ${process.env.GOOSE_PROVIDER || "anthropic"}`,
`GOOSE_MODEL: ${process.env.GOOSE_MODEL || "claude-haiku-4-5-20251001"}`,
`GOOSE_TELEMETRY_ENABLED: false`,
].join("\n"),
);
const appBinary = process.env.APP_E2E_BINARY;
const env = {
...process.env,
APP_TEST_DRIVER_PORT: String(port),
GOOSE2_PROFILE: profile,
GOOSE_PATH_ROOT: goosePathRoot,
};
const child = appBinary
? spawn(appBinary, [], { detached: true, env, stdio: "inherit" })
: spawn(
path.resolve(import.meta.dirname, "../../../scripts/run-tauri-dev.sh"),
["dev"],
{
cwd: path.resolve(import.meta.dirname, "../../.."),
detached: true,
env,
stdio: "inherit",
},
);
process.once("exit", () => stopChild(child));
process.once("SIGINT", () => stopChild(child));
process.once("SIGTERM", () => stopChild(child));
child.once("exit", (code, signal) => {
if (code !== 0 && signal == null) {
console.error(
`App E2E process ${profile} exited before teardown with code ${code}`,
);
}
});
try {
await waitForDriver(port, STARTUP_TIMEOUT_MS);
} catch (error) {
stopChild(child);
throw error;
}
return {
port,
profile,
goosePathRoot,
close: () => stopChild(child),
};
};
+12 -2
View File
@@ -1,11 +1,13 @@
import { beforeAll, beforeEach, afterAll, onTestFailed } from "vitest";
import { type AppRuntime, startAppForTestFile } from "./app-runtime";
import { type TestDriver, createTestDriver } from "./test-driver-client";
declare const __SCREENSHOT_DIR__: string;
declare const __SCREENSHOT_ON_FAILURE__: boolean;
export const useTestDriver = (): TestDriver => {
export const useTestDriver = (testFileId: string): TestDriver => {
let inner: TestDriver;
let runtime: AppRuntime;
const testDriver = new Proxy({} as TestDriver, {
get(_target, prop) {
@@ -16,11 +18,19 @@ export const useTestDriver = (): TestDriver => {
});
beforeAll(async () => {
inner = await createTestDriver();
try {
runtime = await startAppForTestFile(testFileId);
inner = await createTestDriver({ port: runtime.port });
} catch (error) {
inner?.close();
runtime?.close();
throw error;
}
});
afterAll(() => {
inner?.close();
runtime?.close();
});
beforeEach(async () => {
@@ -1,7 +1,5 @@
import net from "node:net";
const PORT = Number(process.env.APP_TEST_DRIVER_PORT) || 9999;
interface TestDriverCommand {
action: string;
selector?: string;
@@ -92,11 +90,11 @@ function send(socket: net.Socket, command: TestDriverCommand): Promise<string> {
* Create a client connection to the Tauri app test driver.
* Returns an object with methods for each test driver command.
*/
export async function createTestDriver({
port = PORT,
export const createTestDriver = async ({
port = Number(process.env.APP_TEST_DRIVER_PORT) || 9999,
}: {
port?: number;
} = {}): Promise<TestDriver> {
} = {}): Promise<TestDriver> => {
const socket = net.createConnection({ port, host: "127.0.0.1" });
await new Promise<void>((resolve, reject) => {
@@ -169,4 +167,4 @@ export async function createTestDriver({
socket.end();
},
};
}
};
+3 -1
View File
@@ -10,6 +10,8 @@ export default defineConfig({
test: {
include: ["tests/app-e2e/**/*.test.ts"],
testTimeout: 60000,
hookTimeout: 10000,
hookTimeout: 120000,
pool: "forks",
maxWorkers: process.env.APP_E2E_WORKERS,
},
});