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
138 changes: 102 additions & 36 deletions apps/server/src/preview/ServerBrowser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import {
} from "@t3tools/contracts";
import * as Clock from "effect/Clock";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as PubSub from "effect/PubSub";
import * as Queue from "effect/Queue";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import type { BrowserContext, Page } from "playwright-core";
import { beforeEach, expect, vi } from "vite-plus/test";
Expand Down Expand Up @@ -473,56 +475,120 @@ it.live("readiness none responds immediately but takeover input waits for naviga
);

it.live.each([
{ method: "goto" as const, message: { type: "navigate", url: "http://localhost:5173/next" } },
{
method: "goto" as const,
message: { type: "navigate", url: "http://localhost:5173/next" },
handoff: "release",
},
{ method: "goBack" as const, message: { type: "history", delta: -1 }, handoff: "release" },
{ method: "goForward" as const, message: { type: "history", delta: 1 }, handoff: "release" },
{ method: "reload" as const, message: { type: "reload" }, handoff: "release" },
{
method: "goto" as const,
message: { type: "navigate", url: "http://localhost:5173/next" },
handoff: "disconnect",
},
])(
"$handoff waits for viewer $method to commit before agent actions",
({ method, message, handoff }) =>
Effect.scoped(
Effect.gen(function* () {
const { browser, broker, tabId } = yield* ready;
const viewerScope = yield* Scope.make();
yield* Effect.addFinalizer(() => Scope.close(viewerScope, Exit.void));
const viewer = yield* browser
.attachViewer(viewerInput(tabId, true))
.pipe(Effect.provideService(Scope.Scope, viewerScope));
yield* viewer.input({ type: "takeControl" });
const started = Promise.withResolvers<void>();
const committed = Promise.withResolvers<void>();
const events: string[] = [];
contexts[0]!.page[method].mockImplementationOnce(async () => {
started.resolve();
await committed.promise;
events.push("navigation committed");
});
const navigate = yield* viewer.input(message).pipe(Effect.forkScoped);
yield* Effect.promise(() => started.promise);
const releasing = yield* (
handoff === "disconnect"
? Scope.close(viewerScope, Exit.void)
: viewer.input({ type: "releaseControl" })
).pipe(Effect.forkScoped);
yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve()));
// Drain runnable promise work while the navigation gate remains closed.
yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)));
const status = yield* broker.invoke<PreviewAutomationStatus>({
scope,
tabId,
operation: "status",
input: {},
});
expect(status.control?.owner).toBe("agent");
const cdp = contexts[0]!.sessions[0]!;
const send = cdp.send.getMockImplementation()!;
cdp.send.mockImplementation(async (operation, input) => {
if (operation === "Runtime.evaluate") events.push("agent acted");
return send(operation, input);
});
const resumed = yield* broker
.invoke({
scope,
tabId,
operation: "evaluate",
input: { expression: "resumed()" },
})
.pipe(Effect.forkScoped);
yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)));
expect(events).toEqual([]);
committed.resolve();
yield* Fiber.join(navigate);
yield* Fiber.join(releasing);
yield* Fiber.join(resumed);
expect(events).toEqual(["navigation committed", "agent acted"]);
const calls = contexts[0]!.page[method].mock.calls;
expect(calls[0]?.at(-1)).toMatchObject({ waitUntil: "commit", timeout: 15_000 });
}),
).pipe(Effect.provide(layer)),
);

it.live.each([
{ method: "goto" as const, message: { type: "navigate", url: "http://10.255.255.1/" } },
{ method: "goBack" as const, message: { type: "history", delta: -1 } },
{ method: "goForward" as const, message: { type: "history", delta: 1 } },
{ method: "reload" as const, message: { type: "reload" } },
])("release waits for viewer $method to commit before agent actions", ({ method, message }) =>
])("a pending viewer $method allows a corrected URL and text input", ({ method, message }) =>
Effect.scoped(
Effect.gen(function* () {
const { browser, broker, tabId } = yield* ready;
const { browser, tabId } = yield* ready;
const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
yield* viewer.input({ type: "takeControl" });
yield* Queue.clear(viewer.output);
const started = Promise.withResolvers<void>();
const committed = Promise.withResolvers<void>();
const events: string[] = [];
contexts[0]!.page[method].mockImplementationOnce(async () => {
const page = contexts[0]!.page;
page[method].mockImplementationOnce(() => {
started.resolve();
await committed.promise;
events.push("navigation committed");
return committed.promise;
});
const navigate = yield* viewer.input(message).pipe(Effect.forkScoped);
yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve()));
const navigation = yield* viewer.input(message).pipe(Effect.forkScoped);
yield* Effect.promise(() => started.promise);
const releasing = yield* viewer.input({ type: "releaseControl" }).pipe(Effect.forkScoped);
let control = yield* Queue.take(viewer.output);
while (control._tag !== "control" || control.controller !== "agent") {
control = yield* Queue.take(viewer.output);
}
const cdp = contexts[0]!.sessions[0]!;
const send = cdp.send.getMockImplementation()!;
cdp.send.mockImplementation(async (operation, input) => {
if (operation === "Runtime.evaluate") events.push("agent acted");
return send(operation, input);
const correction = yield* Effect.gen(function* () {
yield* viewer.input({ type: "navigate", url: "http://localhost:5173/fixed" });
yield* viewer.input({ type: "text", text: "hello" });
}).pipe(Effect.forkScoped);
yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve()));
// Check after runnable promises drain, without waiting for navigation or a timeout.
yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)));
expect(page.goto).toHaveBeenLastCalledWith(
"http://localhost:5173/fixed",
expect.objectContaining({ waitUntil: "commit" }),
);
expect(contexts[0]!.sessions.at(-1)!.send).toHaveBeenCalledWith("Input.insertText", {
text: "hello",
});
const resumed = yield* broker
.invoke({
scope,
tabId,
operation: "evaluate",
input: { expression: "resumed()" },
})
.pipe(Effect.forkScoped);
yield* broker.invoke({ scope, tabId, operation: "status", input: {} });
expect(events).toEqual([]);
committed.resolve();
yield* Fiber.join(navigate);
yield* Fiber.join(releasing);
yield* Fiber.join(resumed);
expect(events).toEqual(["navigation committed", "agent acted"]);
const calls = contexts[0]!.page[method].mock.calls;
expect(calls[0]?.at(-1)).toMatchObject({ waitUntil: "commit", timeout: 15_000 });
yield* Fiber.join(navigation);
yield* Fiber.join(correction);
}),
).pipe(Effect.provide(layer)),
);
Expand Down
13 changes: 8 additions & 5 deletions apps/server/src/preview/ServerBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2169,23 +2169,26 @@ const make = Effect.gen(function* () {
await applySetting(tab, setting);
return;
}
// A stalled navigation must not block a corrected URL or further viewer input.
case "navigate":
if (typeof message.url === "string") {
const url = normalizePreviewUrl(message.url);
await tab.page.goto(url, VIEWER_NAVIGATION_OPTIONS);
tab.control.trackUntilHandoff(tab.page.goto(url, VIEWER_NAVIGATION_OPTIONS));
}
return;
case "history":
await (num(message.delta) < 0
? tab.page.goBack(VIEWER_NAVIGATION_OPTIONS)
: tab.page.goForward(VIEWER_NAVIGATION_OPTIONS));
tab.control.trackUntilHandoff(
num(message.delta) < 0
? tab.page.goBack(VIEWER_NAVIGATION_OPTIONS)
: tab.page.goForward(VIEWER_NAVIGATION_OPTIONS),
);
return;
case "reload":
if (message.ignoreCache === true) {
await session.send("Page.reload", { ignoreCache: true });
return;
}
await tab.page.reload(VIEWER_NAVIGATION_OPTIONS);
tab.control.trackUntilHandoff(tab.page.reload(VIEWER_NAVIGATION_OPTIONS));
return;
case "probe": {
const x = num(message.x);
Expand Down
26 changes: 20 additions & 6 deletions apps/server/src/preview/SessionControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@ export class BrowserControlInterrupted extends Error {
}
}

const settle = (work: Promise<unknown>) =>
work.then(
() => undefined,
() => undefined,
);

/** Reserves control immediately, but drains running work before the new owner can act. */
export class SessionControl {
readonly agentId: string | null;
private readonly onGenerationChange: () => void;
private tail: Promise<unknown> = Promise.resolve();
private pending: Promise<void> = Promise.resolve();
private readonly handoff = new Set<Promise<void>>();
private owner: string | null = null;
private epoch = 0;
private closed = false;
Expand Down Expand Up @@ -45,11 +52,15 @@ export class SessionControl {

/** An action can respond before its navigation commits, while later actions still wait. */
track(work: Promise<unknown>) {
const settled = work.then(
() => undefined,
() => undefined,
);
this.pending = Promise.all([this.pending, settled]).then(() => undefined);
this.pending = Promise.all([this.pending, settle(work)]).then(() => undefined);
}

/** Viewer input stays responsive, but control handoffs still drain its navigations. */
trackUntilHandoff(work: Promise<unknown>) {
const settled = settle(work).finally(() => {
this.handoff.delete(settled);
});
this.handoff.add(settled);
}

private assertOpen() {
Expand Down Expand Up @@ -131,7 +142,10 @@ export class SessionControl {
);
}
this.changeOwner(null);
await this.enqueue(afterDrain);
await this.enqueue(async () => {
await Promise.all(this.handoff);
await afterDrain();
});
}

async disconnect(viewerId: string, afterDrain?: () => Promise<void>) {
Expand Down
Loading