diff --git a/capsules/browser/browser/browser-input-surface.js b/capsules/browser/browser/browser-input-surface.js index 3672ec043..fe36b0b89 100644 --- a/capsules/browser/browser/browser-input-surface.js +++ b/capsules/browser/browser/browser-input-surface.js @@ -14,6 +14,7 @@ export function plainTextFromClipboardEvent(event) { } export function bindBrowserInputSurface({ + captureViewerOwnerGuard, copyRemoteClipboardToHost, friendlyOpenError, getCurrentPage, @@ -28,7 +29,7 @@ export function bindBrowserInputSurface({ unlockRemoteAudioFromGesture, }) { let wheelTimer = 0; - let wheelDelta = { x: 0, y: 0 }; + let wheelBatch = null; let touchPanState = null; let suppressSyntheticClickUntil = 0; const hostModifierState = { @@ -36,6 +37,18 @@ export function bindBrowserInputSurface({ meta: false, }; + function retireTouchPan() { + const retired = touchPanState; + // Drop ownership before releasing capture: lostpointercapture may run now. + touchPanState = null; + if (!retired) return null; + suppressSyntheticClickUntil = Date.now() + 700; + if (renderPanel.hasPointerCapture?.(retired.pointerId)) { + renderPanel.releasePointerCapture(retired.pointerId); + } + return retired; + } + function focusKeyboardCapture() { const target = keyboardCapture || renderPanel; target.focus({ preventScroll: true }); @@ -187,7 +200,10 @@ export function bindBrowserInputSurface({ }; } - function sendClickFromEvent(event) { + function sendClickFromEvent(event, ownerActive = captureViewerOwnerGuard()) { + if (!ownerActive()) { + return; + } unlockRemoteAudioFromGesture(); const point = browserPointFromEvent(event); if (!point) { @@ -199,7 +215,9 @@ export function bindBrowserInputSurface({ { type: "click", x: point.x, y: point.y }, { history: "push" }, ).catch((error) => { - showStatus(friendlyOpenError(error), { sticky: true }); + if (ownerActive()) { + showStatus(friendlyOpenError(error), { sticky: true }); + } }); } @@ -212,29 +230,60 @@ export function bindBrowserInputSurface({ } function queueWheelInput(point, deltaX, deltaY) { - wheelDelta.x += deltaX; - wheelDelta.y += deltaY; + if (wheelBatch && !wheelBatch.ownerActive()) { + window.clearTimeout(wheelTimer); + wheelBatch = null; + } + if (!wheelBatch) { + const ownerActive = captureViewerOwnerGuard(); + if (!ownerActive()) { + return; + } + wheelBatch = { ownerActive, x: 0, y: 0, point }; + } + const batch = wheelBatch; + batch.x += deltaX; + batch.y += deltaY; + batch.point = point; window.clearTimeout(wheelTimer); wheelTimer = window.setTimeout(() => { - const delta = wheelDelta; - wheelDelta = { x: 0, y: 0 }; + if (wheelBatch !== batch) { + return; + } + wheelTimer = 0; + wheelBatch = null; + if (!batch.ownerActive()) { + return; + } sendBrowserInput( { type: "wheel", - x: point?.x, - y: point?.y, - delta_x: delta.x, - delta_y: delta.y, + x: batch.point?.x, + y: batch.point?.y, + delta_x: batch.x, + delta_y: batch.y, }, { history: "replace" }, - ).catch((error) => - showStatus(friendlyOpenError(error), { sticky: true }), - ); + ).catch((error) => { + if (batch.ownerActive()) { + showStatus(friendlyOpenError(error), { sticky: true }); + } + }); }, WHEEL_FLUSH_MS); } + function isSyntheticTouchClick(event) { + // Touch and pen taps dispatch once from pointerup under the captured + // owner. Their later click event cannot capture a replacement owner. + if (event.pointerType === "touch" || event.pointerType === "pen" || + event.sourceCapabilities?.firesTouchEvents === true) return true; + // Older hosts report compatibility clicks without pointer provenance. + // A known mouse remains independent from that short touch fallback. + return event.pointerType !== "mouse" && Date.now() < suppressSyntheticClickUntil; + } + remoteVideo.addEventListener("click", (event) => { - if (Date.now() < suppressSyntheticClickUntil) { + if (isSyntheticTouchClick(event)) { event.preventDefault(); return; } @@ -243,7 +292,7 @@ export function bindBrowserInputSurface({ }); renderPanel.addEventListener("click", (event) => { - if (Date.now() < suppressSyntheticClickUntil) { + if (isSyntheticTouchClick(event)) { event.preventDefault(); return; } @@ -262,13 +311,24 @@ export function bindBrowserInputSurface({ if (event.pointerType !== "touch" && event.pointerType !== "pen") { return; } + // An owner that has retired cannot reserve the next page's gesture. + if (touchPanState && !touchPanState.ownerActive()) retireTouchPan(); + // The current owner keeps its first pointer until that gesture ends. + if (touchPanState) { + return; + } if (!getCurrentPage()) { return; } + const ownerActive = captureViewerOwnerGuard(); + if (!ownerActive()) { + return; + } event.preventDefault(); focusKeyboardCapture(); touchPanState = { pointerId: event.pointerId, + ownerActive, x: event.clientX, y: event.clientY, moved: false, @@ -283,12 +343,15 @@ export function bindBrowserInputSurface({ (event) => { if ( !touchPanState || - event.pointerId !== touchPanState.pointerId || - !getCurrentPage() + event.pointerId !== touchPanState.pointerId ) { return; } event.preventDefault(); + if (!touchPanState.ownerActive() || !getCurrentPage()) { + retireTouchPan(); + return; + } const dx = touchPanState.x - event.clientX; const dy = touchPanState.y - event.clientY; if (Math.abs(dx) < 1 && Math.abs(dy) < 1) { @@ -305,26 +368,28 @@ export function bindBrowserInputSurface({ renderPanel.addEventListener( "pointerup", (event) => { + // A retired finger can remain down longer than the fallback window. + // Renew it at release without taking ownership from a fresh pointer. + if (event.pointerType === "touch" || event.pointerType === "pen") { + suppressSyntheticClickUntil = Date.now() + 700; + } if (!touchPanState || event.pointerId !== touchPanState.pointerId) { return; } event.preventDefault(); - renderPanel.releasePointerCapture?.(event.pointerId); - const moved = touchPanState.moved; - touchPanState = null; - suppressSyntheticClickUntil = Date.now() + 700; + const { moved, ownerActive } = retireTouchPan(); if (!moved) { - sendClickFromEvent(event); + sendClickFromEvent(event, ownerActive); } }, { passive: false }, ); - renderPanel.addEventListener("pointercancel", (event) => { - if (touchPanState?.pointerId === event.pointerId) { - touchPanState = null; - } - }); + for (const type of ["pointercancel", "lostpointercapture"]) { + renderPanel.addEventListener(type, (event) => { + if (touchPanState?.pointerId === event.pointerId) retireTouchPan(); + }); + } renderPanel.addEventListener( "wheel", @@ -333,9 +398,12 @@ export function bindBrowserInputSurface({ return; } unlockRemoteAudioFromGesture(); + const point = browserPointFromEvent(event); + if (!point) { + return; + } event.preventDefault(); focusKeyboardCapture(); - const point = browserPointFromEvent(event); queueWheelInput(point, event.deltaX, event.deltaY); }, { passive: false }, diff --git a/capsules/browser/browser/browser-input-surface.test.mjs b/capsules/browser/browser/browser-input-surface.test.mjs index f1e716506..bfcd92c82 100644 --- a/capsules/browser/browser/browser-input-surface.test.mjs +++ b/capsules/browser/browser/browser-input-surface.test.mjs @@ -1,9 +1,167 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - plainTextFromClipboardEvent, -} from "./browser-input-surface.js"; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import vm from 'node:vm'; +import { execFileSync } from "node:child_process"; +import { plainTextFromClipboardEvent } from "./browser-input-surface.js"; +const sourceRef = process.env.BROWSER_INPUT_SURFACE_SOURCE_REF; +const { bindBrowserInputSurface } = sourceRef + ? await import(`data:text/javascript,${encodeURIComponent(execFileSync("git", ["show", + `${sourceRef}:capsules/browser/browser/browser-input-surface.js`], + { cwd: new URL("../../../", import.meta.url), encoding: "utf8" }))}`) + : await import("./browser-input-surface.js"); +import { runtimePageOwner, sameRuntimePageOwner } from "./browser-page-cleanup.js"; +// The encoder must follow an explicit historical source too: otherwise a +// negative control could silently exercise the fixed working-tree encoder. +const inputSourceRef = process.env.BROWSER_INPUT_COORDINATE_SOURCE_REF || sourceRef; +const { selkiesMessagesForInput } = inputSourceRef + ? await import(`data:text/javascript,${encodeURIComponent(execFileSync("git", ["show", + `${inputSourceRef}:capsules/browser/browser/browser-input.js`], + { cwd: new URL("../../../", import.meta.url), encoding: "utf8" }))}`) + : await import("./browser-input.js"); +// Execute the actual Browser binding, owner predicate, FIFO and encoder. The +// fixture replaces DOM/timers and transport effects, not their source contract. +const source = sourceRef + ? execFileSync("git", ["show", `${sourceRef}:capsules/browser/browser/browser.js`], + { cwd: new URL("../../../", import.meta.url), encoding: "utf8" }) + : readFileSync(new URL("./browser.js", import.meta.url), "utf8"); +function declaration(name) { + const start = source.search(new RegExp(`(?:async )?function ${name}\\(`)); + const end = source.slice(start).search(/\n\}(?=\n|$)/); + assert.ok(start >= 0 && end > 0, name); + return source.slice(start, start + end + 2); +} +function target() { + const listeners = new Map(); + return { listeners, hidden: false, videoWidth: 640, videoHeight: 360, + addEventListener(type, fn) { listeners.set(type, [...listeners.get(type) || [], fn]); }, + focus() {}, setSelectionRange() {}, closest() { return null; }, contains() { return false; }, + capturedPointers: new Set(), + setPointerCapture(id) { this.capturedPointers.add(id); }, + hasPointerCapture(id) { return this.capturedPointers.has(id); }, + releasePointerCapture(id) { + this.capturedPointers.delete(id); + for (const listener of listeners.get("lostpointercapture") || []) listener({ pointerId: id }); + }, + getBoundingClientRect() { return { left: 0, top: 0, width: 640, height: 360 }; }, + ownerDocument: { defaultView: { getComputedStyle: () => ({ objectFit: 'fill' }) } }, + }; +} +const plain = x => JSON.parse(JSON.stringify(x)); +function harness(t) { + const previousWindow = globalThis.window, previousDocument = globalThis.document; + const timers = new Map(), inputs = [], wire = [], errors = [], requests = []; + let now = 0, nextTimer = 0; + const panel = target(); + globalThis.window = Object.assign(target(), { + setTimeout(callback, delay) { timers.set(++nextTimer, { at: now + delay, callback }); return nextTimer; }, + clearTimeout(id) { timers.delete(id); }, + }); + globalThis.document = target(); + t.after(() => { globalThis.window = previousWindow; globalThis.document = previousDocument; }); + const state = vm.createContext({ + currentPage: { page_id: 'page:original', runtime_cleanup: { schema: 'elastos.browser.cleanup-handle/v1', id: 'cleanup:original' }, + display_session: { input: 'datachannel', input_protocol: 'selkies_v1', display_generation: 'display:original' } }, + currentPageGeneration: 1, currentDisplayMode: 'webrtc_remote_display', currentView: { width: 640, height: 360 }, + currentDisplayInput: 'datachannel', currentDisplayInputProtocol: 'selkies_v1', + unloadCleanupStarted: false, homeWindowCloseInFlight: false, homeWindowTerminalCloseConfirmed: false, + pendingHomeWindowCloseDelivery: null, cleanupPending: false, + runtimePageCleanup: { status() { return state.cleanupPending; } }, + runtimePageOwner, sameRuntimePageOwner, selkiesMessagesForInput, libraryPickerRequest: null, + PAGE_STATUS_AFTER_INPUT_DELAY_MS: 1, PAGE_STATUS_AFTER_SCROLL_DELAY_MS: 1, + remoteDisplay: { inputChannelOpen: () => true, sendInputMessages: messages => wire.push(...plain(messages)) }, + fetchJson: async (path, options) => { requests.push({ path, body: plain(options.body) }); return { accepted: true }; }, + recoverMissingRuntimePage() {}, handleFileChooserFromStatus() {}, syncViewFromResponse() {}, syncBrowserLocation() {}, + focusRemoteInput() {}, schedulePageStatusRefresh() {}, + }); + const names = ['currentRuntimePageOwner','runtimeViewerOwnerActive','currentInputTransport','currentInputProtocol','encodeDatachannelInput','sendBrowserInput','dispatchBrowserInput']; + vm.runInContext(source.match(/^let browserInputQueue = [^\n]+;$/m)[0] + '\n' + source.match(/^const MAX_PENDING_BROWSER_INPUTS = [0-9_]+;$/m)[0] + '\n' + names.map(declaration).join('\n'), state); + const actualSendBrowserInput = state.sendBrowserInput; + Object.assign(state, { + bindBrowserInputSurface, renderPanel: panel, remoteVideo: target(), keyboardCapture: target(), + sendBrowserInput: (event, options) => { + inputs.push({ at: now, owner: plain(state.currentRuntimePageOwner()), event }); + return actualSendBrowserInput(event, options); + }, + copyRemoteClipboardToHost: async () => {}, pasteHostClipboardIntoRemote: async () => {}, + homeClipboard: { readText: async () => "" }, + friendlyOpenError: error => error.message, showStatus: message => errors.push(message), + unlockRemoteAudioFromGesture() {}, + }); + const binding = source.match(/^bindBrowserInputSurface\(\{[\s\S]*?\n\}\);/m); + assert.ok(binding, "actual Browser surface binding must remain executable"); + vm.runInContext(binding[0], state); + async function settle() { for (let i = 0; i < 16; i++) await Promise.resolve(); } + async function advance(to) { + for (;;) { + const next = [...timers.entries()].filter(([, timer]) => timer.at <= to).sort((a,b) => a[1].at - b[1].at)[0]; + if (!next) break; + timers.delete(next[0]); now = next[1].at; next[1].callback(); await settle(); + } + now = to; await settle(); + } + function emit(type, event = {}, eventTarget = panel) { + for (const listener of eventTarget.listeners.get(type) || []) listener({ target: eventTarget, clientX: 20, clientY: 30, deltaX: 0, deltaY: 10, + ctrlKey: false, metaKey: false, altKey: false, preventDefault() {}, stopPropagation() {}, ...event }); + } + function replace(kind) { + state.currentPage = { ...state.currentPage, runtime_cleanup: { ...state.currentPage.runtime_cleanup } }; + if (kind === 'page') state.currentPage.page_id = 'page:replacement'; + if (kind === 'generation') state.currentPageGeneration++; + if (kind === 'cleanup') state.currentPage.runtime_cleanup.id = 'cleanup:replacement'; + } + return { state, timers, inputs, wire, errors, requests, emit, replace, advance, settle }; +} +test('unchanged owner keeps 80 ms trailing batch, raw axes, latest point and real encoded steps', async t => { + t.diagnostic('actual browser.js SHA-256 ' + createHash('sha256').update(source).digest('hex')); + const h = harness(t); + h.emit('wheel', { deltaX: 2, deltaY: 8 }); await h.advance(40); + h.replace('metadata'); h.state.currentPage.display_session = { ...h.state.currentPage.display_session, display_generation: 'display:renewed' }; + h.emit('wheel', { deltaX: 3, deltaY: 8, clientX: 25 }); await h.advance(119); + assert.equal(h.inputs.length, 0); await h.advance(120); + assert.deepEqual(h.inputs.map(x => [x.at, x.event]), [[120, { type: 'wheel', x: 25, y: 30, delta_x: 5, delta_y: 16 }]]); + assert.deepEqual(h.wire, ['m,25,30,8,1', 'm,25,30,0,0']); +}); +for (const kind of ['page','generation','cleanup']) test(`deferred wheel never reaches replacement ${kind}`, async t => { + const h = harness(t); h.emit('wheel'); h.replace(kind); await h.advance(80); + assert.deepEqual(h.wire, []); assert.deepEqual(h.requests, []); +}); +for (const flag of ['unloadCleanupStarted','homeWindowCloseInFlight','homeWindowTerminalCloseConfirmed','cleanupPending','pendingHomeWindowCloseDelivery']) test(`pending wheel stops while ${flag}`, async t => { + const h = harness(t); h.emit('wheel'); h.state[flag] = flag === 'pendingHomeWindowCloseDelivery' ? { owner: h.state.currentRuntimePageOwner() } : true; + await h.advance(80); assert.deepEqual(h.wire, []); +}); +test('replacement owner gets only its own new wheel delta', async t => { + const h = harness(t); h.emit('wheel', { deltaY: 80 }); await h.advance(10); h.replace('generation'); + h.emit('wheel', { deltaY: 80 }); await h.advance(90); + assert.deepEqual(h.wire, ['m,20,30,8,1','m,20,30,0,0']); +}); +test('touch gesture cannot cross owners through move/up or synthetic click', async t => { + const h = harness(t); h.emit('pointerdown', { pointerType: 'touch', pointerId: 1 }); h.replace('generation'); + h.emit('pointermove', { pointerId: 1, clientY: 10 }); h.emit('pointerup', { pointerId: 1 }); h.emit('click'); await h.advance(80); + assert.deepEqual(h.wire, []); assert.deepEqual(h.requests, []); +}); +test('old-owner tap cannot click the replacement page', async t => { + const h = harness(t); h.emit('pointerdown', { pointerType: 'touch', pointerId: 1 }); h.replace('cleanup'); + h.emit('pointerup', { pointerId: 1 }); h.emit('click'); await h.settle(); + assert.deepEqual(h.requests, []); +}); +test('fresh owner requires a new touch gesture, then preserves existing motion', async t => { + const h = harness(t); h.emit('pointerdown', { pointerType: 'touch', pointerId: 1 }); h.replace('generation'); + h.emit('pointermove', { pointerId: 1, clientY: 10 }); h.emit('pointerup', { pointerId: 1 }); + h.emit('pointerdown', { pointerType: 'touch', pointerId: 2, clientY: 30 }); + h.emit('pointermove', { pointerId: 2, clientY: 20 }); h.emit('pointerup', { pointerId: 2 }); await h.advance(80); + assert.deepEqual(h.inputs.map(x => x.event.delta_y), [10]); +}); +test('missing valid Runtime owner allocates no deferred input', async t => { + const h = harness(t); h.state.currentPage = { page_id: 'page:unowned' }; h.emit('wheel'); + h.emit('pointerdown', { pointerType: 'touch', pointerId: 1 }); + h.emit('pointermove', { pointerId: 1, clientY: 10 }); + h.emit('pointerup', { pointerId: 1 }); + h.emit('click'); + assert.equal(h.timers.size, 0); await h.advance(80); + assert.deepEqual(h.wire, []); assert.deepEqual(h.requests, []); +}); function clipboardEvent(types, values = {}) { return { @@ -52,3 +210,348 @@ test("missing or malformed ClipboardEvent data is rejected", () => { "", ); }); + + +test("a retired timer callback leaves the replacement batch intact", async t => { + const h = harness(t); + h.emit("wheel", { deltaY: 80 }); + const retired = [...h.timers.values()][0].callback; + await h.advance(10); + h.replace("generation"); + h.emit("wheel", { deltaY: 160 }); + retired(); + await h.settle(); + assert.deepEqual(h.wire, []); + await h.advance(90); + assert.deepEqual(h.wire, ["m,20,30,8,2", "m,20,30,0,0"]); +}); + +test("a late old-owner send failure leaves new-owner status and wheel untouched", async t => { + const h = harness(t); + const send = h.state.remoteDisplay.sendInputMessages; + h.state.remoteDisplay.sendInputMessages = () => { + queueMicrotask(() => { + h.replace("generation"); + h.emit("wheel", { deltaY: 160 }); + }); + h.state.remoteDisplay.sendInputMessages = send; + throw new Error("old channel failed"); + }; + h.emit("wheel"); + await h.advance(80); + assert.deepEqual(h.errors, []); + await h.advance(160); + assert.deepEqual(h.wire, ["m,20,30,8,2", "m,20,30,0,0"]); +}); + +test("current-owner uncertainty is reported once and fresh input can proceed", async t => { + const h = harness(t); + const send = h.state.remoteDisplay.sendInputMessages; + h.state.remoteDisplay.sendInputMessages = () => { throw new Error("channel failed"); }; + h.emit("wheel"); + await h.advance(80); + assert.deepEqual(h.errors, ["channel failed"]); + h.state.remoteDisplay.sendInputMessages = send; + h.emit("wheel", { deltaY: 160 }); + await h.advance(160); + assert.deepEqual(h.wire, ["m,20,30,8,2", "m,20,30,0,0"]); +}); + +for (const flag of ["homeWindowCloseInFlight", "unloadCleanupStarted"]) { + test(`an active touch cannot move or tap while ${flag}`, async t => { + const h = harness(t); + h.emit("pointerdown", { pointerType: "touch", pointerId: 1 }); + h.state[flag] = true; + h.emit("pointermove", { pointerId: 1, clientY: 10 }); + h.emit("pointerup", { pointerId: 1 }); + h.emit("click"); + await h.advance(80); + assert.deepEqual(h.wire, []); + assert.deepEqual(h.requests, []); + }); +} + +test("same-owner tap uses the existing Runtime click route once", async t => { + const h = harness(t); + h.emit("pointerdown", { pointerType: "pen", pointerId: 1 }); + h.replace("metadata"); + h.emit("pointerup", { pointerId: 1 }); + h.emit("click"); + await h.settle(); + assert.deepEqual(h.requests, [{ path: "/api/apps/browser/pages/page%3Aoriginal/input", + body: { event: { type: "click", x: 20, y: 30 } } }]); + assert.deepEqual(h.wire, []); +}); + +test("overlapping pointers retain the current owner's first gesture", async t => { + const h = harness(t); + h.emit("pointerdown", { pointerType: "touch", pointerId: 1 }); + h.emit("pointerdown", { pointerType: "touch", pointerId: 2 }); + h.emit("pointermove", { pointerId: 2, clientY: 10 }); + h.emit("pointerup", { pointerId: 2 }); + h.emit("pointermove", { pointerId: 1, clientY: 20 }); + h.emit("pointerup", { pointerId: 1 }); + await h.advance(80); + assert.deepEqual(h.inputs.map(input => input.event.delta_y), [10]); + assert.deepEqual(h.requests, []); +}); + +for (const kind of ["page", "generation", "cleanup"]) { + test(`retired ${kind} touch cannot block a new pointer without its old release`, async t => { + const h = harness(t); + h.emit("pointerdown", { pointerType: "touch", pointerId: 1 }); + h.replace(kind); + h.emit("pointerdown", { pointerType: "touch", pointerId: 2 }); + assert.deepEqual([...h.state.renderPanel.capturedPointers], [2]); + // A late old release and its synthetic click belong to the retired owner. + h.emit("pointerup", { pointerId: 1 }); + h.emit("click"); + h.emit("pointermove", { pointerId: 2, clientY: 20 }); + h.emit("pointerup", { pointerId: 2 }); + await h.advance(80); + assert.deepEqual(h.inputs.map(input => input.event.delta_y), [10]); + assert.deepEqual(h.wire, ["m,20,20,8,1", "m,20,20,0,0"]); + assert.deepEqual(h.requests, []); + }); +} + +for (const replaceOwner of [false, true]) { + test(`lost pointer capture permits a fresh ${replaceOwner ? "replacement" : "same"} owner's gesture`, async t => { + const h = harness(t); + h.emit("pointerdown", { pointerType: "pen", pointerId: 1 }); + if (replaceOwner) h.replace("generation"); + h.emit("lostpointercapture", { pointerId: 1 }); + h.emit("click"); + h.emit("pointerdown", { pointerType: "pen", pointerId: 2 }); + h.emit("pointermove", { pointerId: 2, clientY: 20 }); + h.emit("pointerup", { pointerId: 2 }); + await h.advance(80); + assert.deepEqual(h.inputs.map(input => input.event.delta_y), [10]); + assert.deepEqual(h.requests, []); + }); +} + +test("a move from a retired owner releases its tracked pointer immediately", async t => { + const h = harness(t); + h.emit("pointerdown", { pointerType: "touch", pointerId: 1 }); + h.replace("generation"); + h.emit("pointermove", { pointerId: 1, clientY: 20 }); + assert.deepEqual([...h.state.renderPanel.capturedPointers], []); + h.emit("click"); + await h.advance(80); + assert.deepEqual(h.inputs, []); + assert.deepEqual(h.requests, []); +}); + +for (const pointerType of ["touch", "pen"]) { + for (const retirement of ["move", "capture loss", "replacement pointer"]) { + test(`delayed ${pointerType} release after ${retirement} cannot click the replacement owner`, async t => { + let wallClock = 1_000; + t.mock.method(Date, "now", () => wallClock); + const h = harness(t); + h.emit("pointerdown", { pointerType, pointerId: 1 }); + h.replace("generation"); + if (retirement === "move") h.emit("pointermove", { pointerType, pointerId: 1, clientY: 20 }); + if (retirement === "capture loss") h.emit("lostpointercapture", { pointerType, pointerId: 1 }); + if (retirement === "replacement pointer") h.emit("pointerdown", { pointerType, pointerId: 2 }); + wallClock = 2_000; + h.emit("pointerup", { pointerType, pointerId: 1 }); + // Provenance protects both click surfaces even after the renewed legacy + // fallback expires. A later callback cannot acquire the current owner. + wallClock = 3_000; + h.emit("click", { pointerType, pointerId: 1 }); + h.emit("click", { pointerType, pointerId: 1 }, h.state.remoteVideo); + await h.settle(); + assert.deepEqual(h.requests, []); + assert.deepEqual(h.wire, []); + if (retirement !== "replacement pointer") h.emit("pointerdown", { pointerType, pointerId: 2 }); + h.emit("pointerup", { pointerType, pointerId: 2 }); + h.emit("click", { pointerType, pointerId: 2 }); + await h.settle(); + assert.deepEqual(h.requests, [{ path: "/api/apps/browser/pages/page%3Aoriginal/input", + body: { event: { type: "click", x: 20, y: 30 } } }], "the fresh owner's tap dispatches once"); + }); + } + + test(`delayed retired ${pointerType} release renews only the legacy click fallback`, async t => { + let wallClock = 1_000; + t.mock.method(Date, "now", () => wallClock); + const h = harness(t); + h.emit("pointerdown", { pointerType, pointerId: 1 }); + h.replace("generation"); + h.emit("pointermove", { pointerType, pointerId: 1, clientY: 20 }); + wallClock = 2_000; + h.emit("pointerup", { pointerType, pointerId: 1 }); + h.emit("click"); + await h.settle(); + assert.deepEqual(h.requests, [], "legacy compatibility click follows the late release"); + h.emit("click", { pointerType: "mouse", pointerId: 9 }); + await h.settle(); + assert.equal(h.requests.length, 1, "a fresh known mouse is not the retired touch"); + wallClock = 3_000; + h.emit("click", { sourceCapabilities: { firesTouchEvents: true } }); + await h.settle(); + assert.equal(h.requests.length, 1, "legacy touch provenance stays bound after the fallback expires"); + }); +} + +test("losing an unrelated pointer's capture keeps the current gesture", async t => { + const h = harness(t); + h.emit("pointerdown", { pointerType: "touch", pointerId: 1 }); + h.emit("lostpointercapture", { pointerId: 2 }); + h.emit("pointermove", { pointerId: 1, clientY: 20 }); + h.emit("pointerup", { pointerId: 1 }); + await h.advance(80); + assert.deepEqual(h.inputs.map(input => input.event.delta_y), [10]); +}); + +test("canceling the tracked pointer permits a fresh owner's gesture", async t => { + const h = harness(t); + h.emit("pointerdown", { pointerType: "pen", pointerId: 1 }); + h.replace("generation"); + h.emit("pointercancel", { pointerId: 1 }); + h.emit("pointerdown", { pointerType: "pen", pointerId: 2 }); + h.emit("pointermove", { pointerId: 2, clientY: 20 }); + h.emit("pointerup", { pointerId: 2 }); + await h.advance(80); + assert.deepEqual(h.inputs.map(input => input.event.delta_y), [10]); + assert.deepEqual(h.wire, ["m,20,20,8,1", "m,20,20,0,0"]); +}); + +const controlSource = readFileSync(new URL("../../../scripts/browser-selkies-control-service.mjs", import.meta.url), "utf8"); +function controlDeclaration(name) { + const start = controlSource.search(new RegExp(`(?:async )?function ${name}\\(`)); + const end = controlSource.slice(start).search(/\n\}(?=\n|$)/); + assert.ok(start >= 0 && end > 0, name); + return controlSource.slice(start, start + end + 2); +} +const provider = vm.createContext({}); +vm.runInContext(["finiteCoordinate", "dispatchBrowserInputEvent"].map(controlDeclaration).join("\n"), provider); + +for (const [clientX, clientY] of [[0, 0], [0, 30], [20, 0], [20, 30]]) { + test(`actual surface and FIFO preserve wheel point (${clientX}, ${clientY}) in Selkies`, async t => { + const h = harness(t); + h.emit("wheel", { clientX, clientY, deltaX: 11, deltaY: -81 }); + await h.advance(80); + assert.deepEqual(h.inputs.map(input => input.event), [{ type: "wheel", x: clientX, y: clientY, delta_x: 11, delta_y: -81 }]); + assert.deepEqual(h.wire, [`m,${clientX},${clientY},16,2`, `m,${clientX},${clientY},0,0`]); + assert.deepEqual(h.requests, []); + }); + test(`actual Runtime route and CDP adapter retain wheel point (${clientX}, ${clientY})`, async t => { + const h = harness(t), cdp = [], requests = []; + h.state.currentPage.display_session.input = "runtime_route"; + h.state.currentDisplayInput = "runtime_route"; + h.state.fetchJson = async (path, options) => { + requests.push({ path, event: plain(options.body.event) }); + await provider.dispatchBrowserInputEvent({}, options.body.event, 100, + { request: async (method, params) => cdp.push({ method, params: plain(params) }) }); + return { accepted: true }; + }; + h.emit("wheel", { clientX, clientY, deltaX: 11, deltaY: -81 }); + await h.advance(80); + assert.deepEqual(requests, [{ path: "/api/apps/browser/pages/page%3Aoriginal/input", + event: { type: "wheel", x: clientX, y: clientY, delta_x: 11, delta_y: -81 } }]); + assert.deepEqual(cdp, [ + { method: "Input.setIgnoreInputEvents", params: { ignore: false } }, + { method: "Input.dispatchMouseEvent", params: { type: "mouseWheel", x: clientX, y: clientY, deltaX: 11, deltaY: -81 } }, + ]); + assert.deepEqual(h.wire, []); + }); +} + +test("scaled contained video maps letterboxed host points to decoded wheel edges", async t => { + const h = harness(t), video = h.state.remoteVideo; + video.videoWidth = 1920; + video.videoHeight = 1080; + video.getBoundingClientRect = () => ({ left: 40, top: 70, width: 1000, height: 800 }); + video.ownerDocument.defaultView.getComputedStyle = () => ({ objectFit: "contain" }); + h.state.currentView = { width: 1920, height: 1080 }; + // The contained image is 1000 x 562.5, inset 118.75 from the host's top. + const points = [[40, 188.75, 0, 0], [40, 245, 0, 108], [90, 188.75, 96, 0]]; + for (const [index, [clientX, clientY]] of points.entries()) { + h.emit("wheel", { clientX, clientY, deltaX: 0, deltaY: 80 }); + await h.advance((index + 1) * 80); + } + assert.deepEqual(h.inputs.map(input => [input.event.x, input.event.y]), + points.map(([, , x, y]) => [x, y])); + assert.deepEqual(h.wire, points.flatMap(([, , x, y]) => [`m,${x},${y},8,1`, `m,${x},${y},0,0`])); + assert.deepEqual(h.requests, []); +}); + +function wheelGeometry(h, kind) { + const video = h.state.remoteVideo; + video.hidden = kind === "hidden"; + video.ownerDocument.defaultView.getComputedStyle = () => ({ objectFit: "contain" }); + const rect = kind === "zero-width" ? { left: 0, top: 0, width: 0, height: 360 } + : kind === "zero-height" ? { left: 0, top: 0, width: 640, height: 0 } + : kind === "top" || kind === "bottom" ? { left: 40, top: 70, width: 640, height: 480 } + : { left: 40, top: 70, width: 800, height: 360 }; + video.getBoundingClientRect = () => rect; + return { + top: { clientX: 360, clientY: 129 }, + bottom: { clientX: 360, clientY: 491 }, + left: { clientX: 119, clientY: 250 }, + right: { clientX: 761, clientY: 250 }, + }[kind] || { clientX: 20, clientY: 30 }; +} + +for (const route of ["datachannel", "runtime_route"]) { + for (const geometry of ["hidden", "zero-width", "zero-height", "top", "bottom", "left", "right"]) { + for (const pending of [false, true]) { + test(`${route} ignores ${geometry} wheel input${pending ? " without disturbing valid pending movement" : ""}`, async t => { + const h = harness(t); + h.state.currentPage.display_session.input = route; + h.state.currentDisplayInput = route; + if (pending) { + h.emit("wheel", { clientX: 0, clientY: 30, deltaX: 11, deltaY: -81 }); + await h.advance(40); + } + const timersBefore = [...h.timers.entries()]; + let prevented = 0, focused = 0; + h.state.keyboardCapture.focus = () => focused++; + h.emit("wheel", { ...wheelGeometry(h, geometry), deltaX: 300, deltaY: 900, + preventDefault() { prevented++; } }); + const timersAfter = [...h.timers.entries()]; + await h.advance(79); + assert.deepEqual(h.inputs, []); + await h.advance(80); + const event = { type: "wheel", x: 0, y: 30, delta_x: 11, delta_y: -81 }; + assert.deepEqual(h.inputs.map(input => [input.at, input.event]), pending ? [[80, event]] : []); + assert.deepEqual(h.wire, pending && route === "datachannel" ? ["m,0,30,16,2", "m,0,30,0,0"] : []); + assert.deepEqual(h.requests, pending && route === "runtime_route" ? [{ + path: "/api/apps/browser/pages/page%3Aoriginal/input", body: { event }, + }] : []); + assert.deepEqual(timersAfter, timersBefore, "valid batch keeps its original deadline"); + assert.equal(prevented, 0, "unaddressable input leaves outer UI behavior available"); + assert.equal(focused, 0, "unaddressable input leaves keyboard focus alone"); + await h.advance(200); + assert.equal(h.inputs.length, pending ? 1 : 0, "ignored input leaves no later batch"); + assert.equal(h.timers.size, 0); + }); + } + } + + test(`${route} accepts contained video edges and interior after a margin gesture`, async t => { + const h = harness(t); + h.state.currentPage.display_session.input = route; + h.state.currentDisplayInput = route; + h.emit("wheel", wheelGeometry(h, "top")); + let prevented = 0, focused = 0; + h.state.keyboardCapture.focus = () => focused++; + // The decoded image occupies x=40..680 and y=130..490 inside this element. + const points = [[40, 130, 0, 0], [680, 490, 640, 360], [360, 310, 320, 180]]; + for (const [index, [clientX, clientY]] of points.entries()) { + h.emit("wheel", { clientX, clientY, deltaX: 0, deltaY: 80, preventDefault() { prevented++; } }); + await h.advance((index + 1) * 80); + } + const events = points.map(([, , x, y]) => ({ type: "wheel", x, y, delta_x: 0, delta_y: 80 })); + assert.deepEqual(h.inputs.map(input => input.event), events); + assert.equal(prevented, points.length); + assert.equal(focused, points.length); + assert.deepEqual(h.wire, route === "datachannel" + ? points.flatMap(([, , x, y]) => [`m,${x},${y},8,1`, `m,${x},${y},0,0`]) : []); + assert.deepEqual(h.requests, route === "runtime_route" ? events.map(event => ({ + path: "/api/apps/browser/pages/page%3Aoriginal/input", body: { event }, + })) : []); + }); +} diff --git a/capsules/browser/browser/browser-input.js b/capsules/browser/browser/browser-input.js index 5baffe1cb..80d7fa0a3 100644 --- a/capsules/browser/browser/browser-input.js +++ b/capsules/browser/browser/browser-input.js @@ -73,8 +73,8 @@ export function selkiesMessagesForInput(event, currentView = null) { } if (event.type === "wheel") { const magnitude = Math.max(1, Math.min(12, Math.ceil(Math.abs(Number(event.delta_y || 0)) / 80))); - const x = Math.round(Number(event.x || (currentView?.width || 1280) / 2)); - const y = Math.round(Number(event.y || (currentView?.height || 720) / 2)); + const x = Math.round(Number(event.x === 0 ? 0 : event.x || (currentView?.width || 1280) / 2)); + const y = Math.round(Number(event.y === 0 ? 0 : event.y || (currentView?.height || 720) / 2)); if (Number(event.delta_y || 0) < 0) { return [`m,${x},${y},16,${magnitude}`, `m,${x},${y},0,0`]; } diff --git a/capsules/browser/browser/browser-input.test.mjs b/capsules/browser/browser/browser-input.test.mjs new file mode 100644 index 000000000..68b05b34c --- /dev/null +++ b/capsules/browser/browser/browser-input.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const sourceRef = process.env.BROWSER_INPUT_COORDINATE_SOURCE_REF; +const source = sourceRef + ? execFileSync("git", ["show", `${sourceRef}:capsules/browser/browser/browser-input.js`], + { cwd: new URL("../../../", import.meta.url), encoding: "utf8" }) + : readFileSync(new URL("./browser-input.js", import.meta.url), "utf8"); +const { selkiesMessagesForInput } = await import(`data:text/javascript,${encodeURIComponent(source)}`); +const view = { width: 1280, height: 720 }; + +for (const [label, point, expected] of [ + ["top-left corner", { x: 0, y: 0 }, [0, 0]], + ["left edge", { x: 0, y: 37 }, [0, 37]], + ["top edge", { x: 19, y: 0 }, [19, 0]], + ["zero x and omitted y", { x: 0 }, [0, 360]], + ["omitted x and zero y", { y: 0 }, [640, 0]], + ["ordinary fractions", { x: 19.6, y: 37.4 }, [20, 37]], + ["omitted coordinates", {}, [640, 360]], + ["null coordinates", { x: null, y: null }, [640, 360]], + ["explicit undefined", { x: undefined, y: undefined }, [640, 360]], + ["false fallback", { x: false, y: false }, [640, 360]], + ["empty-string fallback", { x: "", y: "" }, [640, 360]], + ["NaN fallback", { x: NaN, y: NaN }, [640, 360]], + ["numeric-string coercion", { x: "0", y: "37.4" }, [0, 37]], +]) { + test(`wheel coordinates preserve ${label}`, t => { + if (label === "top-left corner") { + t.diagnostic(`actual encoder SHA-256 ${createHash("sha256").update(source).digest("hex")}`); + } + const [x, y] = expected; + assert.deepEqual(selkiesMessagesForInput({ type: "wheel", ...point, delta_y: 80 }, view), + [`m,${x},${y},8,1`, `m,${x},${y},0,0`]); + }); +} + +test("omitted view keeps the existing default center while zero remains an edge", () => { + assert.deepEqual(selkiesMessagesForInput({ type: "wheel", delta_y: 80 }), + ["m,640,360,8,1", "m,640,360,0,0"]); + assert.deepEqual(selkiesMessagesForInput({ type: "wheel", x: 0, y: 0, delta_y: 80 }), + ["m,0,0,8,1", "m,0,0,0,0"]); +}); + +test("custom view center is used only for missing coordinates", () => { + assert.deepEqual(selkiesMessagesForInput({ type: "wheel", x: 0, y: null, delta_y: 80 }, + { width: 1920, height: 1080 }), ["m,0,540,8,1", "m,0,540,0,0"]); +}); + +for (const [delta, mask, magnitude] of [[1, 8, 1], [-1, 16, 1], [80, 8, 1], [-80, 16, 1], + [81, 8, 2], [-81, 16, 2], [2000, 8, 12], [-2000, 16, 12]]) { + test(`wheel delta ${delta} keeps its direction, quantization and release`, () => { + assert.deepEqual(selkiesMessagesForInput({ type: "wheel", x: 19, y: 37, delta_x: 123, delta_y: delta }, view), + [`m,19,37,${mask},${magnitude}`, "m,19,37,0,0"]); + }); +} + +test("zero vertical delta and horizontal-only input keep their existing empty encoding", () => { + for (const event of [{ delta_y: 0 }, { delta_x: 80, delta_y: 0 }, { delta_x: 80 }]) { + assert.deepEqual(selkiesMessagesForInput({ type: "wheel", x: 0, y: 0, ...event }, view), []); + } +}); diff --git a/capsules/browser/browser/browser.js b/capsules/browser/browser/browser.js index cfa904a61..b9c8e34f7 100644 --- a/capsules/browser/browser/browser.js +++ b/capsules/browser/browser/browser.js @@ -117,8 +117,11 @@ let statusTimer = 0; let canGoBack = false; let canGoForward = false; let pageStatusTimer = 0; +let pageStatusPollingRun = null; let pageStatusRefreshTimers = []; +let pageStatusRefreshRun = null; let pageHeartbeatTimer = 0; +let pageHeartbeatRun = null; let lastPageStatus = null; let unloadCleanupStarted = false; let recoverableDisplayAttachStarted = false; @@ -398,12 +401,14 @@ function setLoading(loading) { } function stopPageStatusPolling() { + pageStatusPollingRun = null; window.clearTimeout(pageStatusTimer); pageStatusTimer = 0; stopPageStatusRefresh(); } function stopPageStatusRefresh() { + pageStatusRefreshRun = null; for (const timer of pageStatusRefreshTimers) { window.clearTimeout(timer); } @@ -411,6 +416,7 @@ function stopPageStatusRefresh() { } function stopPageHeartbeat() { + pageHeartbeatRun = null; window.clearTimeout(pageHeartbeatTimer); pageHeartbeatTimer = 0; } @@ -1249,8 +1255,9 @@ async function fetchPageStatus({ history = "replace", forceAddress = false, fast = false, + isCurrent = () => true, } = {}) { - if (!currentPage?.page_id) { + if (!isCurrent() || !currentPage?.page_id) { return null; } const owner = currentRuntimePageOwner(); @@ -1259,7 +1266,7 @@ async function fetchPageStatus({ `/api/apps/browser/pages/${encodeURIComponent(currentPage.page_id)}/status${query}`, { method: "GET" }, ); - if (!runtimeViewerOwnerActive(owner)) return null; + if (!isCurrent() || !runtimeViewerOwnerActive(owner)) return null; if ( status?.schema !== "elastos.browser.page-status/v1" || status.page_id !== currentPage.page_id @@ -1414,12 +1421,16 @@ function schedulePageStatusRefresh({ ), ]; stopPageStatusRefresh(); + const run = {}; + pageStatusRefreshRun = run; + const isCurrent = () => pageStatusRefreshRun === run; pageStatusRefreshTimers = delays.map((nextDelay) => { const timer = window.setTimeout(async () => { pageStatusRefreshTimers = pageStatusRefreshTimers.filter( (candidate) => candidate !== timer, ); if ( + !isCurrent() || unloadCleanupStarted || !currentPage || document.hidden || @@ -1429,9 +1440,9 @@ function schedulePageStatusRefresh({ } const owner = currentRuntimePageOwner(); try { - await fetchPageStatus({ history, forceAddress }); + await fetchPageStatus({ history, forceAddress, isCurrent }); } catch (error) { - await handlePageObservationFailure(error, owner); + if (isCurrent()) await handlePageObservationFailure(error, owner); } }, nextDelay); return timer; @@ -1440,8 +1451,11 @@ function schedulePageStatusRefresh({ function startPageStatusPolling() { stopPageStatusPolling(); + const run = {}; + pageStatusPollingRun = run; + const isCurrent = () => pageStatusPollingRun === run; const poll = async () => { - if (unloadCleanupStarted || !currentPage) { + if (!isCurrent() || unloadCleanupStarted || !currentPage) { return; } if (document.hidden) { @@ -1454,11 +1468,12 @@ function startPageStatusPolling() { } const owner = currentRuntimePageOwner(); try { - await fetchPageStatus({ fast: true }); + await fetchPageStatus({ fast: true, isCurrent }); } catch (error) { - await handlePageObservationFailure(error, owner); + if (isCurrent()) await handlePageObservationFailure(error, owner); } finally { if ( + isCurrent() && sameRuntimePageOwner(currentRuntimePageOwner(), owner) && !unloadCleanupStarted && !relaunchRequested && @@ -1473,8 +1488,11 @@ function startPageStatusPolling() { function startPageHeartbeat() { stopPageHeartbeat(); + const run = {}; + pageHeartbeatRun = run; + const isCurrent = () => pageHeartbeatRun === run; const beat = async () => { - if (unloadCleanupStarted || !currentPage?.page_id) { + if (!isCurrent() || unloadCleanupStarted || !currentPage?.page_id) { return; } const owner = currentRuntimePageOwner(); @@ -1485,9 +1503,10 @@ function startPageHeartbeat() { { method: "POST" }, ); } catch (error) { - if (await handlePageObservationFailure(error, owner)) nextDelay = PAGE_STATUS_INTERVAL_MS; + if (isCurrent() && await handlePageObservationFailure(error, owner)) nextDelay = PAGE_STATUS_INTERVAL_MS; } finally { if ( + isCurrent() && sameRuntimePageOwner(currentRuntimePageOwner(), owner) && !unloadCleanupStarted && !relaunchRequested && @@ -2563,6 +2582,10 @@ profileResetCommit?.addEventListener("click", () => { }); bindBrowserInputSurface({ + captureViewerOwnerGuard: () => { + const owner = currentRuntimePageOwner(); + return () => runtimeViewerOwnerActive(owner); + }, copyRemoteClipboardToHost, friendlyOpenError, getCurrentPage: () => currentPage, diff --git a/docs/BROWSER_CAPSULE.md b/docs/BROWSER_CAPSULE.md index 5627c6f2c..3b06064ba 100644 --- a/docs/BROWSER_CAPSULE.md +++ b/docs/BROWSER_CAPSULE.md @@ -625,10 +625,32 @@ generic Browser capsules still speak the Runtime Browser ABI, not raw host input APIs. Browser commands such as address navigation, back, forward, and reload remain Runtime/provider input calls; the Selkies control service applies them over private CDP and returns the current page state with `direct_network=false`. + +Ordinary CDP click, wheel, and key dispatch reports the connection failure +without automatically replaying input whose outcome may be uncertain. The +viewer queue then stops dependent input. A fresh connection, or replacement of +a cache already known to be closed, can be established before the first input +attempt. Observation calls keep their existing reconnection behavior. + The product raster is fixed at 1920x1080 with DPR 1. Home resizes only the viewer using `object-fit: contain`, and maps input through decoded-video coordinates. Home CSS dimensions never resize the guest compositor or page raster. +Wheel coordinates at zero retain the decoded video's left or top edge through +Selkies encoding; omitted or null coordinates retain the existing center fallback. +The viewer ignores wheel events outside the decoded image or while the video +is hidden or has zero size. These events leave any valid pending wheel batch +and keyboard focus unchanged, and keep the outer UI's default wheel behavior. +Deferred wheel batches and touch gestures belong to the Runtime page owner +present when they begin, including its cleanup handle and page generation. +The Browser viewer discards that input when the owner retires or starts closing; +a replacement page accepts a fresh gesture. The viewer tracks one touch or pen +pointer until release or cancellation. Ordinary page metadata refreshes +and display reattachment under the same owner preserve the gesture. Late errors +from these wheel and pointer sends stay with their original owner. This rule +preserves the existing wheel batching and encoding; the full movement and latency +requirements remain in +[B05](BROWSER_ACCEPTANCE.md#b05-input-and-navigation-feel-like-a-browser). Browser status polling consumes that page state, including `can_go_back` and `can_go_forward`, so visible navigation follows Chromium's actual history instead of a UI-side shadow model. Address-bar submits in an active hosted diff --git a/scripts/browser-input-retry.test.mjs b/scripts/browser-input-retry.test.mjs new file mode 100644 index 000000000..edcca46af --- /dev/null +++ b/scripts/browser-input-retry.test.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const source = fs.readFileSync(new URL("./browser-selkies-control-service.mjs", import.meta.url), "utf8"); +function declaration(name) { + const start = source.search(new RegExp(`(?:async )?function ${name}\\(`)); + const end = source.slice(start).search(/\n\}(?=\n|$)/); + assert.ok(start >= 0 && end > 0, `${name} must remain executable`); + return source.slice(start, start + end + 2); +} + +function harness({ cached = true, initiallyClosed = false, failRequest = null, + failure = new Error("browser CDP WebSocket closed after request"), markClosed = false, + connectFailure = null } = {}) { + const requests = [], clients = [], interceptions = []; + let constructed = 0, connected = 0, failed = false; + function client(label) { + const cdp = { + label, closed: false, closes: 0, + async request(method, params = {}) { + requests.push({ client: label, method, params: JSON.parse(JSON.stringify(params)) }); + // This records an effectful request before its lost reply. It does not + // claim a real browser or native effect was observed. + if (!failed && failRequest?.(method, params, label)) { + failed = true; + if (markClosed) this.closed = true; + throw failure; + } + return { client: label }; + }, + close() { this.closes++; this.closed = true; }, + }; + clients.push(cdp); + return cdp; + } + const cachedClient = cached ? client("cached") : null; + if (cachedClient) cachedClient.closed = initiallyClosed; + const page = { debugger_url: "ws://fixture.invalid/page", _cdp: cachedClient }; + class CdpClient { + constructor(url, timeout) { + assert.equal(url, page.debugger_url); + assert.equal(timeout, 1000); + constructed++; + const fresh = client("fresh"); + fresh.connect = async (wait) => { + assert.equal(wait, 1000); + connected++; + if (connectFailure) { fresh.closed = true; throw connectFailure; } + }; + return fresh; + } + } + const context = vm.createContext({ + Error, CdpClient, + ensureBrowserFileChooserInterception: async (cdp, current) => { + assert.equal(current, page); + interceptions.push(cdp.label); + }, + }); + vm.runInContext(["withBrowserCdp", "finiteCoordinate", "keyEventDefinition", "dispatchBrowserInputEvent"].map(declaration).join("\n"), context); + return { + page, cachedClient, clients, requests, interceptions, failure, + constructed: () => constructed, connected: () => connected, + run: (event, supplied = null) => context.dispatchBrowserInputEvent(page, event, 1000, supplied), + observe: (action) => context.withBrowserCdp(page, 1000, action), + }; +} +const wheel = { type: "wheel", x: 0, y: 30, delta_x: 2, delta_y: 80 }; +const click = { type: "click", x: 20, y: 30 }; +const effectRequests = (h) => h.requests.filter(({ method }) => method.startsWith("Input.dispatch") || method === "Input.insertText"); +const isMouse = (type) => (method, params) => method === "Input.dispatchMouseEvent" && params.type === type; +const isKey = (type) => (method, params) => method === "Input.dispatchKeyEvent" && params.type === type; +const failures = [ + { name: "wheel", event: wheel, at: isMouse("mouseWheel") }, + { name: "click press", event: click, at: isMouse("mousePressed") }, + { name: "click release", event: click, at: isMouse("mouseReleased") }, + { name: "text insertion", event: { type: "key", key: "a" }, at: method => method === "Input.insertText" }, + { name: "named key down", event: { type: "key", key: "Enter" }, at: isKey("keyDown") }, +]; +for (const { name, event, at } of failures) { + test(`ordinary ${name} reports a cached connection failure without replay`, async () => { + const h = harness({ failRequest: at }); + const outcome = await h.run(event).then(() => ({ succeeded: true }), error => ({ error })); + assert.equal(h.requests.filter(r => at(r.method, r.params)).length, 1, "effectful request is attempted once"); + assert.equal(outcome.error, h.failure, "the original failure reaches the caller"); + assert.equal(h.constructed(), 0, "uncertain input cannot create a retry connection"); + assert.equal(h.cachedClient.closes, 1); + assert.equal(h.page._cdp, null); + if (event.type === "click") { + assert.deepEqual(effectRequests(h).map(r => r.params.type), ["mouseMoved", "mousePressed", "mouseReleased"]); + assert.ok(effectRequests(h).every(r => r.client === "cached"), "release cleanup stays on the original client"); + } + if (name === "named key down") { + // The existing helper does not reach keyUp after keyDown rejects. This + // repair prevents replay; it does not prove native key-state rollback. + assert.deepEqual(effectRequests(h).map(r => r.params.type), ["keyDown"]); + } + }); +} + +test("closed cached client suppresses replay even when the error text is not a connection marker", async () => { + const failure = new Error("reply outcome unavailable"); + const h = harness({ failure, markClosed: true, failRequest: isMouse("mouseWheel") }); + const outcome = await h.run(wheel).then(() => ({ succeeded: true }), error => ({ error })); + assert.equal(effectRequests(h).length, 1); + assert.equal(outcome.error, failure); + assert.equal(h.constructed(), 0); + assert.equal(h.cachedClient.closes, 1); + assert.equal(h.page._cdp, null); +}); + +test("nonconnection input error retains the live cache and original error", async () => { + const failure = new Error("input rejected by page"); + const h = harness({ failure, failRequest: isMouse("mouseWheel") }); + await assert.rejects(h.run(wheel), error => error === failure); + assert.equal(effectRequests(h).length, 1); + assert.equal(h.cachedClient.closes, 0); + assert.equal(h.page._cdp, h.cachedClient); + assert.equal(h.constructed(), 0); +}); + +test("cached setup connection failure is explicit rather than guessed safe to replay", async () => { + const h = harness({ failRequest: method => method === "Page.enable" }); + const outcome = await h.run(wheel).then(() => ({ succeeded: true }), error => ({ error })); + assert.equal(effectRequests(h).length, 0); + assert.equal(outcome.error, h.failure); + assert.equal(h.constructed(), 0); + assert.equal(h.cachedClient.closes, 1); + assert.equal(h.page._cdp, null); +}); + +for (const [name, options, connections, label] of [ + ["live cached", {}, 0, "cached"], + ["fresh", { cached: false }, 1, "fresh"], + ["initially closed cached", { initiallyClosed: true }, 1, "fresh"], +]) { + test(`${name} connection can dispatch one successful input`, async () => { + const h = harness(options); + await h.run(wheel); + assert.equal(h.constructed(), connections); + assert.equal(h.connected(), connections); + assert.equal(effectRequests(h).length, 1); + assert.equal(effectRequests(h)[0].client, label); + assert.deepEqual(effectRequests(h)[0].params, { type: "mouseWheel", x: 0, y: 30, deltaX: 2, deltaY: 80 }); + assert.equal(h.page._cdp.label, label); + assert.deepEqual(h.interceptions, [label]); + if (options.initiallyClosed) assert.equal(h.requests.some(r => r.client === "cached"), false); + }); +} + +test("initial connection failure reports its error without an input or second connection", async () => { + const failure = new Error("browser CDP WebSocket error during connect"); + const h = harness({ cached: false, connectFailure: failure }); + await assert.rejects(h.run(wheel), error => error === failure); + assert.equal(h.constructed(), 1); + assert.equal(h.connected(), 1); + assert.equal(h.requests.length, 0); + assert.equal(h.clients[0].closes, 1); + assert.equal(h.page._cdp, null); +}); + +test("first input on a fresh connection also reports uncertainty without another connection", async () => { + const h = harness({ cached: false, failRequest: isMouse("mouseWheel") }); + await assert.rejects(h.run(wheel), error => error === h.failure); + assert.equal(h.constructed(), 1); + assert.equal(effectRequests(h).length, 1); + assert.equal(h.page._cdp, null); +}); + +test("generic observation keeps its existing cached-connection retry", async () => { + const h = harness({ failRequest: method => method === "Page.getLayoutMetrics" }); + const result = await h.observe(cdp => cdp.request("Page.getLayoutMetrics")); + assert.equal(result.client, "fresh"); + assert.deepEqual(h.requests.map(r => r.client), ["cached", "fresh"]); + assert.equal(h.cachedClient.closes, 1); + assert.equal(h.constructed(), 1); + assert.equal(h.connected(), 1); + assert.equal(effectRequests(h).length, 0); +}); + +for (const rejects of [false, true]) { + test(`supplied CDP ${rejects ? "failure" : "success"} keeps its existing caller-owned path`, async () => { + const h = harness(); + const calls = []; + const supplied = { async request(method, params) { calls.push({ method, params }); if (rejects && method === "Input.dispatchMouseEvent") throw h.failure; } }; + if (rejects) await assert.rejects(h.run(wheel, supplied), error => error === h.failure); + else await h.run(wheel, supplied); + assert.deepEqual(calls.map(r => r.method), ["Input.setIgnoreInputEvents", "Input.dispatchMouseEvent"]); + assert.equal(h.constructed(), 0); + assert.equal(h.requests.length, 0); + assert.equal(h.interceptions.length, 0); + assert.equal(h.page._cdp, h.cachedClient); + assert.equal(h.cachedClient.closes, 0); + }); +} diff --git a/scripts/browser-observation-recovery.test.mjs b/scripts/browser-observation-recovery.test.mjs index 1093555c9..457c58b8d 100644 --- a/scripts/browser-observation-recovery.test.mjs +++ b/scripts/browser-observation-recovery.test.mjs @@ -29,9 +29,9 @@ function deferred() { } const routes = { - status: { start: "startPageStatusPolling", timer: "pageStatusTimer" }, - heartbeat: { start: "startPageHeartbeat", timer: "pageHeartbeatTimer" }, - afterInput: { start: "schedulePageStatusRefresh", timer: "pageStatusRefreshTimers" }, + status: { start: "startPageStatusPolling", stop: "stopPageStatusPolling", timer: "pageStatusTimer" }, + heartbeat: { start: "startPageHeartbeat", stop: "stopPageHeartbeat", timer: "pageHeartbeatTimer" }, + afterInput: { start: "schedulePageStatusRefresh", stop: "stopPageStatusRefresh", timer: "pageStatusRefreshTimers" }, }; function harness() { const timers = new Map(), requests = [], failures = [], renewals = [], mutations = [], messages = []; @@ -40,6 +40,7 @@ function harness() { currentPage: { page_id: "page-current", actual_url: "https://fixture.invalid/nav", title: "Before", runtime_cleanup: { schema: "elastos.browser.cleanup-handle/v1", id: "cleanup-current" } }, unloadCleanupStarted: false, currentPageGeneration: 1, pageStatusTimer: 0, pageHeartbeatTimer: 0, pageStatusRefreshTimers: [], + pageStatusPollingRun: null, pageStatusRefreshRun: null, pageHeartbeatRun: null, relaunchRequested: false, lastPageStatus: null, homeWindowCloseInFlight: false, homeWindowTerminalCloseConfirmed: false, pendingHomeWindowCloseDelivery: null, document: { hidden: false }, isAddressEditing: () => false, @@ -82,6 +83,7 @@ function harness() { mediaOpen: () => mediaOpen, setFetch: value => { fetch = value; }, start: route => state[routes[route].start](), + stop: route => state[routes[route].stop](), async fire(route) { const id = timerFor(route), timer = timers.get(id); assert.ok(timer, `missing ${route} timer`); @@ -236,6 +238,56 @@ for (const route of Object.keys(routes)) { } } +for (const route of Object.keys(routes)) { + for (const lifecycle of ["restart", "stop"]) { + for (const outcome of ["success", "transport", "authority", "terminal"]) { + test(`${route} ${lifecycle} retires a pending ${outcome} observation for the same page owner`, async () => { + const h = harness(), pending = deferred(), owner = h.state.currentRuntimePageOwner(); + const previousStatus = { ...h.healthyStatus(), actual_url: "https://fixture.invalid/previous", title: "Previous" }; + h.setFetch(() => pending.promise); + h.start(route); + const observation = h.fire(route); + assert.equal(h.requests.length, 1); + if (lifecycle === "restart") h.start(route); + else h.stop(route); + h.state.currentPage.actual_url = "https://fixture.invalid/current"; + const remainingTimers = [...h.timers.keys()]; + assert.equal(remainingTimers.length, lifecycle === "stop" ? 0 : route === "afterInput" ? 4 : 1); + + if (outcome === "success") pending.resolve(response(previousStatus)); + else if (outcome === "transport") pending.reject(new TypeError("retired observation failed")); + else pending.resolve(response({ error: "retired observation failed" }, outcome === "authority" ? 401 : 404)); + await observation; + + assertRetained(h, owner); + assert.equal(h.state.currentPage.actual_url, "https://fixture.invalid/current"); + assert.equal(h.state.lastPageStatus, null); + assert.deepEqual(h.mutations, []); + assert.deepEqual(h.messages, []); + assert.deepEqual([...h.timers.keys()], remainingTimers, + "Retired work must leave the replacement timers unchanged"); + + if (lifecycle === "restart") { + if (outcome === "authority") { + h.setFetch(async () => response({ error: "auth session is not active" }, 401)); + await h.fire(route); + assert.equal(h.renewals.length, 1, "The active run must still request fresh authority on HTTP 401"); + assert.equal(h.timers.size, 0); + assert.deepEqual(h.failures, []); + assert.equal(h.mediaOpen(), true); + } else { + h.setFetch(async () => response(h.healthyStatus())); + await h.fire(route); + assertRetained(h, owner); + assert.equal(h.requests.length, 2, "The replacement run must continue observing"); + if (route !== "heartbeat") assert.ok(h.mutations.length > 0); + } + } + }); + } + } +} + for (const route of ["status", "afterInput"]) { for (const invalid of ["schema", "page_id", "direct_network", "json"]) { test(`${route} still handles malformed ${invalid} as a terminal observation error`, async () => { diff --git a/scripts/browser-operator-input.test.mjs b/scripts/browser-operator-input.test.mjs index 52802221e..10fd51e71 100644 --- a/scripts/browser-operator-input.test.mjs +++ b/scripts/browser-operator-input.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import fs from "node:fs"; +import http from "node:http"; import test from "node:test"; import vm from "node:vm"; const source = fs.readFileSync(new URL("./browser-selkies-control-service.mjs", import.meta.url), "utf8"); @@ -222,3 +223,67 @@ test("actual CDP wrapper may connect before the first operator effect", async () assert.equal(effects(h).filter(c=>c.method === "Input.insertText").length,1); } finally {h.close();} }); + + +for (const action of ["type", "click"]) { + test(`actual HTTP input route preserves ${action} uncertainty after a lost effect ACK`, async t => { + const h = harness({ actualWrapper: true }); + t.after(() => h.close()); + await h.acquire(); + let failed = false; + h.hook(async (method, params) => { + if (!failed && (action === "type" ? method === "Input.insertText" + : method === "Input.dispatchMouseEvent" && params.type === "mousePressed")) { + failed = true; + throw new Error("browser CDP WebSocket closed"); + } + }); + Object.assign(h.context, { + http, URL, pages: new Map([[h.page.pageId, h.page]]), + config: { signalTimeoutMs: 100 }, logControlEvent() {}, + }); + // Execute the actual HTTP handler, JSON reader and serializer. Only the + // owned CDP/native effect peer is a fixture; the failure follows dispatch. + vm.runInContext( + block("const DISPLAY_CONTROL_HTTP_STATUS =", "const DISPLAY_RENEGOTIATE_SIGNAL") + + block("const INSPECTION_HTTP_STATUS =", "function browserInspectionError") + + block("function httpJson(", "\nfunction readTail(") + + block("function readJsonRequest(", "\nexport class MinimalWebSocketClient") + + block(" const server = http.createServer(async (req, res) => {", " server.on(\"clientError\"") + + "\nglobalThis.controlServer = server;", h.context); + const server = h.context.controlServer; + t.after(async () => { + server.closeAllConnections(); + if (server.listening) await new Promise(resolve => server.close(resolve)); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const endpoint = `http://127.0.0.1:${server.address().port}/pages/${encodeURIComponent(h.page.pageId)}/input`; + const event = h.event(action); + const post = input => fetch(endpoint, { method: "POST", body: JSON.stringify({ event: input }) }); + const response = await post(event); + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { + error: "Browser operator input could not complete.", code: "operator_outcome_uncertain", + }); + const applied = () => effects(h).filter(call => action === "type" + ? call.method === "Input.insertText" : call.params.type === "mousePressed"); + assert.equal(applied().length, 1, "the native effect can happen before its reply is lost"); + const repeated = await post(event); + assert.notEqual(repeated.status, 200); + await repeated.json(); + assert.equal(applied().length, 1, "an uncertain effect is never replayed"); + }); +} + +test("actual HTTP error serialization keeps ordinary failures distinct from uncertainty", () => { + const start = source.indexOf(" const displayStatus = Object.hasOwn(DISPLAY_CONTROL_HTTP_STATUS"); + const end = source.indexOf("\n }\n });", start); + assert.ok(start > 0 && end > start); + const responses = []; + const context = vm.createContext({ error: new Error("known failure"), Error, + DISPLAY_CONTROL_HTTP_STATUS: {}, INSPECTION_HTTP_STATUS: {}, + httpJson: (_res, status, body) => responses.push({ status, body }), res: {}, + }); + vm.runInContext(source.slice(start, end), context); + assert.deepEqual(JSON.parse(JSON.stringify(responses)), [{ status: 500, body: { error: "known failure" } }]); +}); diff --git a/scripts/browser-selkies-control-service.mjs b/scripts/browser-selkies-control-service.mjs index 95a704e59..17e312bf8 100755 --- a/scripts/browser-selkies-control-service.mjs +++ b/scripts/browser-selkies-control-service.mjs @@ -5509,7 +5509,10 @@ async function dispatchBrowserInputEvent(browserPage, event, timeoutMs, supplied } throw new Error("unsupported browser input event"); }; - return suppliedCdp ? dispatch(suppliedCdp) : withBrowserCdp(browserPage, timeoutMs, dispatch); + // A lost reply can follow an input effect; reconnecting must not replay it. + return suppliedCdp + ? dispatch(suppliedCdp) + : withBrowserCdp(browserPage, timeoutMs, dispatch, { retryAction: false }); } function validateBrowserNavigationUrl(value) { @@ -6304,9 +6307,10 @@ async function main() { const displayStatus = Object.hasOwn(DISPLAY_CONTROL_HTTP_STATUS, error?.code) ? DISPLAY_CONTROL_HTTP_STATUS[error.code] : null; const inspectionStatus = Object.hasOwn(INSPECTION_HTTP_STATUS, error?.code) ? INSPECTION_HTTP_STATUS[error.code] : null; - httpJson(res, inspectionStatus || displayStatus || (error?.code === "invalid_request" ? 400 : 500), { + const inputUncertain = error?.code === "operator_outcome_uncertain"; + httpJson(res, inspectionStatus || displayStatus || (inputUncertain ? 503 : error?.code === "invalid_request" ? 400 : 500), { error: error instanceof Error ? error.message : String(error), - ...(displayStatus || inspectionStatus ? { code: error.code } : {}), + ...(displayStatus || inspectionStatus || inputUncertain ? { code: error.code } : {}), }); } }); diff --git a/scripts/browser-window-close-handshake.test.mjs b/scripts/browser-window-close-handshake.test.mjs index 008294ebf..f7459581a 100644 --- a/scripts/browser-window-close-handshake.test.mjs +++ b/scripts/browser-window-close-handshake.test.mjs @@ -156,6 +156,8 @@ function createActiveAuthorityExpiryHarness(surface) { unloadCleanupStarted: false, pageHeartbeatTimer: 0, pageStatusTimer: 0, + pageStatusPollingRun: null, + pageHeartbeatRun: null, relaunchRequested: false, browserAuthorityRenewal: null, browserAuthorityRenewalRetryTimer: 0, @@ -186,12 +188,14 @@ function createActiveAuthorityExpiryHarness(surface) { friendlyOpenError, showStatus() {}, stopPageStatusPolling() { + context.pageStatusPollingRun = null; for (const [id, timer] of timers) { if (timer.delay !== 60_000) timers.delete(id); } context.pageStatusTimer = 0; }, stopPageHeartbeat() { + context.pageHeartbeatRun = null; for (const [id, timer] of timers) { if (timer.delay === 60_000) timers.delete(id); } diff --git a/scripts/browser-window-close-headless-smoke.mjs b/scripts/browser-window-close-headless-smoke.mjs index b3082a89b..90217909c 100644 --- a/scripts/browser-window-close-headless-smoke.mjs +++ b/scripts/browser-window-close-headless-smoke.mjs @@ -4,18 +4,27 @@ import { createServer } from "node:http"; import { existsSync, readFileSync } from "node:fs"; import { extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { DEFAULT_URL } from "../capsules/browser/browser/browser-runtime-api.js"; const repoRoot = fileURLToPath(new URL("../", import.meta.url)); const browserInstance = "browser:0123456789abcdef0123456789abcdef"; const browserToken = "browser-window-close-headless-token"; +const browserEngineId = "close-proof-engine"; +const remoteExitId = "close-proof-exit"; const state = { + authorityErrors: [], closeCalls: [], + closeOwnershipBrowserInstance: browserInstance, + freshStartAllowed: true, openCalls: 0, openRequests: 0, + openRequestBodies: [], openStatusRequests: 0, + pages: {}, resetCalls: [], releaseInitialOpen: null, serverErrors: [], + statusCalls: [], requests: [], unknownOpenResult: null, }; @@ -92,6 +101,8 @@ function topDocument(origin) { function browserRoute(homeOrigin, includeUrl = true) { const query = new URLSearchParams({ browser_instance: browserInstance, + browser_engine_id: browserEngineId, + remote_exit_id: remoteExitId, display_mode: "webrtc_remote_display", guarantee_level: "mechanism_microvm", home_origin: homeOrigin, @@ -160,17 +171,23 @@ function attackerDocument() { `; } -function openResult() { +function openResult(url) { state.openCalls += 1; const ordinal = state.openCalls; const pageId = `page-${ordinal}`; + state.pages[pageId] = url; return { schema: "elastos.browser.open-result/v1", + stream_session: { + schema: "elastos.net.stream-session/v1", + backend: remoteExitId, + direct_network: false, + }, engine_page: { schema: "elastos.browser.engine.page/v1", page_id: pageId, - url: "https://example.com/close-proof", - actual_url: "https://example.com/close-proof", + url, + actual_url: url, title: "Close proof", display_session: { schema: "elastos.browser.display-session/v1", @@ -202,49 +219,68 @@ async function handleApi(req, res, url) { res.end(); return true; } + if (url.pathname.startsWith("/api/") && req.headers["x-elastos-home-token"] !== browserToken) { + state.authorityErrors.push(`${req.method} ${url.pathname}: wrong Browser token`); + json(res, 403, { error: "invalid fixture Browser authority" }); + return true; + } if (url.pathname === "/api/apps/browser/summary" && req.method === "GET") { json(res, 200, { schema: "elastos.browser.runtime/v1", sessions: { schema: "elastos.browser.session-capacity/v1", status: "configured", - fresh_start_allowed: true, + fresh_start_allowed: state.freshStartAllowed, recoverable_page: null, window_close_ownership: { schema: "elastos.browser.window-close-ownership/v1", - browser_instance: browserInstance, + browser_instance: state.closeOwnershipBrowserInstance, state: "absent", }, }, - browser_engines: [], - remote_carrier_exits: [], + engine_adapter: { + adapters: [{ + id: browserEngineId, + backing_substrate: "local_microvm", + direct_network: false, + wallet_injection: false, + }], + }, + net: { exit_provider: { remote_carrier_exits: [{ id: remoteExitId }] } }, }); return true; } if (url.pathname === "/api/apps/browser/profile/reset" && req.method === "POST") { - state.resetCalls.push({ token: req.headers["x-elastos-home-token"] }); + state.resetCalls.push({ + token: req.headers["x-elastos-home-token"], + closeCallsAtReset: state.closeCalls.length, + openRequestsAtReset: state.openRequests, + }); json(res, 200, { schema: "elastos.browser.profile-reset/v1", status: "ok", removed_profile_disk: true }); return true; } if (url.pathname === "/api/apps/browser/open" && req.method === "POST") { const body = await readBody(req); if ( - req.headers["x-elastos-home-token"] !== browserToken || - body?.browser_instance !== browserInstance + body?.browser_instance !== browserInstance || + body?.adapter_id !== browserEngineId || + body?.remote_exit_id !== remoteExitId ) { + state.authorityErrors.push("open changed the Browser instance, Engine, or Exit selection"); json(res, 403, { error: "invalid fixture Browser owner" }); return true; } state.openRequests += 1; + state.openRequestBodies.push(body); if (state.openRequests === 1) { await new Promise((resolveOpen) => { state.releaseInitialOpen = resolveOpen; }); - json(res, 200, openResult()); + json(res, 200, openResult(body.url)); return true; } if (state.openRequests === 2) { - state.unknownOpenResult = openResult(); + state.unknownOpenResult = openResult(body.url); json(res, 202, { schema: "elastos.browser.open-accepted/v1", status: "pending", @@ -253,6 +289,10 @@ async function handleApi(req, res, url) { }); return true; } + if (state.openRequests === 3) { + json(res, 200, openResult(body.url)); + return true; + } json(res, 409, { error: "unexpected Browser open" }); return true; } @@ -283,7 +323,18 @@ async function handleApi(req, res, url) { } const signal = url.pathname.match(/^\/api\/apps\/browser\/pages\/([^/]+)\/signal$/); if (signal && req.method === "POST") { - await readBody(req); + const body = await readBody(req); + if (body?.type === "candidate" || body?.type === "end_of_candidates") { + json(res, 200, { + schema: "elastos.browser.webrtc-signal-ack/v1", + type: body.type, + accepted: true, + candidates: [], + end_of_candidates: true, + }); + return true; + } + assert(body?.type === "offer", "fixture received an unexpected display signal", body); json(res, 200, { schema: "elastos.browser.webrtc-answer/v1", type: "answer", @@ -297,7 +348,17 @@ async function handleApi(req, res, url) { if (close && req.method === "POST") { const pageId = decodeURIComponent(close[1]); const body = await readBody(req); - state.closeCalls.push({ pageId, body }); + if ( + body?.schema !== "elastos.browser.close-request/v2" || + body?.browser_instance !== browserInstance || + body?.cleanup_id !== pageId.replace("page-", "cleanup-") || + !Object.hasOwn(state.pages, pageId) + ) { + state.authorityErrors.push(`close changed the exact Runtime owner for ${pageId}`); + json(res, 403, { error: "invalid fixture Browser cleanup authority" }); + return true; + } + state.closeCalls.push({ pageId, body, token: req.headers["x-elastos-home-token"] }); const alreadyReaped = pageId === "page-2"; json(res, 200, { schema: "elastos.browser.close-result/v1", @@ -308,11 +369,16 @@ async function handleApi(req, res, url) { }); return true; } - if (/^\/api\/apps\/browser\/pages\/[^/]+\/status$/.test(url.pathname)) { + const status = url.pathname.match(/^\/api\/apps\/browser\/pages\/([^/]+)\/status$/); + if (status && req.method === "GET") { + const pageId = decodeURIComponent(status[1]); + state.statusCalls.push(pageId); json(res, 200, { schema: "elastos.browser.page-status/v1", - actual_url: "https://example.com/close-proof", - title: "Close proof", + page_id: pageId, + direct_network: false, + actual_url: state.pages[pageId], + title: `Observed ${pageId}`, audio: false, video: false, }); @@ -515,7 +581,7 @@ try { try { await browserFrame.waitForFunction( () => - document.body.dataset.loading === "false" && + document.body?.dataset.loading === "false" && !window.__elastosBrowserCurrentPageId, null, { timeout: 10_000 }, @@ -523,7 +589,7 @@ try { } catch (error) { const browserState = await browserFrame.evaluate(() => ({ currentPageId: window.__elastosBrowserCurrentPageId, - loading: document.body.dataset.loading, + loading: document.body?.dataset.loading ?? null, status: document.querySelector("#browser-status")?.textContent || "", })).catch(() => null); throw new Error(`unknown-owner Browser did not settle after the lost poll: ${JSON.stringify({ @@ -639,51 +705,144 @@ try { (frame) => frame.url().includes("/apps/browser/") && !new URL(frame.url()).searchParams.has("url"), "fresh Browser capsule", ); - await browserFrame.waitForFunction(() => document.body.dataset.loading === "false"); + await browserFrame.waitForFunction(() => document.body?.dataset.loading === "false"); + assert( + state.openRequests === 3 && + state.openRequestBodies[2].url === DEFAULT_URL && + state.openRequestBodies[2].adapter_id === browserEngineId && + state.openRequestBodies[2].remote_exit_id === remoteExitId && + (await browserFrame.evaluate(() => window.__elastosBrowserCurrentPageId)) === "page-3", + "a fresh Browser must open its default URL with the selected Engine and Exit", + state.openRequestBodies, + ); + await browserFrame.waitForFunction( + () => document.title === "Observed page-3 - ElastOS Browser" && + window.__elastosBrowserCurrentPageId === "page-3", + null, + { timeout: 10_000 }, + ); + assert(state.statusCalls.includes("page-3"), "the fresh page did not complete a status observation", state.statusCalls); + const opensBeforeReset = state.openRequests; + const closesBeforeReset = state.closeCalls.length; + async function assertResetUnchanged(action) { + assert( + state.resetCalls.length === 0 && + state.closeCalls.length === closesBeforeReset && + state.openRequests === opensBeforeReset && + (await browserFrame.evaluate(() => window.__elastosBrowserCurrentPageId)) === "page-3", + `${action} changed the Browser profile or page`, + state, + ); + } await browserFrame.locator("#browser-settings").click(); const confirmation = browserFrame.locator("#browser-profile-reset-confirmation"); await browserFrame.locator("#browser-profile-reset").click(); await confirmation.waitFor({ state: "visible" }); await browserFrame.locator("#browser-profile-reset-cancel").click(); await confirmation.waitFor({ state: "hidden" }); - assert(state.resetCalls.length === 0, "Cancel changed the Browser profile", state.resetCalls); + await assertResetUnchanged("Cancel"); await browserFrame.locator("#browser-profile-reset").click(); await browserFrame.locator("#browser-profile-reset-cancel").press("Escape"); await confirmation.waitFor({ state: "hidden" }); await browserFrame.locator("#browser-settings").click(); - assert(state.resetCalls.length === 0, "Escape changed the Browser profile", state.resetCalls); + await assertResetUnchanged("Escape"); await browserFrame.locator("#browser-profile-reset").click(); await browserFrame.locator("#browser-settings-close").click(); await browserFrame.locator("#browser-settings").click(); await confirmation.waitFor({ state: "hidden" }); - assert(state.resetCalls.length === 0, "Closing Settings changed the Browser profile", state.resetCalls); + await assertResetUnchanged("Closing Settings"); await browserFrame.locator("#browser-profile-reset").click(); await browserFrame.locator("#browser-profile-reset-commit").click(); await browserFrame.locator("#browser-status").getByText("Browser profile reset. Open the address again.", { exact: true }).waitFor(); - assert(state.resetCalls.length === 1 && state.resetCalls[0].token === browserToken && state.openRequests === 2, - "Confirmed reset must use the exact window authority once without opening an Engine", state.resetCalls); + assert( + state.resetCalls.length === 1 && + state.resetCalls[0].token === browserToken && + state.resetCalls[0].closeCallsAtReset === closesBeforeReset + 1 && + state.resetCalls[0].openRequestsAtReset === opensBeforeReset && + state.closeCalls.length === closesBeforeReset + 1 && + state.closeCalls.at(-1).pageId === "page-3" && + state.closeCalls.at(-1).body.cleanup_id === "cleanup-3" && + state.openRequests === opensBeforeReset && + !(await browserFrame.evaluate(() => window.__elastosBrowserCurrentPageId)), + "Confirmed reset must close the exact page before using window authority once, without a replacement open", + { resetCalls: state.resetCalls, closeCalls: state.closeCalls, openRequests: state.openRequests }, + ); await shellFrame.evaluate( (message) => window.__browserCloseProof.send(message), { ...request, requestId: "headless-close-exact-absence" }, ); await shellFrame.waitForFunction(() => window.__browserCloseProof.resultCount() === 5); + const resetResult = await shellFrame.evaluate(() => window.__browserCloseProof.results().at(-1)); + assert( + state.openRequests === opensBeforeReset && state.closeCalls.length === closesBeforeReset + 1 && + resetResult.state === "terminal" && + resetResult.terminalKind === "no_page" && + resetResult.pageId === "" && + resetResult.cleanupId === "", + "Browser did not retain terminal page absence after profile reset", + { resetResult, closeCalls: state.closeCalls }, + ); + + // An independent window never starts a page. Only Runtime's exact ownership + // receipt may make its close terminal, even when fresh starts are unavailable. + state.freshStartAllowed = false; + state.closeOwnershipBrowserInstance = "browser:another-window"; + await shellFrame.evaluate(() => window.__browserCloseProof.navigateAway()); + await waitForFrame(page, (frame) => frame.url().includes("/blank"), "reset Browser navigation"); + await shellFrame.evaluate(() => window.__browserCloseProof.openFresh()); + browserFrame = await waitForFrame( + page, + (frame) => frame.url().includes("/apps/browser/") && !new URL(frame.url()).searchParams.has("url"), + "Browser without a page", + ); + await browserFrame.waitForFunction(() => document.body?.dataset.loading === "false"); + assert( + state.openRequests === opensBeforeReset && + !(await browserFrame.evaluate(() => window.__elastosBrowserCurrentPageId)), + "Browser opened a page while Runtime withheld fresh-start permission", + state, + ); + await shellFrame.evaluate( + (message) => window.__browserCloseProof.send(message), + { ...request, requestId: "headless-close-other-window-absence" }, + ); + await shellFrame.waitForFunction(() => window.__browserCloseProof.resultCount() === 6); + const unrelatedAbsence = await shellFrame.evaluate(() => window.__browserCloseProof.results().at(-1)); + assert( + unrelatedAbsence.state === "pending" && + unrelatedAbsence.terminalKind === "" && + unrelatedAbsence.reason === "runtime_ownership_unproven", + "Browser accepted another window's absence as its own terminal close", + unrelatedAbsence, + ); + state.closeOwnershipBrowserInstance = browserInstance; + const freshRequest = { ...request, requestId: "headless-close-fresh-exact-absence" }; + await shellFrame.evaluate((message) => window.__browserCloseProof.send(message), freshRequest); + await shellFrame.waitForFunction(() => window.__browserCloseProof.resultCount() === 7); const freshResult = await shellFrame.evaluate(() => window.__browserCloseProof.results().at(-1)); assert( - state.openRequests === 2 && state.closeCalls.length === 1 && + state.openRequests === opensBeforeReset && state.closeCalls.length === closesBeforeReset + 1 && + freshResult.requestId === freshRequest.requestId && + freshResult.homeToken === browserToken && + freshResult.browserInstance === browserInstance && freshResult.state === "terminal" && freshResult.terminalKind === "no_page" && freshResult.pageId === "" && freshResult.cleanupId === "", - "fresh Browser did not accept Runtime's exact absence without closing another page", + "Browser without a page did not accept Runtime's exact absence without closing another page", { freshResult, closeCalls: state.closeCalls }, ); + assert(state.authorityErrors.length === 0, "Browser changed fixture authority", state.authorityErrors); assert(pageErrors.length === 0, "Browser close fixture raised page errors", pageErrors); assert(state.serverErrors.length === 0, "Browser close fixture server failed", state.serverErrors); console.log(JSON.stringify({ schema: "elastos.browser.window-close-headless-smoke/v1", ok: true, close_calls: state.closeCalls.length, + default_startup: state.openRequestBodies[2].url, + fresh_page_status_observed: state.statusCalls.includes("page-3"), fresh_close: freshResult.terminalKind, + unrelated_absence: unrelatedAbsence.state, unload_close_calls: 0, reset_calls: state.resetCalls.length, })); diff --git a/scripts/home-browser-restored-lifecycle-headless-smoke.mjs b/scripts/home-browser-restored-lifecycle-headless-smoke.mjs index 8341c1eef..73200783f 100644 --- a/scripts/home-browser-restored-lifecycle-headless-smoke.mjs +++ b/scripts/home-browser-restored-lifecycle-headless-smoke.mjs @@ -12,11 +12,18 @@ const homeAuthorityToken = "fixture-home-authority-token"; const homeGuiToken = "fixture-home-gui-launch-token"; const browserOwnerToken = "fixture-browser-owner-launch-token"; const browserRefreshedToken = "fixture-browser-refreshed-launch-token"; +const browserReopenedToken = "fixture-browser-reopened-launch-token"; const openId = "browser-open-fixture-0001"; const pageId = "browser-page-fixture-0001"; +const reopenedPageId = "browser-page-fixture-0002"; const principalId = "did:elastos:fixture-home-refresh"; const recoveredCleanupId = "browser-cleanup:fixture-recovered"; const openedCleanupId = "browser-cleanup:fixture-opened"; +const retainedEngineId = "fixture-retained-engine"; +const retainedExitId = "fixture-retained-exit"; +const initialDisplayGeneration = `display:${"1".repeat(32)}`; +const restoredDisplayGeneration = `display:${"2".repeat(32)}`; +const reopenedDisplayGeneration = `display:${"3".repeat(32)}`; let releaseBrowserWindowCloseTerminal; const browserWindowCloseTerminalGate = new Promise((resolveGate) => { releaseBrowserWindowCloseTerminal = resolveGate; @@ -24,8 +31,8 @@ const browserWindowCloseTerminalGate = new Promise((resolveGate) => { const state = { phase: "placeholder", - homeGuiLaunches: { placeholder: 0, restored: 0 }, - browserLaunches: { placeholder: 0, restored: 0 }, + homeGuiLaunches: { placeholder: 0, restored: 0, reopened: 0 }, + browserLaunches: { placeholder: 0, restored: 0, reopened: 0 }, browserOpenRequests: 0, browserSummaryBindings: 0, browserOpenBindings: 0, @@ -33,6 +40,12 @@ const state = { browserOpenIntent: null, browserOpenCompleted: false, browserStatusPolls: 0, + browserPageStatusReads: 0, + browserFastStatusReads: 0, + browserDisplayAttachRequests: [], + browserDisplayAttachEffects: 0, + browserSignals: [], + reopenedBrowserInstanceId: null, browserPageCount: 0, browserVmCount: 0, browserCleanupEffects: 0, @@ -40,6 +53,8 @@ const state = { browserWindowCloseTerminalReceipts: 0, browserReapedCleanupId: null, activeCleanupId: null, + activePageId: null, + activeDisplayGeneration: initialDisplayGeneration, homeStateWrites: [], errors: [], }; @@ -157,6 +172,8 @@ function homeSummary() { height: 720, query: { browser_instance: browserInstanceId, + browser_engine_id: "fixture-default-engine", + remote_exit_id: "fixture-other-exit", }, }, ], @@ -225,6 +242,9 @@ function browserSummary() { return { sessions: { schema: "elastos.browser.session-capacity/v1", + status: "configured", + capacity_available: true, + fresh_start_allowed: state.activeCleanupId === null, active_sessions: state.browserPageCount, launching_sessions: 0, total_sessions: state.browserPageCount, @@ -232,67 +252,77 @@ function browserSummary() { ? { schema: "elastos.browser.recoverable-page/v1", state: "active", - page_id: pageId, + page_id: state.activePageId, cleanup: { schema: "elastos.browser.cleanup-handle/v1", id: state.activeCleanupId, }, - engine_page: { - schema: "elastos.browser.engine.page/v1", - page_id: pageId, - url: "https://ela.city/", + service_selection: { + schema: "elastos.browser.service-selection/v1", + engine_id: state.activePageId === pageId ? retainedEngineId : "", + exit_id: state.activePageId === pageId ? retainedExitId : "", }, + engine_page: enginePage(state.activePageId, state.activeDisplayGeneration), } : null, }, engine_adapter: { + display_attach_supported: true, supported_display_modes: ["webrtc_remote_display"], supported_guarantee_levels: ["mechanism_microvm"], - adapters: [ - { - id: "fixture-engine", - default: true, + adapters: ["fixture-default-engine", retainedEngineId].map((id, index) => ({ + id, + default: index === 0, engine: "fixture-microvm", backing_substrate: "local_microvm", direct_network: false, wallet_injection: false, supported_display_modes: ["webrtc_remote_display"], supported_guarantee_levels: ["mechanism_microvm"], - }, - ], + })), }, net: { exit_provider: { - remote_carrier_exits: [], + remote_carrier_exits: ["fixture-other-exit", retainedExitId].map((id) => ({ + id, grant_id: `grant:${id}`, display_name: id, state: "ready", allowed_for_principal: true, + })), }, }, }; } +function enginePage(id, displayGeneration) { + const retained = id === pageId; + return { + schema: "elastos.browser.engine.page/v1", + page_id: id, + url: retained ? "https://fixture.invalid/retained" : "https://ela.city/", + actual_url: retained ? "https://fixture.invalid/retained" : "https://ela.city/", + title: retained ? "Retained page" : "ela.city", + view: { width: 1280, height: 720 }, + display_session: { + schema: "elastos.browser.display-session/v1", + mode: "webrtc_remote_display", + signaling_url: `/api/apps/browser/pages/${id}/webrtc`, + display_generation: displayGeneration, + input: "runtime_route", + input_protocol: "elastos_json", + offerer: retained ? "engine" : "browser", + media_transport: "runtime_relay", + ice_servers: [], + audio: false, + video: true, + direct_network: false, + width: 1280, + height: 720, + }, + }; +} + function browserOpenResult() { return { schema: "elastos.browser.open-result/v1", - engine_page: { - schema: "elastos.browser.engine.page/v1", - page_id: pageId, - url: "https://ela.city/", - actual_url: "https://ela.city/", - title: "ela.city", - view: { width: 1280, height: 720 }, - display_session: { - schema: "elastos.browser.display-session/v1", - mode: "webrtc_remote_display", - signaling_url: `/api/apps/browser/pages/${pageId}/webrtc`, - input: "runtime_route", - input_protocol: "elastos_json", - offerer: "browser", - media_transport: "runtime_relay", - ice_servers: [], - audio: false, - video: true, - direct_network: false, - }, - }, + engine_page: enginePage(reopenedPageId, reopenedDisplayGeneration), stream_session: { schema: "elastos.net.stream-session/v1", backend: "fixture-local-exit", @@ -319,11 +349,13 @@ function launchRoute(origin, target, query) { params.set(key, String(value)); } params.set("home_origin", origin); - if (target === "browser" && state.phase === "restored") { + if (target === "browser" && state.phase === "reopened") { params.set("fixture_duplicate_open", "1"); } const token = target === "home-gui" ? homeGuiToken + : state.phase === "reopened" + ? browserReopenedToken : state.phase === "restored" ? browserRefreshedToken : browserOwnerToken; @@ -336,6 +368,11 @@ function launchResponse(origin, target, query) { state.homeGuiLaunches[bucket] += 1; } else if (target === "browser") { state.browserLaunches[bucket] += 1; + if (bucket === "reopened") { + assert(query?.browser_instance && query.browser_instance !== browserInstanceId, + "Home fresh reopen reused the retired Browser instance", query); + state.reopenedBrowserInstanceId = query.browser_instance; + } } return { target, @@ -358,6 +395,22 @@ function requireToken(req, expected, res) { return false; } +function currentBrowserToken() { + return state.phase === "reopened" ? browserReopenedToken : browserRefreshedToken; +} + +function currentBrowserInstance() { + return state.phase === "reopened" ? state.reopenedBrowserInstanceId : browserInstanceId; +} + +function fixtureOffer() { + return { + schema: "elastos.browser.webrtc-offer/v1", type: "offer", + sdp: "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\ns=fixture\r\nt=0 0\r\n", + candidates: [], end_of_candidates: true, + }; +} + function placeholderBrowser(res) { const body = ` @@ -427,28 +480,30 @@ async function handleApi(req, res, url) { return true; } if (url.pathname === "/api/apps/browser/summary" && req.method === "GET") { - if (!requireToken(req, browserRefreshedToken, res)) { + if (!requireToken(req, currentBrowserToken(), res)) { return true; } assert( - url.searchParams.get("browser_instance") === browserInstanceId, - "refreshed Browser summary omitted its exact stable instance binding", - { actual: url.searchParams.get("browser_instance"), expected: browserInstanceId }, + url.searchParams.get("browser_instance") === currentBrowserInstance(), + "Browser summary omitted its exact instance binding", + { actual: url.searchParams.get("browser_instance"), expected: currentBrowserInstance() }, ); state.browserSummaryBindings += 1; json(res, 200, browserSummary()); return true; } if (url.pathname === "/api/apps/browser/open" && req.method === "POST") { - if (!requireToken(req, browserRefreshedToken, res)) { + if (!requireToken(req, browserReopenedToken, res)) { return true; } const intent = await readBody(req); assert( - intent?.browser_instance === browserInstanceId, - "refreshed Browser open omitted its exact stable instance binding", - { actual: intent?.browser_instance, expected: browserInstanceId }, + state.phase === "reopened" && intent?.browser_instance === state.reopenedBrowserInstanceId, + "fresh Browser open omitted its new instance binding", + { actual: intent?.browser_instance, expected: state.reopenedBrowserInstanceId }, ); + assert(!intent.adapter_id && !intent.remote_exit_id, + "fresh Home launch inherited retired Engine or Exit choices", intent); state.browserOpenBindings += 1; state.browserOpenRequests += 1; if (state.browserOpenIntent === null) { @@ -458,6 +513,8 @@ async function handleApi(req, res, url) { state.browserPageCount = 1; state.browserVmCount = 1; state.activeCleanupId = openedCleanupId; + state.activePageId = reopenedPageId; + state.activeDisplayGeneration = reopenedDisplayGeneration; } else if (!exactIntent(state.browserOpenIntent, intent)) { json(res, 409, { error: "fixture launch owns a different open intent" }); return true; @@ -466,7 +523,7 @@ async function handleApi(req, res, url) { return true; } if (url.pathname === `/api/apps/browser/open/${openId}` && req.method === "GET") { - if (!requireToken(req, browserRefreshedToken, res)) { + if (!requireToken(req, browserReopenedToken, res)) { return true; } state.browserStatusPolls += 1; @@ -478,17 +535,43 @@ async function handleApi(req, res, url) { }); return true; } - if ( - url.pathname === `/api/apps/browser/pages/${pageId}/webrtc` && - req.method === "POST" - ) { - if (!requireToken(req, browserRefreshedToken, res)) { + const pageRoute = url.pathname.match(/^\/api\/apps\/browser\/pages\/([^/]+)\/(webrtc|status|heartbeat|input|close)$/); + const requestPageId = pageRoute?.[1]; + const pageToken = requestPageId === pageId ? browserRefreshedToken : browserReopenedToken; + if (pageRoute && ![pageId, reopenedPageId].includes(requestPageId)) { + json(res, 404, { error: "fixture page is not owned" }); + return true; + } + if (pageRoute?.[2] === "webrtc" && req.method === "POST") { + if (!requireToken(req, pageToken, res)) { return true; } const signal = await readBody(req); + assert(state.activePageId === requestPageId, "signaling used a retired page", signal); + if (signal?.type === "display_attach") { + assert(requestPageId === pageId && signal.display_generation === initialDisplayGeneration && + signal.request_id === initialDisplayGeneration.slice("display:".length) && + Object.keys(signal).sort().join(",") === "display_generation,request_id,type", + "retained display attach changed its exact request contract", signal); + const previous = state.browserDisplayAttachRequests[0]; + assert(!previous || exactIntent(previous, signal), "display attach retries changed request identity", signal); + state.browserDisplayAttachRequests.push(signal); + if (!previous) state.browserDisplayAttachEffects += 1; + state.activeDisplayGeneration = restoredDisplayGeneration; + json(res, 200, { + schema: "elastos.browser.display-attach-result/v1", page_id: pageId, + request_id: signal.request_id, previous_display_generation: signal.display_generation, + display_generation: restoredDisplayGeneration, initial_offer: fixtureOffer(), audio_offer: fixtureOffer(), + }); + return true; + } + assert(signal?.display_generation === state.activeDisplayGeneration, + "Browser signaling used the wrong display generation", signal); + state.browserSignals.push({ page_id: requestPageId, ...signal }); if (signal?.type === "offer") { json(res, 200, { schema: "elastos.browser.webrtc-answer/v1", + display_generation: signal.display_generation, type: "answer", sdp: [ "v=0", @@ -506,6 +589,7 @@ async function handleApi(req, res, url) { } else { json(res, 200, { schema: "elastos.browser.webrtc-signal-ack/v1", + display_generation: signal.display_generation, type: signal?.type || "end_of_candidates", accepted: true, candidates: [], @@ -514,57 +598,53 @@ async function handleApi(req, res, url) { } return true; } - if ( - url.pathname === `/api/apps/browser/pages/${pageId}/status` && - req.method === "GET" - ) { - if (!requireToken(req, browserRefreshedToken, res)) { + if (pageRoute?.[2] === "status" && req.method === "GET") { + if (!requireToken(req, pageToken, res)) { return true; } + state.browserPageStatusReads += 1; + if (url.searchParams.get("fast") === "1") state.browserFastStatusReads += 1; + const current = enginePage(requestPageId, state.activeDisplayGeneration); json(res, 200, { schema: "elastos.browser.page-status/v1", - page_id: pageId, - actual_url: "https://ela.city/", - title: "ela.city", + page_id: requestPageId, + actual_url: current.actual_url, + title: requestPageId === pageId && url.searchParams.get("fast") === "1" + ? "Retained page after status poll" : current.title, + direct_network: false, can_go_back: false, can_go_forward: false, view: { width: 1280, height: 720 }, }); return true; } - if ( - (url.pathname === `/api/apps/browser/pages/${pageId}/heartbeat` || - url.pathname === `/api/apps/browser/pages/${pageId}/input`) && - req.method === "POST" - ) { - if (!requireToken(req, browserRefreshedToken, res)) { + if (["heartbeat", "input"].includes(pageRoute?.[2]) && req.method === "POST") { + if (!requireToken(req, pageToken, res)) { return true; } json(res, 200, { schema: "elastos.browser.input-result/v1", - page_id: pageId, + page_id: requestPageId, accepted: true, }); return true; } - if ( - url.pathname === `/api/apps/browser/pages/${pageId}/close` && - req.method === "POST" - ) { - if (!requireToken(req, browserRefreshedToken, res)) { + if (pageRoute?.[2] === "close" && req.method === "POST") { + if (!requireToken(req, pageToken, res)) { return true; } const closeRequest = await readBody(req); if ( closeRequest?.schema !== "elastos.browser.close-request/v2" || - closeRequest.cleanup_id !== - (state.activeCleanupId || state.browserReapedCleanupId) + closeRequest.cleanup_id !== state.activeCleanupId || + requestPageId !== state.activePageId || + closeRequest.browser_instance !== currentBrowserInstance() ) { json(res, 400, { error: "fixture received a mismatched Browser cleanup identifier" }); return true; } - if (closeRequest.cleanup_id === openedCleanupId) { - state.browserWindowCloseRequests += 1; + state.browserWindowCloseRequests += 1; + if (closeRequest.cleanup_id === recoveredCleanupId) { if (state.browserWindowCloseRequests === 1) { json(res, 200, { schema: "elastos.browser.close-result/v1", @@ -576,31 +656,21 @@ async function handleApi(req, res, url) { return true; } await browserWindowCloseTerminalGate; - state.browserWindowCloseTerminalReceipts += 1; - json(res, 200, { - schema: "elastos.browser.close-result/v1", - page_id: pageId, - closed: false, - already_closed: true, - cleanup_id: closeRequest.cleanup_id, - terminal_effects: { page_absent: true }, - cleanup: { - schema: "elastos.browser.runtime-session-cleanup/v1", - ok: true, - action: "already_absent", - }, - }); - return true; } state.browserCleanupEffects += 1; + state.browserWindowCloseTerminalReceipts += 1; state.browserPageCount = 0; state.browserVmCount = 0; + state.browserReapedCleanupId = state.activeCleanupId; state.activeCleanupId = null; + state.activePageId = null; json(res, 200, { schema: "elastos.browser.close-result/v1", - page_id: pageId, + page_id: requestPageId, closed: true, cleanup_id: closeRequest.cleanup_id, + terminal_effects: { page_absent: true }, + cleanup: { schema: "elastos.browser.runtime-session-cleanup/v1", ok: true, action: "closed" }, }); return true; } @@ -690,6 +760,7 @@ function browserFrame(page) { const origin = await listen(); let browser = null; +let page = null; try { const imported = await import(playwrightSpecifier()); const { chromium } = imported.default || imported; @@ -734,6 +805,14 @@ try { return {}; } + createDataChannel() { + const channel = new EventTarget(); + channel.readyState = "open"; + channel.send = () => {}; + channel.close = () => { channel.readyState = "closed"; }; + return channel; + } + async createOffer() { return { type: "offer", @@ -750,6 +829,10 @@ try { }; } + async createAnswer() { + return { ...(await this.createOffer()), type: "answer" }; + } + async setLocalDescription(description) { this.localDescription = description; } @@ -797,7 +880,7 @@ try { { contextId: browserContextId }, ); - const page = await context.newPage(); + page = await context.newPage(); const recordPageError = (error) => { if (pageErrors.length < 20) pageErrors.push(String(error).slice(0, 2000)); }; @@ -837,30 +920,33 @@ try { state.browserPageCount = 1; state.browserVmCount = 1; state.activeCleanupId = recoveredCleanupId; + state.activePageId = pageId; await page.reload({ waitUntil: "domcontentloaded" }); await waitFor(() => page.evaluate(() => document.body.dataset.homeStatus === "ready"), 15_000, "restored Home bootstrap"); await waitFor(() => homeGuiFrame(page), 15_000, "refreshed real Home GUI frame"); const restoredGui = homeGuiFrame(page); await waitFor(() => restoredGui.locator('.window[data-target="browser"]').isVisible(), 15_000, "restored Browser window"); await waitFor( - () => browserFrame(page)?.url().includes("fixture_duplicate_open=1"), + () => browserFrame(page)?.url().includes(encodeURIComponent(browserRefreshedToken)), 15_000, "restored real Browser frame", ); const restoredBrowser = browserFrame(page); await waitFor(() => restoredBrowser.locator("#browser-form").isVisible(), 15_000, "restored Browser bootstrap"); await waitFor( - () => state.browserStatusPolls > 0 && state.browserOpenCompleted, + () => state.browserPageStatusReads > 0 && state.browserDisplayAttachEffects === 1, 15_000, - "completed coalesced Browser open", + "retained page status and fresh display attachment", ); await restoredBrowser.waitForFunction( - () => - document.querySelector("#browser-status .browser-status-message")?.textContent === - "Remote display negotiated. Waiting for video...", - null, + (expectedPage) => document.body.dataset.loading === "false" && + window.__elastosBrowserCurrentPageId === expectedPage, + pageId, { timeout: 15_000 }, ); + await waitFor(() => state.browserFastStatusReads > 0, 5_000, "restored page periodic status polling"); + await restoredBrowser.waitForFunction(() => document.title.startsWith("Retained page after status poll"), + null, { timeout: 5_000 }); const restoredWindowCount = await restoredGui .locator('.window[data-target="browser"]') @@ -882,15 +968,26 @@ try { assert(state.homeGuiLaunches.restored === 1, "refresh loaded multiple GUI roots", state); assert(state.browserLaunches.placeholder === 1, "first Home replaced the Browser window", state); assert(state.browserLaunches.restored === 1, "refresh replaced the Browser window", state); - assert(state.browserOpenRequests === 2, "completed-open race did not issue two matching requests", state); + assert(state.browserOpenRequests === 0 && state.browserOpenBindings === 0 && state.browserOpenEffects === 0, + "Home reload opened a replacement for the retained page", state); assert(state.browserSummaryBindings > 0, "refreshed Browser summary was not instance-bound", state); - assert(state.browserOpenBindings === 2, "refreshed Browser opens were not instance-bound", state); - assert(state.browserOpenEffects === 1, "matching completed-open race duplicated provider work", state); - assert(state.browserCleanupEffects === 1, "opaque Home reload did not reap exactly one recovered page", state); + assert(state.browserCleanupEffects === 0, "Home reload reaped the retained page", state); assert(state.browserPageCount === 1, "restored Browser owns the wrong active page count", state); assert(state.browserVmCount === 1, "restored Browser owns the wrong active VM count", state); - assert(state.activeCleanupId === openedCleanupId, "restored Browser cleanup ownership is not recoverable", state); - assert(state.browserStatusPolls === 1, "restored Browser polled a replacement open job", state); + assert(state.activeCleanupId === recoveredCleanupId && state.activePageId === pageId, + "restored Browser changed page or cleanup ownership", state); + assert(state.browserStatusPolls === 0, "restored Browser polled a replacement open job", state); + assert(state.browserDisplayAttachRequests.length === 1 && state.browserDisplayAttachEffects === 1, + "restored Browser duplicated display attachment", state); + assert(state.browserSignals.some((signal) => signal.type === "answer" && + signal.page_id === pageId && signal.display_generation === restoredDisplayGeneration), + "restored viewer did not answer under the new display generation", state); + assert(await restoredBrowser.locator("#browser-engine").inputValue() === retainedEngineId && + await restoredBrowser.locator("#browser-exit").inputValue() === retainedExitId, + "retained nondefault Engine/Exit choices did not replace stale launch hints"); + assert(await restoredBrowser.evaluate(() => window.__elastosBrowserCurrentPageId) === pageId && + new URL(restoredBrowser.url()).searchParams.get("display_generation") === restoredDisplayGeneration, + "viewer did not publish its retained page and new display generation"); assert( !/failed|could not open|did not start|error/i.test(restoredStatus || ""), "restored Browser rendered a false startup failure", @@ -908,10 +1005,13 @@ try { await restoredTaskbarButton.getAttribute("data-open-windows") === "1", "restored Browser taskbar entry did not represent exactly one window", ); - state.browserPageCount = 0; - state.browserVmCount = 0; - state.activeCleanupId = null; - state.browserReapedCleanupId = openedCleanupId; + await restoredGui.evaluate(() => { + window.addEventListener("message", (event) => { + if (event.data?.type === "elastos.browser.window-close.result/v1" && event.data.state === "terminal") { + window.__fixtureRetiredBrowserClose = { data: event.data, source: event.source, origin: event.origin }; + } + }); + }); await restoredGui .locator('.window[data-target="browser"] [data-action="close"]') .click(); @@ -922,15 +1022,20 @@ try { ); assert( await restoredGui.locator('.window[data-target="browser"]').count() === 1 && - await restoredTaskbarButton.getAttribute("data-open-windows") === "1", - "nonterminal Browser close removed the shell or taskbar entry", + await restoredTaskbarButton.getAttribute("data-open-windows") === "1" && + state.browserPageCount === 1 && state.browserVmCount === 1 && + state.activeCleanupId === recoveredCleanupId && state.browserCleanupEffects === 0, + "nonterminal Browser close removed its shell, taskbar or Runtime ownership", ); + await waitFor(() => state.browserWindowCloseRequests === 2, 5_000, "pending terminal close retry"); + assert(await restoredGui.locator('.window[data-target="browser"]').count() === 1 && + state.browserWindowCloseTerminalReceipts === 0, "pending close retry removed the shell before its receipt"); releaseBrowserWindowCloseTerminal(); await waitFor( async () => await restoredGui.locator('.window[data-target="browser"]').count() === 0, 10_000, - "delayed already-absent Browser window close", + "delayed terminal Browser window close", ); const finalTaskbarButton = restoredGui.locator( '.taskbar-item[data-target="browser"]', @@ -948,22 +1053,80 @@ try { state.browserPageCount === 0 && state.browserVmCount === 0 && state.activeCleanupId === null && - state.browserReapedCleanupId === openedCleanupId && - state.browserOpenRequests === 2 && - state.browserOpenEffects === 1, + state.activePageId === null && + state.browserReapedCleanupId === recoveredCleanupId && + state.browserOpenRequests === 0 && + state.browserOpenEffects === 0, "terminal Browser close duplicated work or retained Runtime residue", state, ); + // The real Home taskbar opens a new window only after terminal close. The + // fetch shim duplicates that fresh request; fixture dedup is not gateway proof. + state.phase = "reopened"; + assert(finalTaskbarCount === 1, "fixture Browser pin is missing from the Home taskbar"); + await finalTaskbarButton.click(); + await waitFor(() => state.browserLaunches.reopened === 1 && browserFrame(page), + 15_000, "fresh Browser launched through Home"); + const reopenedBrowser = browserFrame(page); + await waitFor(() => state.browserOpenCompleted && state.browserStatusPolls === 1, + 15_000, "completed matching fresh-open requests"); + await reopenedBrowser.waitForFunction(() => + document.querySelector("#browser-status .browser-status-message")?.textContent === + "Remote display negotiated. Waiting for video...", null, { timeout: 15_000 }); + assert(state.browserOpenRequests === 2 && state.browserOpenBindings === 2 && state.browserOpenEffects === 1, + "matching fresh-open fixture requests duplicated effects or lost instance bindings", state); + assert(state.activePageId === reopenedPageId && state.activeCleanupId === openedCleanupId && + state.browserPageCount === 1 && state.browserVmCount === 1 && + state.reopenedBrowserInstanceId !== browserInstanceId, + "fresh Browser did not acquire distinct page, cleanup and window ownership", state); + assert(await reopenedBrowser.evaluate(() => window.__elastosBrowserCurrentPageId) === reopenedPageId && + new URL(reopenedBrowser.url()).hash.includes(browserReopenedToken), + "fresh Browser did not use its new launch authority and page"); + assert(await reopenedBrowser.locator("#browser-engine").inputValue() === "" && + await reopenedBrowser.locator("#browser-exit").inputValue() === "", + "fresh Home launch did not retain its own Automatic Engine/local Exit choices"); + const replayed = await restoredGui.evaluate(() => { + const retired = window.__fixtureRetiredBrowserClose; + if (!retired) return false; + window.dispatchEvent(new MessageEvent("message", retired)); + return true; + }); + assert(replayed, "fixture did not observe the original terminal close delivery"); + assert(await restoredGui.locator('.window[data-target="browser"]').count() === 1 && + page.frames().filter((frame) => frame.url().includes("/apps/browser/")).length === 1 && + state.activePageId === reopenedPageId && state.activeCleanupId === openedCleanupId && + state.browserWindowCloseRequests === 2, + "late retired close delivery changed the fresh Browser window or owner"); + await restoredGui.locator('.window[data-target="browser"] [data-action="close"]').click(); + await waitFor(async () => await restoredGui.locator('.window[data-target="browser"]').count() === 0, + 10_000, "fresh Browser terminal cleanup"); + assert(state.browserWindowCloseRequests === 3 && state.browserWindowCloseTerminalReceipts === 2 && + state.browserCleanupEffects === 2 && state.browserPageCount === 0 && state.browserVmCount === 0 && + state.activeCleanupId === null && state.activePageId === null && + state.browserOpenRequests === 2 && state.browserOpenEffects === 1, + "fresh close duplicated work or retained Runtime residue", state); + assert(state.errors.length === 0 && pageErrors.length === 0, + "Home/Browser lifecycle recorded errors", { pageErrors, serverErrors: state.errors }); + console.log( "[home-browser-restored-lifecycle-headless] PASS " + `home_refresh=1 shell=${restoredWindowCount} frame=${restoredFrameCount} ` + - `open_requests=${state.browserOpenRequests} provider_effects=${state.browserOpenEffects} ` + - `cleanup_effects=${state.browserCleanupEffects} pages=${state.browserPageCount} ` + - `vms=${state.browserVmCount} recoverable_cleanup=1 delayed_close=1 ` + + `open_requests=${state.browserOpenRequests} fixture_open_effects=${state.browserOpenEffects} ` + + `fixture_cleanup_effects=${state.browserCleanupEffects} fixture_pages=${state.browserPageCount} ` + + `fixture_vms=${state.browserVmCount} retained_restore=1 display_attach=1 delayed_close=1 fresh_reopen=1 ` + `open_id=${openId}`, ); +} catch (error) { + const viewer = page ? await browserFrame(page)?.evaluate(() => ({ + status: document.querySelector("#browser-status .browser-status-message")?.textContent, + loading: document.body.dataset.loading, + pageId: window.__elastosBrowserCurrentPageId, + })).catch(() => null) : null; + console.error("[home-browser-restored-lifecycle-headless] diagnostics", JSON.stringify({ state, pageErrors, viewer })); + throw error; } finally { + releaseBrowserWindowCloseTerminal(); await browser?.close().catch(() => {}); await new Promise((resolveClose) => server.close(resolveClose)); } diff --git a/scripts/test-behaviour.sh b/scripts/test-behaviour.sh index 8e03138fc..6a55cfa1d 100755 --- a/scripts/test-behaviour.sh +++ b/scripts/test-behaviour.sh @@ -34,4 +34,7 @@ if ! installed "$browser" playwright 1.60.0 || ! installed "$core" playwright-co fi export NODE_PATH="${browser}/node_modules" export BROWSER_OPERATOR_PLAYWRIGHT_CORE="${core}/node_modules/playwright-core" +export ELASTOS_PLAYWRIGHT_MODULE="${browser}/node_modules/playwright/index.mjs" +node scripts/browser-window-close-headless-smoke.mjs +node scripts/home-browser-restored-lifecycle-headless-smoke.mjs exec node --test --test-force-exit --test-timeout=60000 --test-reporter=spec elastos/esp/projections.test.mjs scripts/*.test.mjs scripts/build/*.test.mjs scripts/lib/*.test.mjs capsules/*/browser/*.test.mjs capsules/*/browser/src/*.test.mjs scripts/home-fixture-contracts-smoke.mjs scripts/documents-save-conflict-smoke.mjs scripts/gba-save-conflict-smoke.mjs scripts/assistant-shell-smoke.mjs scripts/model-preparation-failure-smoke.mjs scripts/model-management-browser-smoke.mjs diff --git a/scripts/test-behaviour.test.mjs b/scripts/test-behaviour.test.mjs index abe4ff5b0..d37d8a58c 100644 --- a/scripts/test-behaviour.test.mjs +++ b/scripts/test-behaviour.test.mjs @@ -101,3 +101,45 @@ test("behaviour inputs reject a missing pinned installer binary instead of using assert.notEqual(result.status, 0, result.stderr); assert.match(result.stderr, /node\/bin\/node.*(?:No such file|not found)/); }); + + +const lifecycleSmokes = [ + "scripts/browser-window-close-headless-smoke.mjs", + "scripts/home-browser-restored-lifecycle-headless-smoke.mjs", +]; +for (const failedScript of [null, ...lifecycleSmokes]) { + test(`behaviour runner ${failedScript ? `stops when ${failedScript} fails` : "runs both lifecycle smokes with pinned Playwright"}`, t => { + const { env, log } = fixture(t); + const bin = env.PATH.split(":")[0]; + const shim = `#!${process.execPath} + const fs = require("node:fs"), childProcess = require("node:child_process"); + const args = process.argv.slice(2); + if (args[0] === "-p") { + const result = childProcess.spawnSync(process.execPath, args, { stdio: "inherit" }); + process.exit(result.status ?? 1); + } + fs.appendFileSync(process.env.INPUTS_LOG, JSON.stringify({ + args, playwrightModule: process.env.ELASTOS_PLAYWRIGHT_MODULE, + }) + "\\n"); + process.exit(args[0] === process.env.FAILED_SCRIPT ? 73 : 0); + `; + writeFileSync(join(bin, "node"), shim, { mode: 0o755 }); + // Other sequential shell smokes can share this runner after integration. + // Record their invocation without starting an unrelated process fixture. + writeFileSync(join(bin, "bash"), shim, { mode: 0o755 }); + const result = spawnSync("/bin/bash", [script], { + env: { ...env, FAILED_SCRIPT: failedScript || "", ELASTOS_PLAYWRIGHT_MODULE: "/wrong/ambient/playwright.mjs" }, + encoding: "utf8", timeout: 10_000, + }); + assert.equal(result.status, failedScript ? 73 : 0, result.stderr); + const commands = readFileSync(log, "utf8").trim().split("\n").map(JSON.parse); + const expected = failedScript ? lifecycleSmokes.slice(0, lifecycleSmokes.indexOf(failedScript) + 1) : lifecycleSmokes; + assert.deepEqual(commands.filter(command => lifecycleSmokes.includes(command.args[0])).map(command => command.args), + expected.map(name => [name])); + for (const command of commands.filter(command => lifecycleSmokes.includes(command.args[0]))) { + assert.equal(command.playwrightModule, join(env.BEHAVIOUR_INPUTS, "browser/node_modules/playwright/index.mjs")); + } + if (failedScript) assert.equal(commands.at(-1).args[0], failedScript, "failure stops every later check"); + else assert.equal(commands.at(-1).args[0], "--test", "both smoke results precede the Node suite"); + }); +}