Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/workshop-backend/__tests__/browser-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type Harness = {
clientInitialized: () => boolean;
gadgetDisposed: () => boolean;
pdfRequested: () => boolean;
renderSettled: () => boolean;
exportDocument: () => string;
exportDocumentCsp: () => string | undefined;
blobRequestContinued: () => boolean;
Expand All @@ -33,6 +34,7 @@ function makeHarness(pdfChunks = ["%PDF-1.4"], closePdf = true) {
let documentTitle: string | undefined;
let gadgetDisposed = false;
let pdfRequested = false;
let renderSettled = false;
let exportDocument = "";
let exportDocumentCsp: string | undefined;
let blobRequestContinued = false;
Expand Down Expand Up @@ -62,9 +64,16 @@ function makeHarness(pdfChunks = ["%PDF-1.4"], closePdf = true) {
clientInitialized = true;
return Promise.resolve();
}
if (fn.toString().includes("MutationObserver")) {
if (!isolated) throw new Error("DOM settling ran in the main world.");
expect(clientInitialized).toBe(true);
renderSettled = true;
return Promise.resolve();
}
if (fn.toString().includes("document.title")) {
if (!isolated) throw new Error("Document title was assigned in the main world.");
expect(clientInitialized).toBe(true);
expect(renderSettled).toBe(true);
documentTitle = typeof args[0] === "string" ? args[0] : undefined;
return Promise.resolve();
}
Expand Down Expand Up @@ -175,6 +184,7 @@ function makeHarness(pdfChunks = ["%PDF-1.4"], closePdf = true) {
clientInitialized: () => clientInitialized,
gadgetDisposed: () => gadgetDisposed,
pdfRequested: () => pdfRequested,
renderSettled: () => renderSettled,
exportDocument: () => exportDocument,
exportDocumentCsp: () => exportDocumentCsp,
blobRequestContinued: () => blobRequestContinued,
Expand Down Expand Up @@ -298,11 +308,12 @@ describe("limitStream", () => {
});

describe("renderGadgetInBrowser", () => {
it("waits for the client module, streams a PDF, and releases the browser", async () => {
it("waits for the client module and DOM to settle, then streams a PDF", async () => {
let { stream, harness } = render();

expect(await collect(await stream)).toBe("%PDF-1.4");
expect(harness.clientInitialized()).toBe(true);
expect(harness.renderSettled()).toBe(true);
expect(harness.pdfRequested()).toBe(true);
expect(harness.mediaType()).toBe("print");
expect(harness.browserClosed()).toBe(true);
Expand Down
22 changes: 22 additions & 0 deletions packages/workshop-backend/browser/browser-export-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,28 @@ export async function waitForClientModule(): Promise<void> {

// Functions that run in the isolated realm:

/** Waits until the rendered DOM has remained unchanged for the requested interval. */
Comment thread
maxwellpeterson marked this conversation as resolved.
export function waitForDomSettled(quietMs: number): Promise<void> {
return new Promise(resolve => {
let timer: ReturnType<typeof setTimeout>;
const observer = new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(finish, quietMs);
});
function finish() {
observer.disconnect();
resolve();
}
observer.observe(document.documentElement, {
subtree: true,
childList: true,
attributes: true,
characterData: true,
});
timer = setTimeout(finish, quietMs);
});
}

/** Assigns the title used by PDF viewers and the static HTML snapshot. */
export function setDocumentTitle(title: string): void {
document.title = title;
Expand Down
4 changes: 4 additions & 0 deletions packages/workshop-backend/src/browser-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
sendToBrowser,
setDocumentTitle,
waitForClientModule,
waitForDomSettled,
} from "./generated/browser-export-page.js";
import { createExportDeadline, limitExportStream, MAX_EXPORT_BYTES } from "./export-limits";

Expand All @@ -21,6 +22,8 @@ type BrowserExportLogFields = {

const logger = createLogger<BrowserExportLogFields>({ component: "workshop.browser-export" });

/** Compatibility fallback for clients that schedule DOM work outside top-level await. */
const DOM_SETTLE_MS = 250;
/** Budget for releasing the browser session once an export has settled. */
const BROWSER_CLOSE_TIMEOUT_MS = 10_000;
/** Maximum number of pending Worker-to-browser RPC messages. */
Expand Down Expand Up @@ -233,6 +236,7 @@ export async function renderGadgetInBrowser(
await page.evaluate(waitForClientModule);
const frame = page.mainFrame() as FrameWithIsolatedRealm;
const isolatedRealm = frame.isolatedRealm();
await isolatedRealm.evaluate(waitForDomSettled, DOM_SETTLE_MS);
await isolatedRealm.evaluate(setDocumentTitle, documentTitle);
switch (format.contentType) {
case "application/pdf":
Expand Down
2 changes: 1 addition & 1 deletion packages/workshop-backend/src/overseer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2576,7 +2576,7 @@ class OverseerImpl implements AgentHooks {
let bundle = this.getGadgetUiBundle(gadgetId, chatId);
if (!bundle) throw new Error("This Gadget does not have a UI to export.");
let title = this.getGadgetRecord(gadgetId).title;
return renderGadgetInBrowser(browser, bundle.jsCode, title, exportGadget.move(), format);
return renderGadgetInBrowser(browser, bundle.jsCode, title, exportGadget.dup(), format);
}
}

Expand Down
Loading