2026-02-14 13:00:31 +00:00
|
|
|
import path from "node:path";
|
2026-01-14 01:08:15 +00:00
|
|
|
import { fetch as realFetch } from "undici";
|
2026-02-14 17:29:03 +00:00
|
|
|
import { describe, expect, it } from "vitest";
|
2026-02-14 14:42:08 +01:00
|
|
|
import { DEFAULT_UPLOAD_DIR } from "./paths.js";
|
2026-02-14 17:29:03 +00:00
|
|
|
import {
|
2026-02-16 14:52:15 +00:00
|
|
|
installAgentContractHooks,
|
|
|
|
|
postJson,
|
|
|
|
|
startServerAndBase,
|
|
|
|
|
} from "./server.agent-contract.test-harness.js";
|
|
|
|
|
import {
|
2026-02-14 17:29:03 +00:00
|
|
|
getBrowserControlServerTestState,
|
|
|
|
|
getPwMocks,
|
|
|
|
|
setBrowserControlServerEvaluateEnabled,
|
|
|
|
|
} from "./server.control-server.test-harness.js";
|
|
|
|
|
|
|
|
|
|
const state = getBrowserControlServerTestState();
|
|
|
|
|
const pwMocks = getPwMocks();
|
2026-01-14 01:08:15 +00:00
|
|
|
|
|
|
|
|
describe("browser control server", () => {
|
2026-02-16 14:52:15 +00:00
|
|
|
installAgentContractHooks();
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-01-15 05:29:32 +00:00
|
|
|
const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000;
|
|
|
|
|
|
|
|
|
|
it(
|
|
|
|
|
"agent contract: form + layout act commands",
|
|
|
|
|
async () => {
|
|
|
|
|
const base = await startServerAndBase();
|
|
|
|
|
|
2026-02-17 11:16:58 +09:00
|
|
|
const select = await postJson<{ ok: boolean }>(`${base}/act`, {
|
2026-01-15 05:29:32 +00:00
|
|
|
kind: "select",
|
|
|
|
|
ref: "5",
|
|
|
|
|
values: ["a", "b"],
|
2026-01-31 16:03:28 +09:00
|
|
|
});
|
2026-01-15 05:29:32 +00:00
|
|
|
expect(select.ok).toBe(true);
|
|
|
|
|
expect(pwMocks.selectOptionViaPlaywright).toHaveBeenCalledWith({
|
2026-02-14 17:29:03 +00:00
|
|
|
cdpUrl: state.cdpBaseUrl,
|
2026-01-15 05:29:32 +00:00
|
|
|
targetId: "abcd1234",
|
|
|
|
|
ref: "5",
|
|
|
|
|
values: ["a", "b"],
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-02-17 11:16:58 +09:00
|
|
|
const fill = await postJson<{ ok: boolean }>(`${base}/act`, {
|
2026-01-15 05:29:32 +00:00
|
|
|
kind: "fill",
|
|
|
|
|
fields: [{ ref: "6", type: "textbox", value: "hello" }],
|
2026-01-31 16:03:28 +09:00
|
|
|
});
|
2026-01-15 05:29:32 +00:00
|
|
|
expect(fill.ok).toBe(true);
|
|
|
|
|
expect(pwMocks.fillFormViaPlaywright).toHaveBeenCalledWith({
|
2026-02-14 17:29:03 +00:00
|
|
|
cdpUrl: state.cdpBaseUrl,
|
2026-01-15 05:29:32 +00:00
|
|
|
targetId: "abcd1234",
|
|
|
|
|
fields: [{ ref: "6", type: "textbox", value: "hello" }],
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-02-17 11:16:58 +09:00
|
|
|
const resize = await postJson<{ ok: boolean }>(`${base}/act`, {
|
2026-01-15 05:29:32 +00:00
|
|
|
kind: "resize",
|
|
|
|
|
width: 800,
|
|
|
|
|
height: 600,
|
2026-01-31 16:03:28 +09:00
|
|
|
});
|
2026-01-15 05:29:32 +00:00
|
|
|
expect(resize.ok).toBe(true);
|
|
|
|
|
expect(pwMocks.resizeViewportViaPlaywright).toHaveBeenCalledWith({
|
2026-02-14 17:29:03 +00:00
|
|
|
cdpUrl: state.cdpBaseUrl,
|
2026-01-15 05:29:32 +00:00
|
|
|
targetId: "abcd1234",
|
|
|
|
|
width: 800,
|
|
|
|
|
height: 600,
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-02-17 11:16:58 +09:00
|
|
|
const wait = await postJson<{ ok: boolean }>(`${base}/act`, {
|
2026-01-15 05:29:32 +00:00
|
|
|
kind: "wait",
|
|
|
|
|
timeMs: 5,
|
2026-01-31 16:03:28 +09:00
|
|
|
});
|
2026-01-15 05:29:32 +00:00
|
|
|
expect(wait.ok).toBe(true);
|
|
|
|
|
expect(pwMocks.waitForViaPlaywright).toHaveBeenCalledWith({
|
2026-02-14 17:29:03 +00:00
|
|
|
cdpUrl: state.cdpBaseUrl,
|
2026-01-15 05:29:32 +00:00
|
|
|
targetId: "abcd1234",
|
|
|
|
|
timeMs: 5,
|
|
|
|
|
text: undefined,
|
|
|
|
|
textGone: undefined,
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-02-17 11:16:58 +09:00
|
|
|
const evalRes = await postJson<{ ok: boolean; result?: string }>(`${base}/act`, {
|
2026-01-15 05:29:32 +00:00
|
|
|
kind: "evaluate",
|
|
|
|
|
fn: "() => 1",
|
2026-01-31 16:03:28 +09:00
|
|
|
});
|
2026-01-15 05:29:32 +00:00
|
|
|
expect(evalRes.ok).toBe(true);
|
|
|
|
|
expect(evalRes.result).toBe("ok");
|
fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* fix(browser): prevent permanent timeout after stuck evaluate
Thread AbortSignal from client-fetch through dispatcher to Playwright
operations. When a timeout fires, force-disconnect the Playwright CDP
connection to unblock the serialized command queue, allowing the next
call to reconnect transparently.
Key changes:
- client-fetch.ts: proper AbortController with signal propagation
- pw-session.ts: new forceDisconnectPlaywrightForTarget()
- pw-tools-core.interactions.ts: accept signal, align inner timeout
to outer-500ms, inject in-browser Promise.race for async evaluates
- routes/dispatcher.ts + types.ts: propagate signal through dispatch
- server.ts + bridge-server.ts: Express middleware creates AbortSignal
from request lifecycle
- client-actions-core.ts: add timeoutMs to evaluate type
Fixes #10994
* fix(browser): v2 - force-disconnect via Connection.close() instead of browser.close()
When page.evaluate() is stuck on a hung CDP transport, browser.close() also
hangs because it tries to send a close command through the same stuck pipe.
v2 fix: forceDisconnectPlaywrightForTarget now directly calls Playwright's
internal Connection.close() which locally rejects all pending callbacks and
emits 'disconnected' without touching the network. This instantly unblocks
all stuck Playwright operations.
closePlaywrightBrowserConnection (clean shutdown) now also has a 3s timeout
fallback that drops to forceDropConnection if browser.close() hangs.
Fixes permanent browser timeout after stuck evaluate.
* fix(browser): v3 - fire-and-forget browser.close() instead of Connection.close()
v2's forceDropConnection called browser._connection.close() which corrupts
the entire Playwright instance because Connection is shared across all
objects (BrowserType, Browser, Page, etc.). This prevented reconnection
with cascading 'connectOverCDP: Force-disconnected' errors.
v3 fix: forceDisconnectPlaywrightForTarget now:
1. Nulls cached connection immediately
2. Fire-and-forgets browser.close() (doesn't await — it may hang)
3. Next connectBrowser() creates a fresh connectOverCDP WebSocket
Each connectOverCDP creates an independent WebSocket to the CDP endpoint,
so the new connection is unaffected by the old one's pending close.
The old browser.close() eventually resolves when the in-browser evaluate
timeout fires, or the old connection gets GC'd.
* fix(browser): v4 - clear connecting state and remove stale disconnect listeners
The reconnect was failing because:
1. forceDisconnectPlaywrightForTarget nulled cached but not connecting,
so subsequent calls could await a stale promise
2. The old browser's 'disconnected' event handler raced with new
connections, nulling the fresh cached reference
Fix: null both cached and connecting, and removeAllListeners on the
old browser before fire-and-forget close.
* fix(browser): v5 - use raw CDP Runtime.terminateExecution to kill stuck evaluate
When forceDisconnectPlaywrightForTarget fires, open a raw WebSocket
to the stuck page's CDP endpoint and send Runtime.terminateExecution.
This kills running JS without navigating away or crashing the page.
Also clear connecting state and remove stale disconnect listeners.
* fix(browser): abort cancels stuck evaluate
* Browser: always cleanup evaluate abort listener
* Chore: remove Playwright debug scripts
* Docs: add CDP evaluate refactor plan
* Browser: refactor Playwright force-disconnect
* Browser: abort stops evaluate promptly
* Node host: extract withTimeout helper
* Browser: remove disconnected listener safely
* Changelog: note act:evaluate hang fix
---------
Co-authored-by: Bob <bob@dutifulbob.com>
2026-02-11 07:54:48 +08:00
|
|
|
expect(pwMocks.evaluateViaPlaywright).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
2026-02-14 17:29:03 +00:00
|
|
|
cdpUrl: state.cdpBaseUrl,
|
fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* fix(browser): prevent permanent timeout after stuck evaluate
Thread AbortSignal from client-fetch through dispatcher to Playwright
operations. When a timeout fires, force-disconnect the Playwright CDP
connection to unblock the serialized command queue, allowing the next
call to reconnect transparently.
Key changes:
- client-fetch.ts: proper AbortController with signal propagation
- pw-session.ts: new forceDisconnectPlaywrightForTarget()
- pw-tools-core.interactions.ts: accept signal, align inner timeout
to outer-500ms, inject in-browser Promise.race for async evaluates
- routes/dispatcher.ts + types.ts: propagate signal through dispatch
- server.ts + bridge-server.ts: Express middleware creates AbortSignal
from request lifecycle
- client-actions-core.ts: add timeoutMs to evaluate type
Fixes #10994
* fix(browser): v2 - force-disconnect via Connection.close() instead of browser.close()
When page.evaluate() is stuck on a hung CDP transport, browser.close() also
hangs because it tries to send a close command through the same stuck pipe.
v2 fix: forceDisconnectPlaywrightForTarget now directly calls Playwright's
internal Connection.close() which locally rejects all pending callbacks and
emits 'disconnected' without touching the network. This instantly unblocks
all stuck Playwright operations.
closePlaywrightBrowserConnection (clean shutdown) now also has a 3s timeout
fallback that drops to forceDropConnection if browser.close() hangs.
Fixes permanent browser timeout after stuck evaluate.
* fix(browser): v3 - fire-and-forget browser.close() instead of Connection.close()
v2's forceDropConnection called browser._connection.close() which corrupts
the entire Playwright instance because Connection is shared across all
objects (BrowserType, Browser, Page, etc.). This prevented reconnection
with cascading 'connectOverCDP: Force-disconnected' errors.
v3 fix: forceDisconnectPlaywrightForTarget now:
1. Nulls cached connection immediately
2. Fire-and-forgets browser.close() (doesn't await — it may hang)
3. Next connectBrowser() creates a fresh connectOverCDP WebSocket
Each connectOverCDP creates an independent WebSocket to the CDP endpoint,
so the new connection is unaffected by the old one's pending close.
The old browser.close() eventually resolves when the in-browser evaluate
timeout fires, or the old connection gets GC'd.
* fix(browser): v4 - clear connecting state and remove stale disconnect listeners
The reconnect was failing because:
1. forceDisconnectPlaywrightForTarget nulled cached but not connecting,
so subsequent calls could await a stale promise
2. The old browser's 'disconnected' event handler raced with new
connections, nulling the fresh cached reference
Fix: null both cached and connecting, and removeAllListeners on the
old browser before fire-and-forget close.
* fix(browser): v5 - use raw CDP Runtime.terminateExecution to kill stuck evaluate
When forceDisconnectPlaywrightForTarget fires, open a raw WebSocket
to the stuck page's CDP endpoint and send Runtime.terminateExecution.
This kills running JS without navigating away or crashing the page.
Also clear connecting state and remove stale disconnect listeners.
* fix(browser): abort cancels stuck evaluate
* Browser: always cleanup evaluate abort listener
* Chore: remove Playwright debug scripts
* Docs: add CDP evaluate refactor plan
* Browser: refactor Playwright force-disconnect
* Browser: abort stops evaluate promptly
* Node host: extract withTimeout helper
* Browser: remove disconnected listener safely
* Changelog: note act:evaluate hang fix
---------
Co-authored-by: Bob <bob@dutifulbob.com>
2026-02-11 07:54:48 +08:00
|
|
|
targetId: "abcd1234",
|
|
|
|
|
fn: "() => 1",
|
|
|
|
|
ref: undefined,
|
|
|
|
|
signal: expect.any(AbortSignal),
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-01-15 05:29:32 +00:00
|
|
|
},
|
|
|
|
|
slowTimeoutMs,
|
|
|
|
|
);
|
2026-01-14 01:08:15 +00:00
|
|
|
|
2026-01-27 05:00:07 +00:00
|
|
|
it(
|
|
|
|
|
"blocks act:evaluate when browser.evaluateEnabled=false",
|
|
|
|
|
async () => {
|
2026-02-14 17:29:03 +00:00
|
|
|
setBrowserControlServerEvaluateEnabled(false);
|
2026-01-27 05:00:07 +00:00
|
|
|
const base = await startServerAndBase();
|
|
|
|
|
|
2026-02-17 11:16:58 +09:00
|
|
|
const waitRes = await postJson<{ error?: string }>(`${base}/act`, {
|
2026-01-27 05:00:07 +00:00
|
|
|
kind: "wait",
|
|
|
|
|
fn: "() => window.ready === true",
|
2026-01-31 16:03:28 +09:00
|
|
|
});
|
2026-01-27 05:00:07 +00:00
|
|
|
expect(waitRes.error).toContain("browser.evaluateEnabled=false");
|
|
|
|
|
expect(pwMocks.waitForViaPlaywright).not.toHaveBeenCalled();
|
|
|
|
|
|
2026-02-17 11:16:58 +09:00
|
|
|
const res = await postJson<{ error?: string }>(`${base}/act`, {
|
2026-01-27 05:00:07 +00:00
|
|
|
kind: "evaluate",
|
|
|
|
|
fn: "() => 1",
|
2026-01-31 16:03:28 +09:00
|
|
|
});
|
2026-01-27 05:00:07 +00:00
|
|
|
|
|
|
|
|
expect(res.error).toContain("browser.evaluateEnabled=false");
|
|
|
|
|
expect(pwMocks.evaluateViaPlaywright).not.toHaveBeenCalled();
|
|
|
|
|
},
|
|
|
|
|
slowTimeoutMs,
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-14 01:08:15 +00:00
|
|
|
it("agent contract: hooks + response + downloads + screenshot", async () => {
|
|
|
|
|
const base = await startServerAndBase();
|
|
|
|
|
|
|
|
|
|
const upload = await postJson(`${base}/hooks/file-chooser`, {
|
2026-02-14 14:42:08 +01:00
|
|
|
paths: ["a.txt"],
|
2026-01-14 01:08:15 +00:00
|
|
|
timeoutMs: 1234,
|
|
|
|
|
});
|
|
|
|
|
expect(upload).toMatchObject({ ok: true });
|
|
|
|
|
expect(pwMocks.armFileUploadViaPlaywright).toHaveBeenCalledWith({
|
2026-02-14 17:29:03 +00:00
|
|
|
cdpUrl: state.cdpBaseUrl,
|
2026-01-14 01:08:15 +00:00
|
|
|
targetId: "abcd1234",
|
2026-02-14 15:18:19 +01:00
|
|
|
// The server resolves paths (which adds a drive letter on Windows for `\\tmp\\...` style roots).
|
|
|
|
|
paths: [path.resolve(DEFAULT_UPLOAD_DIR, "a.txt")],
|
2026-01-14 01:08:15 +00:00
|
|
|
timeoutMs: 1234,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const uploadWithRef = await postJson(`${base}/hooks/file-chooser`, {
|
2026-02-14 14:42:08 +01:00
|
|
|
paths: ["b.txt"],
|
2026-01-14 01:08:15 +00:00
|
|
|
ref: "e12",
|
|
|
|
|
});
|
|
|
|
|
expect(uploadWithRef).toMatchObject({ ok: true });
|
|
|
|
|
|
|
|
|
|
const uploadWithInputRef = await postJson(`${base}/hooks/file-chooser`, {
|
2026-02-14 14:42:08 +01:00
|
|
|
paths: ["c.txt"],
|
2026-01-14 01:08:15 +00:00
|
|
|
inputRef: "e99",
|
|
|
|
|
});
|
|
|
|
|
expect(uploadWithInputRef).toMatchObject({ ok: true });
|
|
|
|
|
|
|
|
|
|
const uploadWithElement = await postJson(`${base}/hooks/file-chooser`, {
|
2026-02-14 14:42:08 +01:00
|
|
|
paths: ["d.txt"],
|
2026-01-14 01:08:15 +00:00
|
|
|
element: "input[type=file]",
|
|
|
|
|
});
|
|
|
|
|
expect(uploadWithElement).toMatchObject({ ok: true });
|
|
|
|
|
|
|
|
|
|
const dialog = await postJson(`${base}/hooks/dialog`, {
|
|
|
|
|
accept: true,
|
|
|
|
|
timeoutMs: 5678,
|
|
|
|
|
});
|
|
|
|
|
expect(dialog).toMatchObject({ ok: true });
|
|
|
|
|
|
|
|
|
|
const waitDownload = await postJson(`${base}/wait/download`, {
|
2026-02-13 19:24:33 +00:00
|
|
|
path: "report.pdf",
|
2026-01-14 01:08:15 +00:00
|
|
|
timeoutMs: 1111,
|
|
|
|
|
});
|
|
|
|
|
expect(waitDownload).toMatchObject({ ok: true });
|
|
|
|
|
|
|
|
|
|
const download = await postJson(`${base}/download`, {
|
|
|
|
|
ref: "e12",
|
2026-02-13 19:24:33 +00:00
|
|
|
path: "report.pdf",
|
2026-01-14 01:08:15 +00:00
|
|
|
});
|
|
|
|
|
expect(download).toMatchObject({ ok: true });
|
|
|
|
|
|
|
|
|
|
const responseBody = await postJson(`${base}/response/body`, {
|
|
|
|
|
url: "**/api/data",
|
|
|
|
|
timeoutMs: 2222,
|
|
|
|
|
maxChars: 10,
|
|
|
|
|
});
|
|
|
|
|
expect(responseBody).toMatchObject({ ok: true });
|
|
|
|
|
|
2026-01-14 14:31:43 +00:00
|
|
|
const consoleRes = (await realFetch(`${base}/console?level=error`).then((r) => r.json())) as {
|
|
|
|
|
ok: boolean;
|
|
|
|
|
messages?: unknown[];
|
|
|
|
|
};
|
2026-01-14 01:08:15 +00:00
|
|
|
expect(consoleRes.ok).toBe(true);
|
|
|
|
|
expect(Array.isArray(consoleRes.messages)).toBe(true);
|
|
|
|
|
|
2026-02-17 11:16:58 +09:00
|
|
|
const pdf = await postJson<{ ok: boolean; path?: string }>(`${base}/pdf`, {});
|
2026-01-14 01:08:15 +00:00
|
|
|
expect(pdf.ok).toBe(true);
|
|
|
|
|
expect(typeof pdf.path).toBe("string");
|
|
|
|
|
|
2026-02-17 11:16:58 +09:00
|
|
|
const shot = await postJson<{ ok: boolean; path?: string }>(`${base}/screenshot`, {
|
2026-01-14 01:08:15 +00:00
|
|
|
element: "body",
|
|
|
|
|
type: "jpeg",
|
2026-01-31 16:03:28 +09:00
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
expect(shot.ok).toBe(true);
|
|
|
|
|
expect(typeof shot.path).toBe("string");
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-14 18:17:02 +01:00
|
|
|
it("blocks file chooser traversal / absolute paths outside uploads dir", async () => {
|
|
|
|
|
const base = await startServerAndBase();
|
|
|
|
|
|
|
|
|
|
const traversal = await postJson<{ error?: string }>(`${base}/hooks/file-chooser`, {
|
|
|
|
|
paths: ["../../../../etc/passwd"],
|
|
|
|
|
});
|
|
|
|
|
expect(traversal.error).toContain("Invalid path");
|
|
|
|
|
expect(pwMocks.armFileUploadViaPlaywright).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
const absOutside = path.join(path.parse(DEFAULT_UPLOAD_DIR).root, "etc", "passwd");
|
|
|
|
|
const abs = await postJson<{ error?: string }>(`${base}/hooks/file-chooser`, {
|
|
|
|
|
paths: [absOutside],
|
|
|
|
|
});
|
|
|
|
|
expect(abs.error).toContain("Invalid path");
|
|
|
|
|
expect(pwMocks.armFileUploadViaPlaywright).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-14 01:08:15 +00:00
|
|
|
it("agent contract: stop endpoint", async () => {
|
|
|
|
|
const base = await startServerAndBase();
|
|
|
|
|
|
|
|
|
|
const stopped = (await realFetch(`${base}/stop`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
}).then((r) => r.json())) as { ok: boolean; stopped?: boolean };
|
|
|
|
|
expect(stopped.ok).toBe(true);
|
|
|
|
|
expect(stopped.stopped).toBe(true);
|
|
|
|
|
});
|
2026-02-13 19:24:33 +00:00
|
|
|
|
|
|
|
|
it("trace stop rejects traversal path outside trace dir", async () => {
|
|
|
|
|
const base = await startServerAndBase();
|
|
|
|
|
const res = await postJson<{ error?: string }>(`${base}/trace/stop`, {
|
|
|
|
|
path: "../../pwned.zip",
|
|
|
|
|
});
|
|
|
|
|
expect(res.error).toContain("Invalid path");
|
|
|
|
|
expect(pwMocks.traceStopViaPlaywright).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("trace stop accepts in-root relative output path", async () => {
|
|
|
|
|
const base = await startServerAndBase();
|
|
|
|
|
const res = await postJson<{ ok?: boolean; path?: string }>(`${base}/trace/stop`, {
|
|
|
|
|
path: "safe-trace.zip",
|
|
|
|
|
});
|
|
|
|
|
expect(res.ok).toBe(true);
|
|
|
|
|
expect(res.path).toContain("safe-trace.zip");
|
|
|
|
|
expect(pwMocks.traceStopViaPlaywright).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
2026-02-14 17:29:03 +00:00
|
|
|
cdpUrl: state.cdpBaseUrl,
|
2026-02-13 19:24:33 +00:00
|
|
|
targetId: "abcd1234",
|
|
|
|
|
path: expect.stringContaining("safe-trace.zip"),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("wait/download rejects traversal path outside downloads dir", async () => {
|
|
|
|
|
const base = await startServerAndBase();
|
|
|
|
|
const waitRes = await postJson<{ error?: string }>(`${base}/wait/download`, {
|
|
|
|
|
path: "../../pwned.pdf",
|
|
|
|
|
});
|
|
|
|
|
expect(waitRes.error).toContain("Invalid path");
|
|
|
|
|
expect(pwMocks.waitForDownloadViaPlaywright).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("download rejects traversal path outside downloads dir", async () => {
|
|
|
|
|
const base = await startServerAndBase();
|
|
|
|
|
const downloadRes = await postJson<{ error?: string }>(`${base}/download`, {
|
|
|
|
|
ref: "e12",
|
|
|
|
|
path: "../../pwned.pdf",
|
|
|
|
|
});
|
|
|
|
|
expect(downloadRes.error).toContain("Invalid path");
|
|
|
|
|
expect(pwMocks.downloadViaPlaywright).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("wait/download accepts in-root relative output path", async () => {
|
|
|
|
|
const base = await startServerAndBase();
|
|
|
|
|
const res = await postJson<{ ok?: boolean; download?: { path?: string } }>(
|
|
|
|
|
`${base}/wait/download`,
|
|
|
|
|
{
|
|
|
|
|
path: "safe-wait.pdf",
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
expect(res.ok).toBe(true);
|
|
|
|
|
expect(pwMocks.waitForDownloadViaPlaywright).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
2026-02-14 17:29:03 +00:00
|
|
|
cdpUrl: state.cdpBaseUrl,
|
2026-02-13 19:24:33 +00:00
|
|
|
targetId: "abcd1234",
|
|
|
|
|
path: expect.stringContaining("safe-wait.pdf"),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("download accepts in-root relative output path", async () => {
|
|
|
|
|
const base = await startServerAndBase();
|
|
|
|
|
const res = await postJson<{ ok?: boolean; download?: { path?: string } }>(`${base}/download`, {
|
|
|
|
|
ref: "e12",
|
|
|
|
|
path: "safe-download.pdf",
|
|
|
|
|
});
|
|
|
|
|
expect(res.ok).toBe(true);
|
|
|
|
|
expect(pwMocks.downloadViaPlaywright).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
2026-02-14 17:29:03 +00:00
|
|
|
cdpUrl: state.cdpBaseUrl,
|
2026-02-13 19:24:33 +00:00
|
|
|
targetId: "abcd1234",
|
|
|
|
|
ref: "e12",
|
|
|
|
|
path: expect.stringContaining("safe-download.pdf"),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-01-14 01:08:15 +00:00
|
|
|
});
|