mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
enabled build once and launch multiple isolated tauri app
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+6
-6
@@ -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]]
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user