diff --git a/skills/webcmd-browser/references/browser-run-playwright.md b/skills/webcmd-browser/references/browser-run-playwright.md index fdb2ac31..ec6a5b5b 100644 --- a/skills/webcmd-browser/references/browser-run-playwright.md +++ b/skills/webcmd-browser/references/browser-run-playwright.md @@ -6,9 +6,63 @@ `context.newPage()` is not available inside `run`; create or bind Session tabs through Webcmd commands so page ownership stays deterministic. -## Artifact paths +## Artifacts: getting bytes out of the sandbox -Artifacts written by Playwright must use a relative logical filename. Webcmd returns an artifact receipt with its locator; it does not grant host-path write access. +There is no host filesystem. The only way to get a file out of a run is to write it as an +artifact, using a **relative logical filename** — absolute paths and `..` are rejected with +`BROWSER_RUN_INVALID_INPUT`. + +### Writing one + +```js +const receipt = await writeArtifact('report.csv', new TextEncoder().encode(csv), 'text/csv'); +return receipt; +``` + +`writeArtifact(filename, bytes, contentType?)` takes a `Uint8Array` and resolves to the +receipt. `contentType` is optional and defaults to `application/octet-stream` for anything +that is not `.png`/`.jpg` — pass it explicitly when it matters. `__webcmdWriteArtifact` is a +legacy alias for the same function. + +Two other calls write artifacts for you: `page.screenshot({ path: 'shot.png' })` and +`download.saveAs('out.csv')`. Both take the same relative logical filename. + +### Capturing a download + +`download.createReadStream()` throws — Readable streams do not exist in the sandbox. Use +`saveAs` with a relative name instead; it routes through the artifact sink: + +```js +const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Convert' }).click(), +]); +await download.saveAs(download.suggestedFilename()); +return { saved: download.suggestedFilename() }; +``` + +Do not scrape an on-page preview as a substitute for the downloaded bytes — it will not match. + +### Redeeming a receipt + +Every artifact written during a run appears in the run result's `artifacts` array, whether it +came from `writeArtifact`, `saveAs`, or `screenshot`: + +```json +{ + "artifactId": "artifact_9d1f6368490aa37a22a18426", + "filename": "downloads/out.csv", + "contentType": "application/octet-stream", + "byteSize": 12, + "locator": "browser-run://artifact_9d1f6368490aa37a22a18426/downloads%2Fout.csv" +} +``` + +Locally the bytes land at `~/.webcmd/cache/browser-run//` (under +`$WEBCMD_CACHE_DIR/browser-run` when that is set), readable once `run` has returned. Hosted +runs use the same receipt shape with a `cloud-artifact://` locator backed by the execution's +trace artifact store. The receipt never carries the bytes themselves, so return the receipt — +or just read it off `artifacts` — rather than trying to return file contents through `result`. ## Errors diff --git a/src/browser/run/runner.test.ts b/src/browser/run/runner.test.ts index 7f6653eb..bdf91037 100644 --- a/src/browser/run/runner.test.ts +++ b/src/browser/run/runner.test.ts @@ -1,4 +1,6 @@ import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { createRequire } from 'node:module'; import { afterAll, @@ -16,6 +18,7 @@ import { type BrowserContext, type Page, } from 'playwright-core'; +import { LocalBrowserRunArtifactSink } from './artifacts.js'; import { QuickJSHost } from './quickjs-host.js'; import { runBrowserProgram } from './runner.js'; @@ -42,10 +45,11 @@ function sessionScope(pages: () => readonly Page[] = () => context.pages()) { }; } -function run(source: string, options = {}) { +function run(source: string, options = {}, input = {}) { return runBrowserProgram({ ...sessionScope(), pageId: 'page-1', + ...input, }, source, options); } @@ -643,6 +647,36 @@ afterAll(async () => { expect(output.result).toEqual({ length: 3, returned: [255, 0, 128] }); }); + it.each(['writeArtifact', '__webcmdWriteArtifact'])( + 'returns a redeemable receipt from %s', + async (fn) => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-artifact-')); + try { + const output = await run(` + const receipt = await ${fn}( + 'nested/report.csv', + new TextEncoder().encode('id,name\\n1,caf\\u00e9\\n'), + ); + return receipt; + `, {}, { artifactSink: new LocalBrowserRunArtifactSink({ baseDir }) }); + + const receipt = output.result as { artifactId: string; locator: string }; + expect(receipt).toMatchObject({ + filename: 'nested/report.csv', + contentType: 'application/octet-stream', + byteSize: 16, + locator: expect.stringContaining('browser-run://'), + }); + expect(output.artifacts).toEqual([receipt]); + expect( + fs.readFileSync(path.join(baseDir, receipt.artifactId, 'nested/report.csv'), 'utf8'), + ).toBe('id,name\n1,caf\u00e9\n'); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }, + ); + it('rejects absolute artifact paths instead of touching host paths', async () => { const target = '/tmp/webcmd-browser-run-owned.txt'; fs.rmSync(target, { force: true }); diff --git a/src/browser/run/runner.ts b/src/browser/run/runner.ts index ba4395b9..e8e89203 100644 --- a/src/browser/run/runner.ts +++ b/src/browser/run/runner.ts @@ -264,7 +264,7 @@ export async function runBrowserProgram( name !== 'writeArtifact' || typeof args[0] !== 'string' || typeof args[1] !== 'string' - || (args[2] !== undefined && typeof args[2] !== 'string') + || (args[2] != null && typeof args[2] !== 'string') ) { throw new BrowserRunError( 'BROWSER_RUN_INVALID_INPUT', @@ -440,12 +440,18 @@ export async function runBrowserProgram( globalThis.__webcmdTransportReceive = message => { connection.dispatch(JSON.parse(message)); }; - globalThis.__webcmdWriteArtifact = async (filename, bytes, contentType) => { - await __webcmdHostCall( + globalThis.writeArtifact = async (filename, bytes, contentType) => ( + __webcmdHostCall( 'writeArtifact', - JSON.stringify([filename, __webcmdEncodeBase64(bytes), contentType]), - ); - }; + JSON.stringify([ + filename, + __webcmdEncodeBase64(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)), + contentType, + ]), + ) + ); + // Legacy alias; writeArtifact is the documented name. + globalThis.__webcmdWriteArtifact = globalThis.writeArtifact; __WebcmdPlaywrightClient.quickjsPlatform.fs().promises.readFile = () => ( unsupported('Host filesystem reads') ); diff --git a/src/skills.test.ts b/src/skills.test.ts index ea19e4a8..ce2c0cbf 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -216,7 +216,8 @@ describe('webcmd skills content', () => { expect(browser).not.toContain('page.snapshotForAI()'); expect(browser).not.toContain('--snapshot-diff'); expect(browserRunReference).toMatch(/sandbox boundaries/i); - expect(browserRunReference).toMatch(/artifact paths/i); + expect(browserRunReference).toMatch(/artifacts/i); + expect(browserRunReference).toContain("writeArtifact("); expect(browserRunReference).toMatch(/errors/i); expect(browserRunReference).toMatch(/snapshot behavior/i); expect(browserRunReference).toContain('--snapshot-mode act|tree');