diff --git a/CHANGELOG.md b/CHANGELOG.md index f5cfed4..8dbd61a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 3.0.1 + +- Include the conventional `tests/opencloud.e2e.js` source in deterministic + bundles, validate its bounded test contract locally, and expose its immutable + SHA-256 metadata. +- Require exact-revision external E2E evidence during `app dev verify`, with an + optional bounded parallelism override, so a missing specification fails + closed instead of producing a legacy receipt. +- Generate new projects with a deliberately failing external E2E starter that + agents must replace with the app's primary outcomes before promotion. + ## 3.0.0 - Vendor the hard-cut OpenCloud browser SDK 2.0.0 and final schema-2 manifest diff --git a/README.md b/README.md index 343aab0..f7fe005 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,12 @@ offline source bundle, but cannot connect to or deploy through OpenCloud. ## Install a pinned release -OpenCloud application skills pin an exact CLI release. To install `v3.0.0` in +OpenCloud application skills pin an exact CLI release. To install `v3.0.1` in an isolated task directory: ```bash -OPENCLOUD_CLI_VERSION="v3.0.0" -OPENCLOUD_CLI_PACKAGE="opencloud-cli-3.0.0.tgz" +OPENCLOUD_CLI_VERSION="v3.0.1" +OPENCLOUD_CLI_PACKAGE="opencloud-cli-3.0.1.tgz" OPENCLOUD_CLI_DIR="$(mktemp -d)" curl -fsSLo "$OPENCLOUD_CLI_DIR/$OPENCLOUD_CLI_PACKAGE" \ @@ -169,7 +169,7 @@ Use the stable capability preview and isolated migration-replayed database befor --id "$ITEM_ID" --values '{"title":"Updated preview item"}' "$OPENCLOUD_CLI" app dev invoke . function-name --body '{"example":true}' "$OPENCLOUD_CLI" app dev requests . -"$OPENCLOUD_CLI" app dev verify . +"$OPENCLOUD_CLI" app dev verify . --parallelism 5 "$OPENCLOUD_CLI" app dev promote . --idempotency-key "$IDEMPOTENCY_KEY" "$OPENCLOUD_CLI" app dev receipts . "$OPENCLOUD_CLI" app dev evidence . @@ -182,7 +182,10 @@ configured required values remain unavailable and optional values may be absent. Functions imported from `@opencloud/server` remain dormant until `app dev invoke` or a deliberate preview interaction calls them. Exact-revision verification requires -every declared Function to have a successful explicit invocation. +every declared Function to have a successful explicit invocation and runs the +immutable `tests/opencloud.e2e.js` specification. The conventional test source +stays outside `frontend.directory`, is included in the deterministic artifact, +and must use only the bounded `@opencloud/test` UI fixtures. `app dev promote` is the completion path: it deploys only the verified receipt, follows the durable production operation, runs feature-aware production diff --git a/package-lock.json b/package-lock.json index 56cb788..27c49e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opencloud/cli", - "version": "3.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opencloud/cli", - "version": "3.0.0", + "version": "3.0.1", "dependencies": { "@napi-rs/keyring": "1.3.0" }, diff --git a/package.json b/package.json index 8ef37d2..24de6d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opencloud/cli", - "version": "3.0.0", + "version": "3.0.1", "description": "Versioned command-line client for building, deploying, and verifying OpenCloud applications", "type": "module", "bin": { diff --git a/src/bundle.test.ts b/src/bundle.test.ts index 9b33610..24a1288 100644 --- a/src/bundle.test.ts +++ b/src/bundle.test.ts @@ -1,9 +1,14 @@ +import { createHash } from "node:crypto"; import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import * as tar from "tar"; -import { buildBundle } from "./bundle.js"; +import { + buildBundle, + OPEN_CLOUD_E2E_TEST_MAX_BYTES, + OPEN_CLOUD_E2E_TEST_PATH, +} from "./bundle.js"; const temporary: string[] = []; @@ -330,3 +335,181 @@ functions: ); }); }); + +describe("conventional OpenCloud E2E tests", () => { + const validE2eSource = `import { expect, test } from "@opencloud/test"; + +test("creates a record through the UI", async ({ ownerPage }) => { + await ownerPage.getByRole("button", { name: "Add item" }).click(); + await expect(ownerPage.getByRole("status")).toHaveText("Item added"); +}); +`; + + it("keeps legacy bundles unchanged when the conventional spec is absent", async () => { + const directory = await e2eFixture(); + + const bundle = await buildBundle(directory); + + expect(bundle.e2eTest).toBeUndefined(); + expect(bundle.files).not.toContain(OPEN_CLOUD_E2E_TEST_PATH); + expect(bundle.sourceFiles).not.toContain(OPEN_CLOUD_E2E_TEST_PATH); + }); + + it("includes a valid conventional spec and exposes bounded immutable metadata", async () => { + const directory = await e2eFixture(validE2eSource); + + const first = await buildBundle(directory); + const second = await buildBundle(directory); + + expect(first.files).toContain(OPEN_CLOUD_E2E_TEST_PATH); + expect(first.sourceFiles).toContain(OPEN_CLOUD_E2E_TEST_PATH); + expect(first.e2eTest).toEqual({ + path: OPEN_CLOUD_E2E_TEST_PATH, + source: validE2eSource, + sha256: createHash("sha256").update(validE2eSource).digest("hex"), + }); + expect(second.sha256).toBe(first.sha256); + expect(second.archive).toEqual(first.archive); + expect(second.e2eTest).toEqual(first.e2eTest); + }); + + it.each([".", "tests"])( + "rejects an E2E spec inside public frontend directory %s", + async (frontendDirectory) => { + const directory = await e2eFixture(validE2eSource); + await writeManifest( + directory, + ` +frontend: + directory: ${frontendDirectory} + spa: true +runtime: + sdk: + version: 2.0.0 +`, + ); + + await expect(buildBundle(directory)).rejects.toThrow( + /must stay outside frontend\.directory/, + ); + }, + ); + + it("rejects a spec over the bounded source size", async () => { + const oversized = `${validE2eSource}\n/*${"x".repeat(OPEN_CLOUD_E2E_TEST_MAX_BYTES)}*/`; + const directory = await e2eFixture(oversized); + + await expect(buildBundle(directory)).rejects.toThrow( + new RegExp(`exceeds ${OPEN_CLOUD_E2E_TEST_MAX_BYTES} bytes`), + ); + }); + + it.each([ + [ + "a package subpath", + 'import { test } from "@opencloud/test/fixtures";\ntest("flow", async () => {});\n', + ], + [ + "an unrelated package", + 'import { test } from "@playwright/test";\ntest("flow", async () => {});\n', + ], + [ + "a default import", + 'import test from "@opencloud/test";\ntest("flow", async () => {});\n', + ], + [ + "a second import", + 'import { test, expect } from "@opencloud/test";\nimport value from "./helper.js";\ntest("flow", async () => expect(value));\n', + ], + ])("rejects %s instead of the exact test import", async (_label, source) => { + const directory = await e2eFixture(source); + + await expect(buildBundle(directory)).rejects.toThrow( + /exactly one named import from "@opencloud\/test"/, + ); + }); + + it("requires the named test import", async () => { + const directory = await e2eFixture( + 'import { expect } from "@opencloud/test";\nexpect(true).toBe(true);\n', + ); + + await expect(buildBundle(directory)).rejects.toThrow( + /must import exactly test and expect from "@opencloud\/test"/, + ); + }); + + it("requires at least one test declaration", async () => { + const directory = await e2eFixture( + 'import { test, expect } from "@opencloud/test";\ntest.describe("items", () => expect(items));\n', + ); + + await expect(buildBundle(directory)).rejects.toThrow( + /must declare at least one test/, + ); + }); + + it.each([ + ["test.skip", "test.skip"], + ["test.only", "test.only"], + ["test.describe.skip", "test.describe.skip"], + ["test.describe.only", "test.describe.only"], + ])("rejects %s", async (_label, call) => { + const directory = await e2eFixture( + `import { test, expect } from "@opencloud/test";\n${call}("flow", async () => expect(true));\ntest("required", async () => expect(true));\n`, + ); + + await expect(buildBundle(directory)).rejects.toThrow( + /cannot use skip or only/, + ); + }); + + it.each([ + ["fetch", "await fetch('/items')"], + ["XMLHttpRequest", "new XMLHttpRequest()"], + ["browser evaluation", "await ownerPage.evaluate(() => true)"], + [ + "locator evaluation", + "await ownerPage.locator('main').evaluateAll(() => [])", + ], + ["network routing", "await ownerPage.route('**/*', () => {})"], + ["direct navigation", "await ownerPage.goto('https://example.test')"], + ["request context", "await request.get('/items')"], + ["backend route", 'const backend = "/rest/v1/items"'], + ])("rejects direct %s access", async (_label, expression) => { + const directory = await e2eFixture( + `import { test, expect } from "@opencloud/test";\ntest("flow", async ({ ownerPage, request }) => { ${expression}; expect(ownerPage); });\n`, + ); + + await expect(buildBundle(directory)).rejects.toThrow( + /cannot (?:use|access)/, + ); + }); +}); + +async function e2eFixture(source?: string): Promise { + const directory = await temporaryDirectory(); + await mkdir(path.join(directory, "frontend"), { recursive: true }); + await Promise.all([ + writeManifest( + directory, + ` +frontend: + directory: frontend + spa: true +runtime: + sdk: + version: 2.0.0 +`, + ), + writeFile( + path.join(directory, "frontend/index.html"), + "Test", + ), + ]); + if (source !== undefined) { + await mkdir(path.join(directory, "tests"), { recursive: true }); + await writeFile(path.join(directory, OPEN_CLOUD_E2E_TEST_PATH), source); + } + return directory; +} diff --git a/src/index.ts b/src/index.ts index d6d8400..c5d3794 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ import { openBrowser, revokeAccountCredential, } from "./account-auth.js"; -import { buildBundle } from "./bundle.js"; +import { buildBundle, OPEN_CLOUD_E2E_TEST_PATH } from "./bundle.js"; import { CredentialStore } from "./credential-store.js"; import { doctorDiagnostics } from "./doctor.js"; import { devDataRequest, type DevDataAction } from "./dev-data.js"; @@ -41,7 +41,7 @@ import { resolveWorkspaceFile, } from "./workspace-store.js"; -const CLI_VERSION = "3.0.0"; +const CLI_VERSION = "3.0.1"; const program = new Command() .name("opencloud") @@ -1185,6 +1185,9 @@ dev cliVersion: CLI_VERSION, appId: bundle.manifest.appId, localArtifactSha256: bundle.sha256, + e2eTest: bundle.e2eTest + ? { path: bundle.e2eTest.path, sha256: bundle.e2eTest.sha256 } + : null, localSession: localState ? { sessionId: localState.sessionId, @@ -1203,11 +1206,30 @@ dev "Run Chromium and primary-flow checks for the exact dev revision", ) .argument("[directory]", "app source directory", ".") - .action(async (directory) => { + .option( + "--parallelism ", + "number of isolated external E2E tests to run concurrently (1-10)", + ) + .action(async (directory, options) => { const state = await requireDevState(callerPath(directory)); + const parallelism = + options.parallelism === undefined + ? undefined + : Number(options.parallelism); + if ( + parallelism !== undefined && + (!Number.isInteger(parallelism) || parallelism < 1 || parallelism > 10) + ) { + throw new Error("--parallelism must be an integer between 1 and 10"); + } const result = await client().call("verifyDevSession", { appId: state.appId, sessionId: state.sessionId, + body: { + requireInteractionContract: true, + requireExternalE2eSpec: true, + ...(parallelism === undefined ? {} : { parallelism }), + }, }); output(result); if (!result.receipt.summary.passed) process.exitCode = 1; @@ -1224,6 +1246,17 @@ dev .action(async (directory, options) => { const sourceRoot = callerPath(directory); const state = await requireDevState(sourceRoot); + const bundle = await buildBundle(sourceRoot); + if (!bundle.e2eTest) { + throw new Error( + `${OPEN_CLOUD_E2E_TEST_PATH} is required before promotion. Add the external E2E specification, sync, invoke every Function, and verify the exact revision.`, + ); + } + if (bundle.sha256 !== state.artifactSha256) { + throw new Error( + "Local source differs from the active development revision. Run app dev sync, invoke every Function, and verify again before promotion.", + ); + } const control = client(); const result = await control.call( "promoteDevRevision", @@ -1461,6 +1494,7 @@ program } await mkdir(path.join(root, "frontend"), { recursive: true }); await mkdir(path.join(root, "migrations"), { recursive: true }); + await mkdir(path.join(root, "tests"), { recursive: true }); await writeFile( path.join(root, "frontend", "index.html"), ` @@ -1514,6 +1548,31 @@ program }), { flag: "wx" }, ); + await writeFile( + path.join(root, ...OPEN_CLOUD_E2E_TEST_PATH.split("/")), + `import { test, expect } from "@opencloud/test"; + +test("REQ-001 replace this with the app's primary outcome", async ({ + page, + uniqueValue, + clickIfVisible, +}) => { + const value = uniqueValue("Primary outcome", 60); + const scope = page.getByRole("main"); + try { + await scope + .getByRole("button", { name: "Replace this test", exact: true }) + .click(); + await expect(scope.getByText(value, { exact: true })).toBeVisible(); + } finally { + await clickIfVisible( + page.getByRole("button", { name: "Cancel", exact: true }), + ); + } +}); +`, + { flag: "wx" }, + ); output({ directory: root, manifest: path.join(root, "opencloud.yaml") }); }); @@ -1614,6 +1673,9 @@ program cron: bundle.manifest.cron.filter((item) => item.enabled).length, secrets: bundle.manifest.secrets, files: bundle.files, + e2eTest: bundle.e2eTest + ? { path: bundle.e2eTest.path, sha256: bundle.e2eTest.sha256 } + : null, warnings: bundle.warnings, archivePath, next: archivePath @@ -1659,6 +1721,9 @@ program artifactSha256: bundle.sha256, artifactBytes: bundle.archive.byteLength, files: bundle.files, + e2eTest: bundle.e2eTest + ? { path: bundle.e2eTest.path, sha256: bundle.e2eTest.sha256 } + : null, warnings: bundle.warnings, }); }); diff --git a/vendor/bundler/src/index.ts b/vendor/bundler/src/index.ts index c7ae12e..ec9bc40 100644 --- a/vendor/bundler/src/index.ts +++ b/vendor/bundler/src/index.ts @@ -54,9 +54,16 @@ export interface BuiltBundle { files: string[]; sourceManifest: string; sourceFiles: string[]; + e2eTest?: BuiltE2eTest; warnings: BundleWarning[]; } +export interface BuiltE2eTest { + path: typeof OPEN_CLOUD_E2E_TEST_PATH; + source: string; + sha256: string; +} + export interface BundleWarning { code: | "UNDECLARED_MIGRATION_FILE" @@ -81,6 +88,9 @@ const manifestNames = [ "opencloud.json", ] as const; +export const OPEN_CLOUD_E2E_TEST_PATH = "tests/opencloud.e2e.js" as const; +export const OPEN_CLOUD_E2E_TEST_MAX_BYTES = 64 * 1024; + function comparePaths(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } @@ -136,6 +146,10 @@ export async function buildBundle( } const manifest = parseManifest(raw); const selection = await selectBundleFiles(root, manifest, manifestFile); + const e2eTest = await selectE2eTest(root, selection, manifestFile); + if (e2eTest) { + assertE2eTestOutsideFrontend(manifest.frontend.directory); + } const warnings = [ ...(await findUndeclaredConventionalFiles(root, manifest)), ...(await findFrontendSdkWarnings(manifest, selection)), @@ -194,6 +208,7 @@ export async function buildBundle( files, sourceManifest, sourceFiles, + ...(e2eTest ? { e2eTest } : {}), warnings, }; } finally { @@ -201,6 +216,218 @@ export async function buildBundle( } } +export function assertE2eTestOutsideFrontend( + frontendDirectory: string, +): void { + const relative = path.posix.relative( + frontendDirectory, + OPEN_CLOUD_E2E_TEST_PATH, + ); + const overlaps = + relative === "" || + (relative !== ".." && + !relative.startsWith("../") && + !path.posix.isAbsolute(relative)); + if (overlaps) { + throw new Error( + `${OPEN_CLOUD_E2E_TEST_PATH} must stay outside frontend.directory so verification source is never served publicly`, + ); + } +} + +async function selectE2eTest( + root: string, + selection: BundleSelection, + manifestFile: string, +): Promise { + const absoluteFile = resolveBundlePath( + root, + OPEN_CLOUD_E2E_TEST_PATH, + "OpenCloud E2E test", + ); + let info; + try { + info = await lstat(absoluteFile); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + await assertNoSymlinkComponents(root, absoluteFile, "OpenCloud E2E test"); + if (info.isSymbolicLink()) { + throw new Error("OpenCloud E2E test cannot be a symlink"); + } + if (!info.isFile()) { + throw new Error("OpenCloud E2E test is not a regular file"); + } + if (info.size > OPEN_CLOUD_E2E_TEST_MAX_BYTES) { + throw new Error( + `OpenCloud E2E test exceeds ${OPEN_CLOUD_E2E_TEST_MAX_BYTES} bytes: ${OPEN_CLOUD_E2E_TEST_PATH}`, + ); + } + + const bytes = await readFile(absoluteFile); + const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + validateE2eTestSource(source); + addSelectedFile(root, absoluteFile, selection, manifestFile); + return { + path: OPEN_CLOUD_E2E_TEST_PATH, + source, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; +} + +function validateE2eTestSource(source: string): void { + const code = maskJavaScriptCommentsAndLiterals(source); + const imports = [ + ...source.matchAll( + /\bimport\s*\{([\s\S]*?)\}\s*from\s*(["'])([^"']+)\2\s*;?/g, + ), + ].filter((match) => { + const index = match.index ?? -1; + return index >= 0 && code.slice(index, index + 6) === "import"; + }); + const importTokenCount = code.match(/\bimport\b/g)?.length ?? 0; + if ( + imports.length !== 1 || + importTokenCount !== 1 || + imports[0]?.[3] !== "@opencloud/test" + ) { + throw new Error( + `OpenCloud E2E test must have exactly one named import from "@opencloud/test": ${OPEN_CLOUD_E2E_TEST_PATH}`, + ); + } + const importedNames = (imports[0]?.[1] ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + if ( + importedNames.length !== 2 || + !importedNames.includes("test") || + !importedNames.includes("expect") + ) { + throw new Error( + `OpenCloud E2E test must import exactly test and expect from "@opencloud/test": ${OPEN_CLOUD_E2E_TEST_PATH}`, + ); + } + if (!/\btest\s*\(/.test(code)) { + throw new Error( + `OpenCloud E2E test must declare at least one test(...): ${OPEN_CLOUD_E2E_TEST_PATH}`, + ); + } + if ( + /\b(?:test|describe)(?:\s*[.]\s*[A-Za-z_$][\w$]*)*\s*[.]\s*(?:skip|only)\s*\(/.test( + code, + ) + ) { + throw new Error( + `OpenCloud E2E test cannot use skip or only: ${OPEN_CLOUD_E2E_TEST_PATH}`, + ); + } + if (/\bimport\s*\(/.test(code)) { + throw new Error( + `OpenCloud E2E test cannot use dynamic imports: ${OPEN_CLOUD_E2E_TEST_PATH}`, + ); + } + + const forbiddenPatterns: Array<[RegExp, string]> = [ + [/\bfetch\s*\(/, "fetch"], + [/\b(?:XMLHttpRequest|WebSocket|EventSource)\b/, "direct network APIs"], + [/\bnavigator\s*[.]\s*sendBeacon\s*\(/, "sendBeacon"], + [ + /[.]\s*(?:evaluate|evaluateAll|evaluateHandle|\$eval|\$\$eval)\s*\(/, + "browser evaluation", + ], + [/[.]\s*(?:route|unroute|routeFromHAR)\s*\(/, "network routing"], + [ + /[.]\s*(?:goto|setContent|addInitScript|addScriptTag|exposeBinding|exposeFunction)\s*\(/, + "direct page or script injection", + ], + [ + /\brequest\s*[.]\s*(?:delete|fetch|get|head|patch|post|put)\s*\(/, + "direct request access", + ], + ]; + for (const [pattern, label] of forbiddenPatterns) { + if (pattern.test(code)) { + throw new Error( + `OpenCloud E2E test cannot use ${label}; drive the app through the bounded @opencloud/test UI fixtures: ${OPEN_CLOUD_E2E_TEST_PATH}`, + ); + } + } + if (/\/(?:rest|storage|functions)\/v1(?:\/|\b)/.test(source)) { + throw new Error( + `OpenCloud E2E test cannot access platform backend routes directly; drive the app through the bounded @opencloud/test UI fixtures: ${OPEN_CLOUD_E2E_TEST_PATH}`, + ); + } +} + +function maskJavaScriptCommentsAndLiterals(source: string): string { + let output = ""; + let index = 0; + let state: + | "code" + | "line-comment" + | "block-comment" + | "single-quote" + | "double-quote" + | "template" = "code"; + while (index < source.length) { + const character = source[index] ?? ""; + const next = source[index + 1] ?? ""; + if (state === "code") { + if (character === "/" && next === "/") { + output += " "; + index += 2; + state = "line-comment"; + continue; + } + if (character === "/" && next === "*") { + output += " "; + index += 2; + state = "block-comment"; + continue; + } + if (character === "'") state = "single-quote"; + else if (character === '"') state = "double-quote"; + else if (character === "`") state = "template"; + output += state === "code" ? character : " "; + index += 1; + continue; + } + if (state === "line-comment") { + if (character === "\n") { + output += "\n"; + state = "code"; + } else output += " "; + index += 1; + continue; + } + if (state === "block-comment") { + if (character === "*" && next === "/") { + output += " "; + index += 2; + state = "code"; + } else { + output += character === "\n" ? "\n" : " "; + index += 1; + } + continue; + } + const terminator = + state === "single-quote" ? "'" : state === "double-quote" ? '"' : "`"; + if (character === "\\") { + output += " "; + if (index + 1 < source.length) output += next === "\n" ? "\n" : " "; + index += 2; + continue; + } + output += character === "\n" ? "\n" : " "; + index += 1; + if (character === terminator) state = "code"; + } + return output; +} + async function findFrontendSdkWarnings( manifest: OpenCloudManifest, selection: BundleSelection, diff --git a/vendor/contracts/src/control-plane.test.ts b/vendor/contracts/src/control-plane.test.ts index 9115b7e..f0e563f 100644 --- a/vendor/contracts/src/control-plane.test.ts +++ b/vendor/contracts/src/control-plane.test.ts @@ -182,12 +182,26 @@ describe("controlPlaneOperations", () => { expect( controlPlaneOperations.verifyDevSession.input.parse({ ...path, - body: { requireInteractionContract: true }, + body: { + requireInteractionContract: true, + requireExternalE2eSpec: true, + parallelism: 5, + }, }), ).toEqual({ ...path, - body: { requireInteractionContract: true }, + body: { + requireInteractionContract: true, + requireExternalE2eSpec: true, + parallelism: 5, + }, }); + expect(() => + controlPlaneOperations.verifyDevSession.input.parse({ + ...path, + body: { parallelism: 11 }, + }), + ).toThrow(); }); it("allows an empty draft-file selection and documents normalized dev data paths", () => { diff --git a/vendor/contracts/src/control-plane.ts b/vendor/contracts/src/control-plane.ts index 7d24907..eed90f8 100644 --- a/vendor/contracts/src/control-plane.ts +++ b/vendor/contracts/src/control-plane.ts @@ -1030,13 +1030,15 @@ export const controlPlaneOperations = { path: "/v1/apps/{appId}/dev-sessions/{sessionId}/verify", summary: "Verify a development revision", description: - "Runs Chromium, console, HTTP, and optional primary-flow checks and issues a receipt bound to the exact revision.", + "Runs Chromium, console, HTTP, and exact-revision external browser checks and issues a receipt bound to the exact revision.", auth: "bearer", scopes: ["app:deploy"], input: devSessionPath.extend({ body: z .object({ requireInteractionContract: z.boolean().optional(), + requireExternalE2eSpec: z.boolean().optional(), + parallelism: z.number().int().min(1).max(10).optional(), }) .optional(), }),