From 92eacdafcc5a2b50e972dc9460c09b75e5709b71 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 14 Aug 2026 23:59:37 -0400 Subject: [PATCH] feat(browser): add artifact-backed viewport capture --- README.md | 1 + docs/public-api-contract.md | 12 + .../assets/browser-runtime.js | 212 ++++++++++++++++++ tests/browser-sdk-facade.test.ts | 91 ++++++++ 4 files changed, 316 insertions(+) diff --git a/README.md b/README.md index cc7b246e..0ff49676 100644 --- a/README.md +++ b/README.md @@ -903,6 +903,7 @@ Browser run and artifact outputs use one product-safe DTO lane: - `wp-codebox/browser-artifact-persistence/ref/v1` reports persisted browser artifact data and canonical `artifactRefs`. Older `wp-codebox/browser-artifact-persistence-projection/v1` inputs are still accepted by helpers for compatibility, but new consumer-facing output uses the `/ref/v1` schema. - `window.wpCodeboxBrowser.v1.normalizeBrowserRunResult()` and `browserArtifactPersistenceRef()` normalize legacy browser runner/materialization variants into those DTOs. - `window.wpCodeboxBrowser.v1.createBrowserConnectorRequest()` builds the public `wp-codebox/browser-connector-request/v1` envelope for connector-scoped browser calls. `executeBrowserConnectorRequest()` accepts that envelope and adapts it through Codebox's internal provider bridge; product callers should not construct `wp-codebox/browser-provider-proxy-request/v1` directly. +- `window.wpCodeboxBrowser.v1.captureViewportScreenshot(client, { route, viewport, timeout_ms }, { browserInvoker, session? })` routes a requested route and desktop or mobile viewport through the generic browser invoker, then persists PNG evidence through the Codebox-owned `wp-codebox/persist-browser-artifact` ability. It returns canonical `wp-codebox/browser-artifact-ref/v1` data, including bundle id, immutable artifact path, and SHA-256. `verifyViewportScreenshot(evidence)` calls `wp-codebox/inspect-artifact` server-side and requires the artifact manifest checksum to match before evidence can be consumed. Explicit persistence and verification adapters are optional test/extensibility seams. - Non-browser TypeScript consumers should use the equivalent public DTO helpers from `@automattic/wp-codebox-core/public`: `normalizeBrowserRunResult()`, `browserRunResultEnvelope()`, `browserArtifactPersistenceProjection()`, `persistedBrowserArtifactRefs()`, and `artifactBundleFileManifest()`. `browser_runner.capture_paths` is the generic result-capture layer for browser materialization. Each entry names a sandbox-local file that the generated runner should read after the ability or hook returns. The runner writes `/tmp/wp-codebox-agent-result.json` with `wp-codebox/browser-materialization/v1`, normalized `success`/`status`/`error` fields, invocation metadata, the raw response, and captured files as `wp-codebox/browser-capture/v1` records. JSON files are decoded into `json`, text files into `content`, and binary files into `content_base64`, bounded by `max_bytes`. diff --git a/docs/public-api-contract.md b/docs/public-api-contract.md index bff6e014..053ec414 100644 --- a/docs/public-api-contract.md +++ b/docs/public-api-contract.md @@ -95,6 +95,18 @@ startup and remains distinguishable as `browser_preview_aborted`; an accepted start result exposes idempotent async `dispose()` cleanup without changing its `client` field or result envelope. +`window.wpCodeboxBrowser.v1.captureViewportScreenshot()` is the generic visual +evidence primitive. It accepts a prepared client, an absolute route, viewport +width and height, and a bounded `timeout_ms`. The browser invoker receives +`wp-codebox/browser-invocation-request/v1` with operation `viewport-screenshot`. +Codebox persists returned PNG bytes through `wp-codebox/persist-browser-artifact` +and returns its canonical immutable bundle/file ref and SHA-256. +The resulting `wp-codebox/browser-viewport-screenshot/v1` envelope reports +`captured` or a non-passing `failed` state with diagnostics. Consumers call +`verifyViewportScreenshot(evidence)` before use; it calls the Codebox-owned +`wp-codebox/inspect-artifact` server-side verifier, which confirms the artifact +bundle and exact file SHA-256. Explicit adapters are optional test seams. + Consumer-facing WordPress abilities use the `wp-codebox/*` namespace. Public docs and schemas describe the canonical Codebox-owned names that integrations should call directly. diff --git a/packages/wordpress-plugin/assets/browser-runtime.js b/packages/wordpress-plugin/assets/browser-runtime.js index a27cc1aa..6777fa71 100644 --- a/packages/wordpress-plugin/assets/browser-runtime.js +++ b/packages/wordpress-plugin/assets/browser-runtime.js @@ -30,6 +30,8 @@ 'filesystem:ensure-directory', 'review:write-file', 'contract:probe', + 'browser-viewport:capture', + 'browser-viewport:verify', ] ); const safeName = ( name ) => String( name || 'task' ).replace( /[^a-z0-9_-]/gi, '-' ).toLowerCase(); @@ -238,6 +240,212 @@ } }; + const viewportScreenshotSchema = 'wp-codebox/browser-viewport-screenshot/v1'; + const viewportScreenshotVerificationSchema = 'wp-codebox/browser-viewport-screenshot-verification/v1'; + const viewportScreenshotMaxTimeoutMs = 60000; + const viewportScreenshotMaxBytes = 5242880; + + const viewportScreenshotDiagnostics = ( code, message, phase, metadata = undefined ) => [ Object.fromEntries( Object.entries( { + code, + message, + severity: 'error', + phase, + metadata, + } ).filter( ( [ , value ] ) => value !== undefined ) ) ]; + + const viewportScreenshotFailure = ( input, code, message, phase, metadata = undefined ) => ( { + schema: viewportScreenshotSchema, + success: false, + status: 'failed', + route: typeof input?.route === 'string' ? input.route : null, + viewport: isPlainObject( input?.viewport ) ? input.viewport : null, + artifact: null, + sha256: null, + diagnostics: viewportScreenshotDiagnostics( code, message, phase, metadata ), + } ); + + const viewportScreenshotInput = ( input ) => { + if ( ! isPlainObject( input ) || typeof input.route !== 'string' || ! input.route.startsWith( '/' ) || input.route.startsWith( '//' ) ) { + throw runtimeError( 'viewport_capture_validate', 'viewport_capture_route_invalid', 'Viewport capture requires a safe absolute route.' ); + } + const viewport = isPlainObject( input.viewport ) ? input.viewport : {}; + const width = Number( viewport.width ); + const height = Number( viewport.height ); + if ( ! Number.isInteger( width ) || ! Number.isInteger( height ) || width < 1 || height < 1 || width > 10000 || height > 10000 ) { + throw runtimeError( 'viewport_capture_validate', 'viewport_capture_viewport_invalid', 'Viewport capture requires width and height between 1 and 10000.' ); + } + const timeoutMs = input.timeout_ms === undefined ? 30000 : Number( input.timeout_ms ); + if ( ! Number.isInteger( timeoutMs ) || timeoutMs < 1 || timeoutMs > viewportScreenshotMaxTimeoutMs ) { + throw runtimeError( 'viewport_capture_validate', 'viewport_capture_timeout_invalid', `Viewport capture timeout_ms must be between 1 and ${ viewportScreenshotMaxTimeoutMs }.` ); + } + return { route: input.route, viewport: { width, height }, timeout_ms: timeoutMs }; + }; + + const withViewportScreenshotTimeout = async ( promise, timeoutMs ) => { + let timeout; + try { + return await Promise.race( [ + promise, + new Promise( ( resolve, reject ) => { + timeout = setTimeout( () => reject( runtimeError( 'viewport_capture', 'viewport_capture_timeout', 'Viewport screenshot capture timed out.', { timeout_ms: timeoutMs } ) ), timeoutMs ); + } ), + ] ); + } finally { + clearTimeout( timeout ); + } + }; + + const viewportScreenshotBytes = ( capture ) => { + const value = capture?.png_base64 ?? capture?.pngBase64 ?? capture?.data; + if ( typeof value !== 'string' || ! value ) { + throw runtimeError( 'viewport_capture', 'viewport_capture_png_missing', 'Viewport capture did not return PNG bytes.' ); + } + let binary; + try { + binary = atob( value ); + } catch ( error ) { + throw runtimeError( 'viewport_capture', 'viewport_capture_png_invalid', 'Viewport capture returned invalid base64 PNG bytes.' ); + } + if ( binary.length < 8 || binary.length > viewportScreenshotMaxBytes || binary.charCodeAt( 0 ) !== 137 || binary.slice( 1, 4 ) !== 'PNG' ) { + throw runtimeError( 'viewport_capture', 'viewport_capture_png_invalid', 'Viewport capture did not return a bounded PNG artifact.' ); + } + return { base64: value, bytes: binary.length }; + }; + + const viewportScreenshotArtifact = ( persisted ) => { + const persistedBundle = isPlainObject( persisted?.artifact_ref ) ? persisted : ( isPlainObject( persisted?.data ) ? persisted.data : null ); + if ( persistedBundle?.artifact_ref && Array.isArray( persistedBundle.files ) ) { + const file = persistedBundle.files.find( ( item ) => isPlainObject( item ) && typeof item.sha256?.value === 'string' && typeof item.artifact_path === 'string' ); + if ( ! file || typeof persistedBundle.artifact_ref.artifact_id !== 'string' || ! persistedBundle.artifact_ref.artifact_id ) { + throw runtimeError( 'viewport_persist', 'viewport_capture_artifact_invalid', 'Codebox artifact persistence did not return a canonical screenshot ref.' ); + } + return { + schema: persistedBundle.artifact_ref.schema || 'wp-codebox/browser-artifact-ref/v1', + artifact_id: persistedBundle.artifact_ref.artifact_id, + content_digest: persistedBundle.artifact_ref.content_digest, + artifacts_path: persistedBundle.artifact_ref.artifacts_path, + path: file.artifact_path, + kind: file.kind || 'browser-screenshot', + contentType: file.mime_type || 'image/png', + sha256: file.sha256.value.toLowerCase(), + }; + } + const artifact = isPlainObject( persisted?.artifact ) ? persisted.artifact : persisted; + const sha256 = artifact?.sha256?.value || artifact?.sha256 || artifact?.digest?.value || artifact?.digest; + if ( ! isPlainObject( artifact ) || typeof artifact.id !== 'string' || ! artifact.id || typeof artifact.path !== 'string' || ! artifact.path || typeof sha256 !== 'string' || ! /^[a-f0-9]{64}$/i.test( sha256 ) ) { + throw runtimeError( 'viewport_persist', 'viewport_capture_artifact_invalid', 'Artifact persistence must return an immutable artifact id, path, and SHA-256 checksum.' ); + } + return { + id: artifact.id, + path: artifact.path, + kind: typeof artifact.kind === 'string' && artifact.kind ? artifact.kind : 'browser-viewport-screenshot', + contentType: artifact.contentType || artifact.content_type || 'image/png', + sha256: sha256.toLowerCase(), + }; + }; + + const executeBrowserSdkAbility = async ( ability, input ) => { + if ( window.wp?.apiFetch ) { + return await window.wp.apiFetch( { path: abilityRestPath( ability ), method: 'POST', data: input } ); + } + if ( typeof fetch !== 'function' ) { + throw runtimeError( 'ability', 'browser_sdk_ability_fetch_unavailable', 'Browser fetch or wp.apiFetch is required to call a Codebox ability.' ); + } + const response = await fetch( abilityRestEndpoint( ability ), { method: 'POST', credentials: 'same-origin', headers: codeboxRestHeaders(), body: JSON.stringify( input ) } ); + const json = await response.json().catch( () => null ); + if ( ! response.ok || ! isPlainObject( json ) ) { + throw runtimeError( 'ability', 'browser_sdk_ability_failed', 'Codebox ability request failed.', { ability, status: response.status, response: json } ); + } + return json; + }; + + const persistViewportScreenshot = async ( request, png, capture, options ) => { + if ( typeof options.persistArtifact === 'function' ) { + return await options.persistArtifact( request ); + } + return await executeBrowserSdkAbility( 'wp-codebox/persist-browser-artifact', { + session_id: typeof options.session?.session_id === 'string' ? options.session.session_id : undefined, + caller_schema: viewportScreenshotSchema, + caller_kind: 'browser-viewport-screenshot', + caller_metadata: { route: request.route, viewport: request.viewport, diagnostics: normalizeBrowserRunDiagnostics( capture?.diagnostics ) }, + entrypoint: 'screenshot.png', + files: [ { path: 'screenshot.png', content_base64: png.base64, encoding: 'base64', mime_type: 'image/png', kind: 'browser-screenshot' } ], + } ); + }; + + const captureViewportScreenshot = async ( client, input = {}, options = {} ) => { + let request; + try { + request = viewportScreenshotInput( input ); + } catch ( error ) { + return viewportScreenshotFailure( input, error.code || 'viewport_capture_invalid', error.message, error.phase || 'viewport_capture_validate', error.data ); + } + if ( typeof options.browserInvoker !== 'function' ) { + return viewportScreenshotFailure( request, 'viewport_capture_unavailable', 'A generic browserInvoker adapter is required to capture viewport evidence.', 'viewport_capture' ); + } + try { + const capture = await withViewportScreenshotTimeout( options.browserInvoker( { + schema: 'wp-codebox/browser-invocation-request/v1', + operation: 'viewport-screenshot', + client, + ...request, + } ), request.timeout_ms ); + const png = viewportScreenshotBytes( capture ); + const persistenceRequest = { + schema: 'wp-codebox/browser-viewport-screenshot-persistence-request/v1', + route: request.route, + viewport: request.viewport, + content_type: 'image/png', + content_base64: png.base64, + bytes: png.bytes, + diagnostics: normalizeBrowserRunDiagnostics( capture?.diagnostics ), + }; + const persisted = await withViewportScreenshotTimeout( persistViewportScreenshot( persistenceRequest, png, capture, options ), request.timeout_ms ); + const artifact = viewportScreenshotArtifact( persisted ); + return { + schema: viewportScreenshotSchema, + success: true, + status: 'captured', + route: request.route, + viewport: request.viewport, + artifact, + sha256: artifact.sha256, + diagnostics: normalizeBrowserRunDiagnostics( capture?.diagnostics ), + }; + } catch ( error ) { + return viewportScreenshotFailure( request, error.code || 'viewport_capture_failed', error.message || 'Viewport screenshot capture failed.', error.phase || 'viewport_capture', error.data ); + } + }; + + const verifyViewportScreenshot = async ( evidence, options = {} ) => { + const failure = ( code, message, metadata = undefined ) => ( { + schema: viewportScreenshotVerificationSchema, + success: false, + status: 'failed', + evidence: evidence || null, + diagnostics: viewportScreenshotDiagnostics( code, message, 'viewport_verify', metadata ), + } ); + if ( evidence?.schema !== viewportScreenshotSchema || evidence?.status !== 'captured' || evidence?.success !== true || ! evidence?.artifact || typeof evidence.sha256 !== 'string' ) { + return failure( 'viewport_capture_evidence_invalid', 'Viewport evidence must be a successful captured evidence envelope.' ); + } + try { + const verification = typeof options.verifyArtifact === 'function' + ? await options.verifyArtifact( { schema: viewportScreenshotVerificationSchema, artifact: evidence.artifact, sha256: evidence.sha256 } ) + : await executeBrowserSdkAbility( 'wp-codebox/inspect-artifact', { artifact_id: evidence.artifact.artifact_id } ); + const verificationFile = Array.isArray( verification?.artifact?.changed_files?.files ) + ? verification.artifact.changed_files.files.find( ( file ) => file?.artifactPath === evidence.artifact.path ) + : null; + const verificationSha256 = verification?.sha256 || verificationFile?.sha256?.value || verificationFile?.sha256; + const verificationSuccess = typeof options.verifyArtifact === 'function' ? verification?.success === true && verification?.exists === true : verification?.success === true && verification?.verification?.valid === true && !! verificationFile; + if ( ! verificationSuccess || verificationSha256 !== evidence.sha256 ) { + return failure( verification?.exists === false ? 'viewport_capture_artifact_missing' : 'viewport_capture_checksum_mismatch', 'Server-side artifact verification did not confirm the captured SHA-256 checksum.', verification || null ); + } + return { schema: viewportScreenshotVerificationSchema, success: true, status: 'verified', evidence, diagnostics: normalizeBrowserRunDiagnostics( verification.diagnostics ) }; + } catch ( error ) { + return failure( error?.code || 'viewport_capture_verification_failed', error?.message || 'Server-side artifact verification failed.', error?.data ); + } + }; + const normalizeBrowserArtifactDigest = ( value ) => { if ( typeof value === 'string' && value ) { return { algorithm: 'sha256', value }; @@ -961,6 +1169,7 @@ const browserSdkContract = Object.freeze( [ { name: 'activateTheme' }, { name: 'browserSessionRecipe' }, + { name: 'captureViewportScreenshot', topLevelOrder: 20.5, topLevel: ( api ) => ( client, input = {}, options = {} ) => api.captureViewportScreenshot( client, input, options ) }, { name: 'createBrowserConnectorRequest', topLevelOrder: 16, topLevel: ( api ) => api.createBrowserConnectorRequest }, { name: 'executeBrowserConnectorRequest', topLevelOrder: 17, topLevel: ( api ) => api.executeBrowserConnectorRequest }, { name: 'executeBrowserProviderProxyRequest' }, @@ -990,6 +1199,7 @@ { name: 'runWordPressOperation' }, { name: 'selectPreparedBrowserBlueprint' }, { name: 'setFrontendAdminBarVisible', topLevelOrder: 22, topLevel: ( api ) => ( client, args = {}, options = {} ) => api.setFrontendAdminBarVisible( client, args, options ) }, + { name: 'verifyViewportScreenshot', topLevelOrder: 20.6, topLevel: ( api ) => ( evidence, options = {} ) => api.verifyViewportScreenshot( evidence, options ) }, { name: 'writeFile', topLevelOrder: 7, topLevel: ( api ) => ( client, args = {}, options = {} ) => api.writeFile( client, args, options ) }, { name: 'writeReviewFile' }, ] ); @@ -3327,6 +3537,7 @@ echo wp_json_encode( array( activateTheme, aggregateFanoutOutputs, browserSessionRecipe, + captureViewportScreenshot, bootExecutableBrowserSession, consumeContainedSiteSync, openOrCreateBrowserContainedSite, @@ -3350,6 +3561,7 @@ echo wp_json_encode( array( runWordPressOperation, selectPreparedBrowserBlueprint, setFrontendAdminBarVisible, + verifyViewportScreenshot, startBrowserPreview, validateBrowserRuntimeMaterialization, writeFile, diff --git a/tests/browser-sdk-facade.test.ts b/tests/browser-sdk-facade.test.ts index 1d57dd32..93e3e2e2 100644 --- a/tests/browser-sdk-facade.test.ts +++ b/tests/browser-sdk-facade.test.ts @@ -11,6 +11,7 @@ const previewFixture = JSON.parse(await readFile(new URL("contracts/browser-prod const sandbox = { window: { dispatchEvent: () => true } as { wpCodebox?: Record, wpCodeboxBrowser?: Record, wp?: Record, dispatchEvent?: (event: any) => boolean }, btoa: (value: string) => Buffer.from(value, "binary").toString("base64"), + atob: (value: string) => Buffer.from(value, "base64").toString("binary"), CustomEvent: class CustomEvent { type: string detail: unknown @@ -63,6 +64,8 @@ assert.deepEqual(plain(api.v1.info()), { "filesystem:ensure-directory", "review:write-file", "contract:probe", + "browser-viewport:capture", + "browser-viewport:verify", ], globals: { name: "wpCodeboxBrowser", @@ -105,6 +108,8 @@ const expectedV1TopLevelKeys = [ "createParentToolRequest", "dispatchParentTool", "runBrowserSessionRecipe", + "captureViewportScreenshot", + "verifyViewportScreenshot", "setFrontendAdminBarVisible", "methods", ] as const @@ -113,6 +118,7 @@ assert.deepEqual(Object.keys(api.v1), expectedV1TopLevelKeys, "wpCodeboxBrowser. const expectedV1MethodKeys = [ "activateTheme", "browserSessionRecipe", + "captureViewportScreenshot", "createBrowserConnectorRequest", "executeBrowserConnectorRequest", "executeBrowserProviderProxyRequest", @@ -142,6 +148,7 @@ const expectedV1MethodKeys = [ "runWordPressOperation", "selectPreparedBrowserBlueprint", "setFrontendAdminBarVisible", + "verifyViewportScreenshot", "writeFile", "writeReviewFile", ] as const @@ -166,6 +173,8 @@ assert.equal(api.v1.methods.validateBrowserRuntimeMaterialization, api.validateB assert.equal(typeof api.v1.setFrontendAdminBarVisible, "function") assert.equal(api.v1.methods.setFrontendAdminBarVisible, api.setFrontendAdminBarVisible) assert.equal(typeof api.v1.runBrowserSessionRecipe, "function") +assert.equal(typeof api.v1.captureViewportScreenshot, "function") +assert.equal(typeof api.v1.verifyViewportScreenshot, "function") assert.equal(typeof api.v1.startBrowserPreview, "function") assert.equal(typeof api.v1.consumeContainedSiteSync, "function") assert.equal(typeof api.v1.openOrCreateBrowserContainedSite, "function") @@ -191,6 +200,88 @@ assert.equal(typeof api.v1.createParentToolRequest, "function") assert.equal(typeof api.v1.validateBrowserRuntimeMaterialization, "function") assert.equal(typeof api.v1.createRuntimeTaskRequest, "function") assert.equal(typeof api.v1.runRuntimeTask, "function") + +const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64") +let viewportInvocation: Record | undefined +const captured = await api.v1.captureViewportScreenshot({}, { + route: "/example", + viewport: { width: 1440, height: 900 }, + timeout_ms: 50, +}, { + browserInvoker: async (request: Record) => { + viewportInvocation = request + return { png_base64: png, diagnostics: [{ code: "browser_ready", message: "Browser is ready.", severity: "info" }] } + }, + persistArtifact: async () => ({ artifact: { id: "viewport-1", path: "files/browser/example.png", sha256: "a".repeat(64) } }), +}) +assert.deepEqual(plain(viewportInvocation), { + schema: "wp-codebox/browser-invocation-request/v1", + operation: "viewport-screenshot", + client: {}, + route: "/example", + viewport: { width: 1440, height: 900 }, + timeout_ms: 50, +}) +assert.deepEqual(plain(captured), { + schema: "wp-codebox/browser-viewport-screenshot/v1", + success: true, + status: "captured", + route: "/example", + viewport: { width: 1440, height: 900 }, + artifact: { id: "viewport-1", path: "files/browser/example.png", kind: "browser-viewport-screenshot", contentType: "image/png", sha256: "a".repeat(64) }, + sha256: "a".repeat(64), + diagnostics: [{ code: "browser_ready", message: "Browser is ready.", severity: "info" }], +}) +const verified = await api.v1.verifyViewportScreenshot(captured, { + verifyArtifact: async () => ({ success: true, exists: true, sha256: "a".repeat(64) }), +}) +assert.equal(verified.status, "verified") +assert.equal(verified.success, true) + +const routeFailure = await api.v1.captureViewportScreenshot({}, { route: "https://example.test", viewport: { width: 375, height: 667 } }, {}) +assert.equal(routeFailure.status, "failed") +assert.equal(routeFailure.diagnostics[0].code, "viewport_capture_route_invalid") +const timeoutFailure = await api.v1.captureViewportScreenshot({}, { route: "/slow", viewport: { width: 375, height: 667 }, timeout_ms: 1 }, { + browserInvoker: async () => await new Promise(() => undefined), + persistArtifact: async () => ({ artifact: { id: "unused", path: "unused.png", sha256: "a".repeat(64) } }), +}) +assert.equal(timeoutFailure.status, "failed") +assert.equal(timeoutFailure.diagnostics[0].code, "viewport_capture_timeout") +const missingArtifact = await api.v1.verifyViewportScreenshot(captured, { + verifyArtifact: async () => ({ success: false, exists: false, sha256: "a".repeat(64) }), +}) +assert.equal(missingArtifact.status, "failed") +assert.equal(missingArtifact.diagnostics[0].code, "viewport_capture_artifact_missing") +const checksumMismatch = await api.v1.verifyViewportScreenshot(captured, { + verifyArtifact: async () => ({ success: true, exists: true, sha256: "b".repeat(64) }), +}) +assert.equal(checksumMismatch.status, "failed") +assert.equal(checksumMismatch.diagnostics[0].code, "viewport_capture_checksum_mismatch") +const previousViewportAbilityWp = sandbox.window.wp +const viewportAbilityRequests: any[] = [] +sandbox.window.wp = { + apiFetch: async (request: any) => { + viewportAbilityRequests.push(request) + if (request.path === "/wp-abilities/v1/abilities/wp-codebox/persist-browser-artifact/run") { + return { + schema: "wp-codebox/browser-persisted-artifact-bundle/v1", + artifact_ref: { schema: "wp-codebox/browser-artifact-ref/v1", artifact_id: "bundle-1", content_digest: "c".repeat(64), artifacts_path: "/artifacts/bundle-1" }, + files: [{ artifact_path: "files/browser/screenshot.png", kind: "browser-screenshot", mime_type: "image/png", sha256: { algorithm: "sha256", value: "c".repeat(64) } }], + } + } + return { success: true, artifact: { changed_files: { files: [{ artifactPath: "files/browser/screenshot.png", sha256: { algorithm: "sha256", value: "c".repeat(64) } }] } }, verification: { valid: true } } + }, +} +const ownedCapture = await api.v1.captureViewportScreenshot({}, { route: "/owned", viewport: { width: 375, height: 667 } }, { browserInvoker: async () => ({ png_base64: png }) }) +assert.equal(ownedCapture.success, true) +assert.equal(ownedCapture.artifact.artifact_id, "bundle-1") +assert.equal(ownedCapture.artifact.sha256, "c".repeat(64)) +assert.equal((await api.v1.verifyViewportScreenshot(ownedCapture)).status, "verified") +assert.deepEqual(plain(viewportAbilityRequests.map((request) => request.path)), [ + "/wp-abilities/v1/abilities/wp-codebox/persist-browser-artifact/run", + "/wp-abilities/v1/abilities/wp-codebox/inspect-artifact/run", +]) +sandbox.window.wp = previousViewportAbilityWp assert.deepEqual(plain(api.v1.normalizeError(Object.assign(new Error("Nope"), { code: "demo_error", phase: "probe", status: 418, data: { demo: true } }))), { schema: "wp-codebox/browser-sdk-error/v1", code: "demo_error",