Skip to content

Latest commit

 

History

History
177 lines (142 loc) · 9.57 KB

offscreen.md

File metadata and controls

177 lines (142 loc) · 9.57 KB

Stateful Offscreen

harness.offscreen shares the harness's context and document registry with runtime.getContexts(). The real @addon-core/browser helpers observe creation and closure without individually configured return values.

Plain Node: test the real helpers

import assert from "node:assert/strict";
import {
    closeOffscreen,
    createOffscreen,
    getOffscreenContext,
    getOffscreenPath,
    hasOffscreen,
    hasOffscreenPath,
} from "@addon-core/browser";
import {createBrowserHarness, installBrowserGlobals} from "@addon-core/browser/testing";

const harness = createBrowserHarness();
const restore = installBrowserGlobals(harness, {environment: "preserve"});

try {
    await createOffscreen({
        url: "offscreen.html",
        reasons: ["DOM_PARSER"],
        justification: "Parse a document in the application",
    });
    assert.equal(await hasOffscreen(), true);
    assert.equal(await getOffscreenPath(), "/offscreen.html");
    assert.equal(await hasOffscreenPath("offscreen.html"), true);
    assert.equal((await getOffscreenContext())?.contextId, harness.offscreen.context?.info.contextId);

    await closeOffscreen();
    assert.equal(await hasOffscreen(), false);
    assert.equal(await getOffscreenContext(), undefined);
} finally {
    try { harness.reset(); } finally { restore(); }
}

The API accepts a relative path or same-extension absolute URL, a nonempty list of known chrome.offscreen.Reason values and a string justification. It rejects unknown options, foreign URLs and invalid parameters before registration. The URL is normalized and inputs are detached from the caller's mutable objects. No HTML file needs to exist in a unit test: this is registration, not actual page loading.

State and failures

  • createDocument registers one offscreen context and its document after the creation gate succeeds. Another creation while one exists or is being created fails; the adapter does not silently reuse or replace it.
  • hasDocument reads the registry. During a delayed creation it is false; during a delayed closure it remains true.
  • closeDocument removes the document and its associated contexts after the closure gate succeeds. Closing an absent document, closing during creation or requesting a second simultaneous close fails explicitly.
  • Constructor fixtures and manually registered offscreen contexts are visible to the same methods. The raw registry can deliberately represent multiple offscreen contexts; the adapter reports this ambiguity instead of picking one.

Errors generated by the adapter name the API, for example offscreen.closeDocument: no current offscreen document. Injected errors retain the error supplied by the test. These are kit diagnostics, not promises of exact Chrome wording.

Both facades support dual invocation: a supplied callback receives the result and the method returns undefined; without a callback it returns a Promise. Default stateful callbacks run asynchronously. Failures set runtime.lastError only while invoking the callback; Promise calls reject without exposing lastError.

harness.offscreen.createDocument.failNext(new Error("Document load failed"));
// await createOffscreen(...) now rejects; nothing is registered.

harness.chrome.offscreen.closeDocument(() => {
    // With no document, runtime.lastError.message names offscreen.closeDocument here only.
    const error = harness.chrome.runtime.lastError;
});

harness.offscreen.createDocument, closeDocument and hasDocument expose the usual method controls and call records. The existing harness.configurable.chrome.offscreen and .browser.offscreen paths remain aliases to these same controls, with shared state, configuration and call history. Root harness.calls counts each invocation once. Unlike the other separately configurable namespaces, configuring either Offscreen alias affects both facades.

Delay an operation without replacing its behavior

beforeCreate and beforeClose are Promise-style controls, defaulting to an immediately resolved Promise. There are no wall-clock waits or fake timers. Configure these gates to delay or fail an operation while retaining its normal registry mutation:

let releaseCreate = () => {};
const gate = new Promise<void>(resolve => { releaseCreate = resolve; });
harness.offscreen.beforeCreate.setImplementation(() => gate);

const creating = createOffscreen({
    url: "offscreen.html", reasons: ["DOM_PARSER"], justification: "Test creation race",
});
assert.equal(await getOffscreenContext(), undefined);
releaseCreate();
await creating;

harness.offscreen.beforeClose.failNext(new Error("Closure failed"));
await assert.rejects(closeOffscreen(), /Closure failed/);
assert.equal(await hasOffscreen(), true); // The failed gate did not remove the document.
await closeOffscreen();

beforeCreate receives a detached copy of the creation parameters. beforeClose takes no arguments. Both have call history, setResult, queueResult, setImplementation, failNext and reset.

Direct setResult/setImplementation on createDocument, closeDocument or hasDocument intentionally replaces the whole method, including its state changes and cancellation. Use a gate when the test should retain stateful behavior. For example, createDocument.setResult(undefined) reports success without creating a document.

Cleanup and reset

Successful closure disposes the offscreen context: its signal aborts, listeners are removed, pending context.track() operations and manual context.onMessage.emit() calls reject, and synchronous onDispose cleanup runs. Cleanup errors are aggregated after removal; even a failing cleanup does not resurrect the document or prevent a later creation. Unrelated background/page contexts are not removed.

Context-bound messaging associates pending responses with the offscreen context, so closure invalidates those responses automatically. A request may still receive an answer from another live recipient. Legacy root runtime.onMessage channels remain unscoped and are not closed indiscriminately with Offscreen. Use harness.runtime.closeMessageChannels() for explicit closure of both root and routed channels.

harness.reset() cancels pending stateful Offscreen calls, clears gates/configuration/call history and restores initial context/document fixtures. harness.contexts.reset() and harness.runtime.reset() also cancel Offscreen operations before replacing the registry. Attach rejection handlers before resetting a pending operation. Late gate fulfillment or rejection is observed but cannot register a document or remove a replacement after reset. Await cancelled production-wrapper calls before restoring globals: their asynchronous callbacks still read runtime.lastError through the installed namespace.

harness.offscreen.reset() resets only these controls and pending calls, not the current document. A single method's reset() clears that method's configuration/history, not already running operations. Arbitrary work started by a custom implementation or gate cannot be forcibly stopped; it must cooperate with its own cancellation mechanism.

Jest and jsdom

Keep the existing DOM by using environment: "preserve"; Offscreen does not replace it or create another DOM:

/** @jest-environment jsdom */
import {expect, test} from "@jest/globals";
import {createOffscreen, getOffscreenPath} from "@addon-core/browser";
import {createBrowserHarness, installBrowserGlobals} from "@addon-core/browser/testing";

test("initializes the application's offscreen document", async () => {
    const harness = createBrowserHarness();
    const originalDocument = document;
    const restore = installBrowserGlobals(harness, {environment: "preserve"});
    try {
        await createOffscreen({url: "worker.html", reasons: ["WORKERS"], justification: "Run application worker"});
        expect(await getOffscreenPath()).toBe("/worker.html");
        expect(document).toBe(originalDocument);
    } finally {
        try { harness.reset(); } finally { restore(); }
    }
});

Compatibility and browser checks

The adapter is available in every kit profile for compatibility with existing configurable controls, not because every real browser supports native Offscreen. Firefox/Safari profiles use the same model and do not prove support. Method availability is explicit, including browser-version-dependent hasDocument:

harness.capabilities.set("offscreen.hasDocument", false);
// typeof harness.chrome.offscreen.hasDocument === "undefined"; registry queries remain available.

The model does not enforce manifest permissions, MV3, extension API restrictions inside an offscreen page, bundled-file existence, HTML loading, DOM/JavaScript execution, separate incognito profiles or audio-based automatic closure. It does not reproduce browser lifecycle timing. Native constraints are described in the Chrome Offscreen reference.

The existing real-Chromium smoke runs the same probe against the built kit and a disposable MV3 extension. It compares create/close, repeated/absent-operation errors, native context fields and callback/Promise paths. hasDocument is feature-detected; its absence is reported, not counted as tested behavior. A separate messaging probe compares worker/Offscreen delivery and held-channel closure. These probes do not validate Firefox/Safari, modeled DOM execution or permissions enforcement. Fresh-tarball checks additionally run the real production helpers through ESM/CJS, TypeScript and jsdom. See the contributor procedure.