* fix(ci): skip unrelated required checks cleanly Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable. * fix(ci): gate changes to install patching script * fix(ci): route cross-package test contracts narrowly * fix(ci): keep routed contracts merge-blocking * fix(server): exclude nested test utilities from builds * fix(ci): cover app-owned native and browser domains * refactor(ci): align tests with runtime domains Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check. * fix(ci): keep routing and desktop suites isolated The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs. * fix(ci): isolate desktop e2e runtime * perf(test): isolate Playwright workers * perf(test): parallelize Playwright within specs * fix(test): keep Playwright file scheduling * fix(test): share dynamic Metro fixture * fix(test): preserve own-host restart fixtures * fix(ci): preserve routed contracts after rebase * Stabilize Playwright wheel-scroll assertions
226 lines
8.6 KiB
TypeScript
226 lines
8.6 KiB
TypeScript
import { expect, type Page } from "@playwright/test";
|
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
|
import { test } from "../support/fixtures";
|
|
import { seedWorkspace, type SeedDaemonClient } from "../support/helpers/seed-client";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
|
import {
|
|
expectAgentIdle,
|
|
expectInlineWorkingIndicator,
|
|
expectTurnCopyButton,
|
|
} from "../support/helpers/agent-stream";
|
|
import {
|
|
expectReconnectingToastGone,
|
|
expectReconnectingToastVisible,
|
|
} from "../support/helpers/workspace-ui";
|
|
|
|
interface ViewedTimelineScenario {
|
|
client: SeedDaemonClient;
|
|
workspaceId: string;
|
|
firstAgentId: string;
|
|
secondAgentId: string;
|
|
cleanup(): Promise<void>;
|
|
}
|
|
|
|
async function seedViewedTimelineScenario(
|
|
options: { firstAgentModel?: string } = {},
|
|
): Promise<ViewedTimelineScenario> {
|
|
const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" });
|
|
const createAgent = (title: string, model = "ten-second-stream") =>
|
|
workspace.client.createAgent({
|
|
provider: "mock",
|
|
cwd: workspace.repoPath,
|
|
workspaceId: workspace.workspaceId,
|
|
title,
|
|
modeId: "load-test",
|
|
model,
|
|
});
|
|
const [firstAgent, secondAgent] = await Promise.all([
|
|
createAgent("First viewed chat", options.firstAgentModel),
|
|
createAgent("Second viewed chat"),
|
|
]);
|
|
return {
|
|
client: workspace.client,
|
|
workspaceId: workspace.workspaceId,
|
|
firstAgentId: firstAgent.id,
|
|
secondAgentId: secondAgent.id,
|
|
cleanup: workspace.cleanup,
|
|
};
|
|
}
|
|
|
|
async function openAgent(page: Page, scenario: ViewedTimelineScenario, agentId: string) {
|
|
await page.goto(buildHostAgentDetailRoute(getServerId(), agentId, scenario.workspaceId));
|
|
await page.waitForURL(
|
|
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
|
);
|
|
await waitForWorkspaceTabsVisible(page);
|
|
}
|
|
|
|
async function selectAgent(page: Page, title: string) {
|
|
await page.getByRole("button", { name: title, exact: true }).click();
|
|
}
|
|
|
|
async function enableMoveTabShortcut(page: Page) {
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
|
});
|
|
}
|
|
|
|
async function moveActiveTabRight(page: Page) {
|
|
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
|
|
}
|
|
|
|
async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, prompt: string) {
|
|
await scenario.client.sendAgentMessage(agentId, prompt);
|
|
const finish = await scenario.client.waitForFinish(agentId, 30_000);
|
|
expect(finish.status).toBe("idle");
|
|
}
|
|
|
|
async function startVisibleTurn(
|
|
page: Page,
|
|
scenario: ViewedTimelineScenario,
|
|
prompt: string,
|
|
): Promise<void> {
|
|
await scenario.client.sendAgentMessage(scenario.firstAgentId, prompt);
|
|
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
|
await expectInlineWorkingIndicator(page);
|
|
}
|
|
|
|
async function expectAgentConsistentlyIdle(page: Page, title: string): Promise<void> {
|
|
const tab = page.getByRole("button", { name: title, exact: true });
|
|
await expect(tab.locator('[data-status-bucket="running"]')).toHaveCount(0);
|
|
await expectAgentIdle(page);
|
|
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
|
await expectTurnCopyButton(page);
|
|
}
|
|
|
|
test.describe("Viewed agent timelines", () => {
|
|
test("a turn that finishes while unsubscribed reopens with consistently idle chrome", async ({
|
|
page,
|
|
}) => {
|
|
test.setTimeout(150_000);
|
|
const subscriptions = observeTimelineSubscriptions(page);
|
|
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
|
|
try {
|
|
await openAgent(page, scenario, scenario.firstAgentId);
|
|
await startVisibleTurn(page, scenario, "Finish after this chat becomes unsubscribed.");
|
|
await selectAgent(page, "Second viewed chat");
|
|
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
|
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
|
|
expect(finish.status).toBe("idle");
|
|
|
|
await selectAgent(page, "First viewed chat");
|
|
await expectAgentConsistentlyIdle(page, "First viewed chat");
|
|
} finally {
|
|
await scenario.cleanup();
|
|
}
|
|
});
|
|
|
|
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
|
|
test.setTimeout(90_000);
|
|
const subscriptions = observeTimelineSubscriptions(page);
|
|
const scenario = await seedViewedTimelineScenario();
|
|
try {
|
|
await openAgent(page, scenario, scenario.firstAgentId);
|
|
await selectAgent(page, "Second viewed chat");
|
|
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
|
await commitMessage(
|
|
scenario,
|
|
scenario.firstAgentId,
|
|
"Committed after the first chat unsubscribed.",
|
|
);
|
|
await expect(
|
|
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
|
).toHaveCount(0);
|
|
await selectAgent(page, "First viewed chat");
|
|
await expect(
|
|
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
|
} finally {
|
|
await scenario.cleanup();
|
|
}
|
|
});
|
|
|
|
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
|
|
test.setTimeout(60_000);
|
|
const subscriptions = observeTimelineSubscriptions(page);
|
|
const scenario = await seedViewedTimelineScenario();
|
|
try {
|
|
await openAgent(page, scenario, scenario.firstAgentId);
|
|
await selectAgent(page, "Second viewed chat");
|
|
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
|
await commitMessage(
|
|
scenario,
|
|
scenario.firstAgentId,
|
|
"Committed while the first chat is hidden.",
|
|
);
|
|
await expect(
|
|
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
|
).toHaveCount(0);
|
|
await selectAgent(page, "First viewed chat");
|
|
await expect(
|
|
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
|
} finally {
|
|
await scenario.cleanup();
|
|
}
|
|
});
|
|
|
|
test("two visible split chats both stay current", async ({ page }) => {
|
|
const scenario = await seedViewedTimelineScenario();
|
|
try {
|
|
await enableMoveTabShortcut(page);
|
|
await openAgent(page, scenario, scenario.firstAgentId);
|
|
await page.getByRole("button", { name: "Split pane right" }).click();
|
|
await selectAgent(page, "Second viewed chat");
|
|
await moveActiveTabRight(page);
|
|
await expect(
|
|
page.getByRole("button", { name: "First viewed chat", exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("button", { name: "Second viewed chat", exact: true }),
|
|
).toBeVisible();
|
|
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveCount(2);
|
|
await commitMessage(scenario, scenario.firstAgentId, "First visible pane update.");
|
|
await expect(page.getByText("First visible pane update.", { exact: true })).toBeVisible();
|
|
await expect(
|
|
page.getByRole("button", { name: "Second viewed chat", exact: true }),
|
|
).toBeVisible();
|
|
} finally {
|
|
await scenario.cleanup();
|
|
}
|
|
});
|
|
|
|
test("a visible chat catches up after reconnecting", async ({ page }) => {
|
|
const gate = await installDaemonWebSocketGate(page);
|
|
const scenario = await seedViewedTimelineScenario();
|
|
try {
|
|
await openAgent(page, scenario, scenario.firstAgentId);
|
|
await expect(page.getByRole("button", { name: "First viewed chat" })).toHaveAttribute(
|
|
"aria-selected",
|
|
"true",
|
|
);
|
|
await gate.drop();
|
|
await expectReconnectingToastVisible(page);
|
|
await commitMessage(scenario, scenario.firstAgentId, "Committed while the chat reconnects.");
|
|
await expect(
|
|
page.getByText("Committed while the chat reconnects.", { exact: true }),
|
|
).toHaveCount(0);
|
|
gate.restore();
|
|
await expectReconnectingToastGone(page);
|
|
const recoveredMessage = page.getByText("Committed while the chat reconnects.", {
|
|
exact: true,
|
|
});
|
|
await expect(recoveredMessage).toHaveCount(1);
|
|
await expect(recoveredMessage).toBeVisible();
|
|
} finally {
|
|
gate.restore();
|
|
await scenario.cleanup();
|
|
}
|
|
});
|
|
});
|