mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
ci: simplify and de-flake the test suite (#4868)
* chore(ci): audit packaging costs and repeated test execution Read-only audit: Linux packaging and smokes consume 12-14 minutes; all three CLI shards repeat the 294-test source suite and build the server twice. Preserve the 17 required contexts, move packaging to main, run CLI source tests once, and consolidate repeated E2E setup into journeys. Historical logs and the complete audit were reported outside the repository. * ci: move packaging to main and remove repeated CLI suites PR runners were rebuilding distribution artifacts and executing the entire CLI source suite on all three E2E shards. Keep packaging coverage after merge, let the local E2E runner own its dependency build, and execute source tests once in an existing required check. Root typecheck already validates plugin examples. * test(ci): consolidate journeys and remove repeated startup work Run user journeys with shared setup, retain one Hub CLI contract, and weight the existing Playwright shards using observed costs. Remove implementation-only and ad-hoc measurement checks. Await root-watch closure and publish creation failure commands atomically so teardown and failure injection have completion boundaries. * test(hub): remove incidental observation and speculative shutdown changes Hub control RPCs need a connected client without an agent observation. Preserve the failed-create subscription contract and remove product shutdown changes whose Windows cleanup cause was not reproduced. * fix(client): clear the scheduled retry when a connect attempt opens a transport An explicit connect() during reconnect backoff opened a new transport but left the scheduled retry armed. When it fired on the healthy connection it disposed the transport and reconnected, dropping requests in flight. This is why owned-subscriptions-old-daemon could still fail after #4858 added the explicit reconnect. attemptConnect now owns the rule ensureConnected already applied; the new unit test fails on the old client. * test(e2e): keep the completed-block, font-readiness, and live-page guards inside journeys - agent-stream-ui: the auto-scroll journey anchors on the completed intro block by content and requires its Markdown root to stay the same mounted node while later blocks stream (#1989 guard, reshaped off the tail block). - changes-pane: the code-typography journey holds font loads and requires no diff geometry before they resolve, then applies the font change. - agent-timeline-pagination: the live-output page load runs against a thirty-minute mock stream and gates the release on the running turn, so the status assertion no longer races a ten-second turn. * test(desktop): install the Electron binary once before the unit suite Electron 44 downloads its binary on the first Node require("electron") instead of at npm ci. Vitest workers importing electron-log/main raced that download and extraction (File exists on dist/electron, then "Electron failed to install correctly" in a sibling worker) on both Ubuntu and Windows desktop-tests jobs. Run the package's idempotent install-electron in one process before vitest forks. * ci(desktop-packages): pin actions and smoke packaging-path pull requests Pin checkout, setup-node, and upload-artifact to full commit SHAs. Keep packaging off ordinary PR CI, but run the Linux package smoke on pull requests that touch packages/desktop, nix, or the workflow so a packaging or sandbox regression cannot land on main unchecked. The desktop release workflow already builds and smokes its own artifacts on all platforms.
This commit is contained in:
+13
-62
@@ -201,7 +201,7 @@ jobs:
|
||||
|
||||
- name: Install virtual display
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm xdotool
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool
|
||||
|
||||
- name: Install Playwright browser
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
@@ -249,59 +249,6 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Build Linux desktop artifacts
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
# Smoke package contents and installation without spending minutes on xz compression.
|
||||
run: |
|
||||
printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json"
|
||||
npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json"
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Smoke installed artifacts with restricted user namespaces
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
|
||||
if unshare --user --map-root-user --net true; then
|
||||
echo "Expected the launching user's namespace probe to be denied"
|
||||
exit 1
|
||||
fi
|
||||
sudo apt-get install -y ./packages/desktop/release/*.deb
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/restricted
|
||||
|
||||
- name: Smoke sandbox-preserving portable launches
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
unshare --user --map-root-user --net true
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release enabled
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/userns
|
||||
|
||||
- name: Smoke installed RPM with restricted user namespaces
|
||||
if: ${{ runner.os == 'Linux' }}
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
|
||||
sudo dpkg --remove paseo
|
||||
# Ubuntu supplies the runtime libraries under Debian package names.
|
||||
# RPM still performs the real payload installation and postinstall.
|
||||
sudo rpm --install --nodeps packages/desktop/release/*.rpm
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled --installed-only
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/rpm
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() && runner.os == 'Linux' }}
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
desktop-tests-windows:
|
||||
name: desktop-tests (windows-latest)
|
||||
needs: changes
|
||||
@@ -363,9 +310,6 @@ jobs:
|
||||
- name: Run plugin SDK tests
|
||||
run: npm run test --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Typecheck plugin examples
|
||||
run: npm run typecheck --workspace=@getpaseo/plugin
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
@@ -414,6 +358,9 @@ jobs:
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# Weight file groups by observed duration; raw test counts overload
|
||||
# early agent/Changes specs. Required check names remain unchanged.
|
||||
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -499,14 +446,18 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run CLI tests
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
# The local runner builds its dependencies and shards its own E2E files.
|
||||
- name: Run CLI E2E tests
|
||||
run: npm run test:local --workspace=@getpaseo/cli
|
||||
|
||||
# Source tests do not read PASEO_CLI_TEST_SHARD. Run them once, on the
|
||||
# shortest E2E shard, after its runner has built the server stack.
|
||||
- name: Run CLI source tests
|
||||
if: ${{ env.PASEO_CLI_TEST_SHARD == '3' }}
|
||||
run: npm run test:unit --workspace=@getpaseo/cli
|
||||
|
||||
cli-tests-2:
|
||||
name: cli-tests (shard 2/3)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
name: Desktop Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Packaging leaves ordinary PR CI, but a PR that changes what gets packaged
|
||||
# or how it is smoked still proves the real artifacts before merge.
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/desktop/**"
|
||||
- "nix/**"
|
||||
- ".github/workflows/desktop-packages.yml"
|
||||
|
||||
concurrency:
|
||||
group: desktop-packages-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ONNXRUNTIME_NODE_INSTALL: skip
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Install package smoke dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm
|
||||
|
||||
- name: Build Linux desktop artifacts
|
||||
# Smoke package contents and installation without spending minutes on xz compression.
|
||||
run: |
|
||||
printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json"
|
||||
npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json"
|
||||
env:
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||
|
||||
- name: Smoke installed artifacts with restricted user namespaces
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
|
||||
if unshare --user --map-root-user --net true; then
|
||||
echo "Expected the launching user's namespace probe to be denied"
|
||||
exit 1
|
||||
fi
|
||||
sudo apt-get install -y ./packages/desktop/release/*.deb
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/restricted
|
||||
|
||||
- name: Smoke sandbox-preserving portable launches
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
unshare --user --map-root-user --net true
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release enabled
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/userns
|
||||
|
||||
- name: Smoke installed RPM with restricted user namespaces
|
||||
run: |
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1
|
||||
sudo dpkg --remove paseo
|
||||
# Ubuntu supplies the runtime libraries under Debian package names.
|
||||
# RPM still performs the real payload installation and postinstall.
|
||||
sudo rpm --install --nodeps packages/desktop/release/*.rpm
|
||||
node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled --installed-only
|
||||
env:
|
||||
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/rpm
|
||||
|
||||
- name: Upload packaged smoke diagnostics
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: desktop-packaged-smoke-linux-x64
|
||||
path: ${{ runner.temp }}/desktop-smoke
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -1,27 +1,6 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docker/**"
|
||||
- ".dockerignore"
|
||||
- ".github/workflows/docker.yml"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "patches/**"
|
||||
- "scripts/**"
|
||||
- "tsconfig.json"
|
||||
- "tsconfig.base.json"
|
||||
- "packages/app/**"
|
||||
- "packages/cli/**"
|
||||
- "packages/client/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/server/**"
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
@@ -51,7 +30,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/app/**"
|
||||
- "packages/expo-two-way-audio/**"
|
||||
- "packages/highlight/**"
|
||||
- "packages/plugin/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/build-daemon-web-ui.mjs"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -31,8 +12,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -110,8 +89,6 @@ jobs:
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
|
||||
+3
-3
@@ -114,7 +114,7 @@ The harness launches the packaged app with isolated user data and daemon state,
|
||||
- the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap;
|
||||
- the bundled CLI can query that daemon and run a terminal command.
|
||||
|
||||
Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff selects desktop coverage. The Linux job is pinned to Ubuntu 24.04 and runs twice: with AppArmor user namespace restrictions enabled, then with user namespaces available. It installs the real `.deb`, launches the real AppImage via `--appimage-extract-and-run`, and launches the extracted tar archive. It also replaces the Debian installation with the generated RPM through `rpm --install --nodeps` and checks its sandboxed launch under restrictions. Ubuntu supplies the runtime libraries under Debian package names, so this verifies the RPM payload and postinstall rather than Fedora dependency resolution. Each launch verifies the reported sandbox decision; enabled renderers must also have `NoNewPrivs: 1` and `Seccomp: 2` in `/proc`. The desktop release matrix retains its host-native smokes. Linux release builds stay on Ubuntu 22.04 to preserve their native-library baseline; the restricted-host regression runs on Ubuntu 24.04 in PR CI.
|
||||
The Desktop Packages workflow runs the Linux x64 smoke under Xvfb on main pushes and on pull requests that change `packages/desktop/**`, `nix/**`, or the workflow itself. The Linux job is pinned to Ubuntu 24.04 and runs twice: with AppArmor user namespace restrictions enabled, then with user namespaces available. It installs the real `.deb`, launches the real AppImage via `--appimage-extract-and-run`, and launches the extracted tar archive. It also replaces the Debian installation with the generated RPM through `rpm --install --nodeps` and checks its sandboxed launch under restrictions. Ubuntu supplies the runtime libraries under Debian package names, so this verifies the RPM payload and postinstall rather than Fedora dependency resolution. Each launch verifies the reported sandbox decision; enabled renderers must also have `NoNewPrivs: 1` and `Seccomp: 2` in `/proc`. The desktop release matrix retains its host-native smokes. Linux release builds stay on Ubuntu 22.04 to preserve their native-library baseline; the restricted-host regression runs on Ubuntu 24.04 after merge.
|
||||
|
||||
Never repair `chrome-sandbox` in the smoke harness. The old unpacked smoke set its mode to 4755 and concealed a broken package installer. Run installer tests as root and launch tests as an ordinary user: a root-run namespace probe does not reproduce Ubuntu's AppArmor policy for desktop users. Preserve both restricted and unrestricted cases; either one alone permits another sandbox regression.
|
||||
|
||||
@@ -132,7 +132,7 @@ npm run build:desktop -- --publish never --linux --x64 --dir
|
||||
|
||||
electron-builder packs `node_modules` by walking declared production `dependencies`. A package that imports something it only lists as a `peerDependency` resolves fine in this hoisted workspace, passes every test, and then throws `ERR_MODULE_NOT_FOUND` inside `app.asar` — killing the desktop daemon at startup. That shipped twice from `@replit/codemirror-lang-*` grammars, which are interactive editor extensions published as if they were bare parsers.
|
||||
|
||||
The packaged smoke catches it but only runs when a PR touches the `desktop` filter in `.github/ci-paths.yml`. Both offenders landed under `packages/highlight/**`, which maps to `sdk`.
|
||||
The packaged smoke catches it after merge. PRs need the fast dependency-closure test below because packaging runs on main.
|
||||
|
||||
`packages/highlight/src/__tests__/dependency-closure.test.ts` replicates the packer's traversal statically and runs with the normal unit tests. It is scoped to `@getpaseo/highlight` on purpose: that tree is small and pure, so the check is exact. Running the same walk over `@getpaseo/server` produces dozens of false positives from optional dependencies loaded behind `try`/`catch`.
|
||||
|
||||
@@ -204,7 +204,7 @@ PR checks are routed by the behavior each suite proves, using `.github/ci-paths.
|
||||
|
||||
Required matrix legs are declared as statically named jobs. Their shared steps use YAML anchors, while job-level `if` conditions let GitHub report an unaffected leg as genuinely skipped without allocating a runner or losing the exact required-check name.
|
||||
|
||||
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer, real-Electron, and packaged-app coverage together in the Ubuntu leg. CLI-side Hub changes select one focused test inside the existing required server jobs. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
|
||||
The smallest meaningful contract wins over package ownership. Tiny structural invariants such as daemon launch supervision run unconditionally in the always-running routing job instead of maintaining a transitive file list; this check reads source entrypoints and builds no product. Routed integration contracts use stable domain directories. Browser changes select the required Playwright shards; desktop changes select the existing required desktop jobs, with renderer and real-Electron coverage together in the Ubuntu leg. Packaging runs on main in the Desktop Packages, Docker, and Nix workflows. CLI-side Hub changes select one focused test inside the existing required server jobs. The CLI source suite runs once in the third required CLI job; only the separate local E2E runner is sharded, and that runner owns its dependency build. Repository scripts and the shared Vitest configuration run every PR contract because they are cross-cutting toolchain inputs.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
|
||||
@@ -872,7 +872,7 @@ test.describe("Agent message submission", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps one canonical prompt when the provider echoes before accepting", async ({
|
||||
test("keeps canonical prompts through early echoes, reload, and later turns", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({
|
||||
@@ -893,6 +893,20 @@ test.describe("Agent message submission", () => {
|
||||
await expect(submittedPrompt).toHaveCount(1);
|
||||
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
for (const [index, nextPrompt] of [
|
||||
"emit 1 coalesced agent stream updates for the second turn.",
|
||||
"emit 1 coalesced agent stream updates for the third turn.",
|
||||
].entries()) {
|
||||
await submitMessage(page, nextPrompt);
|
||||
await expect(page.getByText(nextPrompt, { exact: true })).toBeVisible();
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
await expectComposerEditable(page);
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(index + 2);
|
||||
}
|
||||
await fillComposerDraft(page, "Keep this unsent draft.");
|
||||
await composerLocator(page).blur();
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(3);
|
||||
await expectComposerEditable(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
@@ -912,16 +926,12 @@ test.describe("Agent message submission", () => {
|
||||
await submitMessage(page, prompt);
|
||||
const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
|
||||
const marker = `late-provider-identity-${Date.now()}`;
|
||||
await submittedPrompt.evaluate((element, value) => {
|
||||
element.dataset.lateProviderIdentity = value;
|
||||
}, marker);
|
||||
|
||||
await submittedPrompt.hover();
|
||||
await expect(submittedPrompt.getByTestId("rewind-menu-trigger")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.locator(`[data-late-provider-identity="${marker}"]`)).toBeVisible();
|
||||
await expect(submittedPrompt).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Stop agent", exact: true }).click();
|
||||
await expectVisibleAgentSurfacesIdle(page);
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
expectSameOlderHistoryLoadingOperation,
|
||||
expectStableHistoryStartGutter,
|
||||
expectTimelineAtHistoryStart,
|
||||
expectTimelinePromptCentered,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
holdOlderHistoryPages,
|
||||
makeLoadedTimelineFitViewport,
|
||||
openAgentTimeline,
|
||||
rememberOlderHistoryLoadingOperation,
|
||||
rememberTimelineViewport,
|
||||
rememberTimelinePromptPosition,
|
||||
reloadAgentTimelineFromPersistedReplica,
|
||||
@@ -26,42 +24,10 @@ import {
|
||||
expectTimelineViewportAnchoredAfterPrepend,
|
||||
userNavigatesTimelineToHistoryStartWithKeyboard,
|
||||
userScrollsTimelineToHistoryStart,
|
||||
waitForPersistedCanonicalTimelineRange,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { trackAgentTimelineRequests } from "../support/helpers/agent-timeline-gate";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
test.describe("Agent timeline pagination", () => {
|
||||
test("resumes a persisted canonical timeline after its exact end", async ({ page }) => {
|
||||
const prompt = "timeline persisted range resumes without tail replay";
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-persisted-range-",
|
||||
title: "Persisted timeline range",
|
||||
initialPrompt: prompt,
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
const range = await waitForPersistedCanonicalTimelineRange(page, agent.agentId);
|
||||
|
||||
const tracker = await trackAgentTimelineRequests(page, agent.agentId);
|
||||
await page.reload();
|
||||
await expectTimelinePromptVisible(page, prompt);
|
||||
|
||||
const request = await tracker.nextRequest();
|
||||
expect(request).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: range.epoch, seq: range.endSeq },
|
||||
});
|
||||
await tracker.waitForResponse();
|
||||
expect(tracker.requests().some((entry) => entry.direction === "tail")).toBe(false);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a fixed history-start gutter before, during, and after pagination", async ({
|
||||
test("keeps the history-start gutter and visible position stable through the final page", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
@@ -73,8 +39,10 @@ test.describe("Agent timeline pagination", () => {
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
await expectStableHistoryStartGutter(page);
|
||||
const viewport = await rememberTimelineViewport(page);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
await expectStableHistoryStartGutter(page);
|
||||
} finally {
|
||||
@@ -96,7 +64,9 @@ test.describe("Agent timeline pagination", () => {
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
|
||||
await history.expectRequestedPages(1);
|
||||
const viewport = await rememberTimelineViewport(page);
|
||||
history.releasePage(1);
|
||||
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
@@ -137,23 +107,6 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const viewport = await rememberTimelineViewport(page);
|
||||
|
||||
history.releasePage(1);
|
||||
await expectTimelineViewportAnchoredAfterPrepend(page, viewport);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps visible history anchored when live output grows during a prepend", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -181,7 +134,8 @@ test.describe("Agent timeline pagination", () => {
|
||||
|
||||
test("finishes loading an older page while live output continues", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
// The live turn streams for thirty minutes, so it is still running at every assertion.
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40, liveTurns: "thirty-minute-stream" });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
@@ -193,8 +147,11 @@ test.describe("Agent timeline pagination", () => {
|
||||
agent.agentId,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
);
|
||||
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await expect(timeline.getByText("walking through").first()).toBeAttached();
|
||||
history.releasePage(1);
|
||||
|
||||
await expect(timeline.getByText(agent.newestOlderPagePrompt, { exact: true })).toBeAttached();
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 });
|
||||
const running = await agent.client.fetchAgents({ scope: "active" });
|
||||
expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe(
|
||||
@@ -205,28 +162,7 @@ test.describe("Agent timeline pagination", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 40 });
|
||||
try {
|
||||
const history = await holdOlderHistoryPages(page, agent);
|
||||
await openAgentTimeline(page, agent);
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt);
|
||||
|
||||
history.releasePage(1);
|
||||
|
||||
await expectTimelinePromptPositionPreserved(page, position);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("continues one loading operation while older pages still leave history start exposed", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("keeps loading while older pages still leave history start exposed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
@@ -235,12 +171,12 @@ test.describe("Agent timeline pagination", () => {
|
||||
await openAgentTimeline(page, agent);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await history.expectRequestedPages(1);
|
||||
const loading = await rememberOlderHistoryLoadingOperation(page);
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
|
||||
history.releasePage(1);
|
||||
await history.expectRequestedPages(2);
|
||||
await expectTimelineAtHistoryStart(page);
|
||||
await expectSameOlderHistoryLoadingOperation(page, loading);
|
||||
await expect(page.getByTestId("load-older-history-spinner")).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -980,11 +980,21 @@ test("canvas diff stays sharp while its workspace pane is resized", async ({ pag
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("changes diff applies code size changes to gutter and code typography", async ({ page }) => {
|
||||
test("changes diff waits for configured fonts, then applies code typography changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 12);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await holdBrowserFontLoads(page);
|
||||
await navigateToWorkspaceChanges(page, workspace);
|
||||
|
||||
await test.step("commit no geometry before the configured fonts are ready", async () => {
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await releaseBrowserFontLoads(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
});
|
||||
const before = await readDiffTypographyGeometry(page);
|
||||
|
||||
await changeCodeTypographyFromSettings(page, {
|
||||
@@ -1003,63 +1013,31 @@ test("changes diff applies code size changes to gutter and code typography", asy
|
||||
expect(after.canvasPixels).not.toEqual(before.canvasPixels);
|
||||
});
|
||||
|
||||
test("canvas diff does not commit geometry before configured fonts are ready", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await holdBrowserFontLoads(page);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await releaseBrowserFontLoads(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
});
|
||||
|
||||
test("canvas diff creates, edits, and deletes an inline review without DOM code rows", async ({
|
||||
test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await cancelInlineReview(page);
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("autofocusing an inline review keeps the Changes tab focused", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true });
|
||||
const focusedBackground = await changesTab.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect
|
||||
.poll(() => changesTab.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.toBe(focusedBackground);
|
||||
});
|
||||
|
||||
test("inline reviews keep the browser text context menu", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await test.step("start a focused review with the browser text menu", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor-input")).toBeFocused();
|
||||
await expect(changesTab).toHaveCSS("background-color", focusedBackground);
|
||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
||||
await cancelInlineReview(page);
|
||||
});
|
||||
await test.step("save, edit, and delete the review", async () => {
|
||||
await startReviewOnFirstChangedLine(page);
|
||||
await saveInlineReview(page, "Please keep this branch explicit");
|
||||
await editInlineReview(page, "Please keep this branch named explicitly");
|
||||
await deleteInlineReview(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||
@@ -1169,31 +1147,16 @@ test("the whole reviewable row reveals the gutter affordance and uses a text cur
|
||||
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
|
||||
});
|
||||
|
||||
test("canvas diff copies a dragged character selection without opening a review", async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await page.keyboard.press("ControlOrMeta+C");
|
||||
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff preserves selection through a repaint and copies selections and source lines", async ({
|
||||
test("selects, preserves, copies, and dismisses diff text without opening a review", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openCopyableSelectionDiff(page, "ABCDEFGHIJ");
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
|
||||
await test.step("copy the selection before resizing", async () => {
|
||||
await test.step("copy the selected characters with the keyboard", async () => {
|
||||
await copyDiffSelectionWithKeyboard(page);
|
||||
await expectClipboardText(page, "CDEFGH");
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
await test.step("copy the preserved selection after resizing", async () => {
|
||||
await resizeDiffViewportHeight(page, 960);
|
||||
@@ -1204,6 +1167,16 @@ test("canvas diff preserves selection through a repaint and copies selections an
|
||||
await copyFromChangedLineMenu(page, "Copy line");
|
||||
await expectClipboardText(page, "ABCDEFGHIJ");
|
||||
});
|
||||
await test.step("dismiss a new selection without starting a review", async () => {
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
await rightClickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
|
||||
@@ -1217,19 +1190,6 @@ test("canvas diff clears a selection when collapsing an earlier file", async ({
|
||||
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
|
||||
await clickFirstChangedLine(page);
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("canvas diff replaces a selection with forward and backward drags", async ({
|
||||
context,
|
||||
page,
|
||||
@@ -1424,14 +1384,17 @@ async function setOpenChangesPresentation(
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds every font load in the document until released; later loads pass through. */
|
||||
async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const fontSet = document.fonts;
|
||||
const originalLoad = fontSet.load.bind(fontSet);
|
||||
const pending: Array<() => void> = [];
|
||||
let released = false;
|
||||
Object.defineProperty(fontSet, "load", {
|
||||
configurable: true,
|
||||
value(font: string, text?: string) {
|
||||
if (released) return originalLoad(font, text);
|
||||
return new Promise<FontFace[]>((resolve, reject) => {
|
||||
pending.push(() => {
|
||||
originalLoad(font, text).then(resolve, reject);
|
||||
@@ -1441,6 +1404,7 @@ async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||
});
|
||||
Object.assign(window, {
|
||||
__releasePaseoDiffFontLoads() {
|
||||
released = true;
|
||||
for (const release of pending.splice(0)) release();
|
||||
},
|
||||
});
|
||||
@@ -1690,12 +1654,16 @@ async function createWorkspaceWithStickyTransitionDiff(): Promise<DirtyWorkspace
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await navigateToWorkspaceChanges(page, workspace);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function navigateToWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await openChangesInVisibleExplorer(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
/** The Explorer overlay a phone-sized viewport shows, with its Changes tab selected. */
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
test.describe("Command center host labels", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("selecting an agent result opens its workspace tab", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "command-center-agent-navigation-" });
|
||||
const title = `cc-navigation-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
try {
|
||||
const agent = await createIdleAgent(seeded.client, {
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title,
|
||||
});
|
||||
|
||||
await gotoAppShell(page);
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await expectAgentTabActive(page, agent.id);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { createIdleAgent } from "../support/helpers/archive-tab";
|
||||
import { expectAgentTabActive } from "../support/helpers/launcher";
|
||||
import { openCommandCenter } from "../support/helpers/command-center";
|
||||
import { addOfflineHostAndReload } from "../support/helpers/hosts";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
@@ -109,6 +110,13 @@ test.describe("Command center workspaces", () => {
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await test.step("find an agent and open its workspace tab", async () => {
|
||||
const agentPanel = await openCommandCenter(page);
|
||||
await agentPanel.getByTestId("command-center-input").fill(AGENT_TITLE);
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAgentTabActive(page, agent.id);
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
|
||||
@@ -52,19 +52,6 @@ const TEST_JSON = {
|
||||
};
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-plus-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await openAttachmentMenu(page);
|
||||
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
});
|
||||
|
||||
test("compact Plus menu aligns attachment rows with its sheet title", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -101,59 +88,27 @@ test.describe("Composer attachments", () => {
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
|
||||
test("attaches an issue and a pull request from the repository picker", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "attach-issue",
|
||||
issues: [{ title: "fix: attach-issue-unique-alpha" }],
|
||||
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
|
||||
category: "attachments",
|
||||
issues: [{ title: "fix: attachment issue" }],
|
||||
prs: [{ title: "feat: attachment pull request", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(
|
||||
page,
|
||||
"attach-issue-unique-alpha",
|
||||
`issue:${ghRepo.issues[0].number}`,
|
||||
);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.issues[0].number,
|
||||
title: ghRepo.issues[0].title,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "attach-pr",
|
||||
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.prs[0].number,
|
||||
title: ghRepo.prs[0].title,
|
||||
});
|
||||
// Choose from the repository list. Newly created repositories are not
|
||||
// necessarily available in GitHub's separate text-search index yet.
|
||||
await selectGithubOption(page, "", `issue:${ghRepo.issues[0].number}`);
|
||||
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
|
||||
await selectGithubOption(page, "", `change_request:${ghRepo.prs[0].number}`);
|
||||
await expectGithubAttachmentPill(page, ghRepo.prs[0]);
|
||||
await expectGithubAttachmentPill(page, ghRepo.issues[0]);
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
@@ -168,7 +123,7 @@ test.describe("Composer attachments", () => {
|
||||
// needed before this can be exercised end-to-end.
|
||||
});
|
||||
|
||||
test("image lightbox opens on pill click and closes on Escape", async ({
|
||||
test("attaches, previews, and removes an image before dropping a file", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
@@ -178,38 +133,25 @@ test.describe("Composer attachments", () => {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await test.step("open the available attachment choices", async () => {
|
||||
await openAttachmentMenu(page);
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await openImageLightbox(page);
|
||||
await closeImageLightbox(page);
|
||||
});
|
||||
|
||||
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-pill-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
});
|
||||
|
||||
test("dropped JSON file renders as a file attachment in active chat", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-drop-json-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await dropFileOnComposer(page, TEST_JSON);
|
||||
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
await test.step("remove the image and attach a dropped file", async () => {
|
||||
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
|
||||
await dropFileOnComposer(page, TEST_JSON);
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
});
|
||||
});
|
||||
|
||||
test("dropped JSON file renders as a file attachment in New Workspace", async ({ page }) => {
|
||||
@@ -238,23 +180,6 @@ test.describe("Composer attachments", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-remove-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
|
||||
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("submitting while agent is running queues the message and clears the draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -111,21 +111,6 @@ test.afterEach(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("file explorer rows share the workspace title opacity treatment", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const row = page
|
||||
.getByTestId("file-explorer-tree-scroll")
|
||||
.getByTestId(/^file-explorer-row-\d+$/)
|
||||
.first();
|
||||
const name = row.getByTestId(/-name$/);
|
||||
await expect(row).toHaveCSS("opacity", "1");
|
||||
await expect(name).toHaveCSS("opacity", "0.76");
|
||||
await row.hover();
|
||||
await expect(name).toHaveCSS("opacity", "1");
|
||||
});
|
||||
|
||||
test("creates, renames, copies, and deletes entries through the file explorer", async ({
|
||||
context,
|
||||
page,
|
||||
|
||||
@@ -82,121 +82,66 @@ const test = base.extend<{ twoHostSidebar: TwoHostSidebar }>({
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("Host appearance", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("renaming a host renames its sidebar badge", async ({ page, twoHostSidebar }) => {
|
||||
const badge = {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
};
|
||||
test("customizes a host badge and preserves its appearance after reload", async ({
|
||||
page,
|
||||
twoHostSidebar,
|
||||
}) => {
|
||||
const badge = {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
};
|
||||
const hostName = "Developer MacBook Pro.local";
|
||||
|
||||
await test.step("rename the host and use its available sidebar width", async () => {
|
||||
await expectHostBadgeName(page, { ...badge, hostName: SECONDARY_HOST_LABEL });
|
||||
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await renameHostFromSettings(page, "Build Box");
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
await expectHostBadgeName(page, { ...badge, hostName: "Build Box" });
|
||||
});
|
||||
|
||||
test("host names use the available metadata width", async ({ page, twoHostSidebar }) => {
|
||||
const hostName = "Developer MacBook Pro.local";
|
||||
const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`;
|
||||
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
await renameHostFromSettings(page, hostName);
|
||||
await leaveHostAppearanceSettings(page);
|
||||
await expectHostBadgeName(page, { ...badge, hostName });
|
||||
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
|
||||
const badge = row.getByTestId(`host-badge-${twoHostSidebar.secondaryServerId}`);
|
||||
await expect(badge).toHaveText(hostName);
|
||||
const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]);
|
||||
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${badge.serverId}:${badge.workspaceId}`);
|
||||
const hostBadge = row.getByTestId(`host-badge-${badge.serverId}`);
|
||||
const [badgeBox, rowBox] = await Promise.all([hostBadge.boundingBox(), row.boundingBox()]);
|
||||
expect(badgeBox).not.toBeNull();
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(badgeBox!.width).toBeGreaterThan(96);
|
||||
expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
|
||||
const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({
|
||||
clientWidth: label.clientWidth,
|
||||
scrollWidth: label.scrollWidth,
|
||||
const widths = await hostBadge.getByText(hostName, { exact: true }).evaluate((label) => ({
|
||||
client: label.clientWidth,
|
||||
content: label.scrollWidth,
|
||||
}));
|
||||
expect(labelWidths.scrollWidth).toBeLessThanOrEqual(labelWidths.clientWidth);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.client);
|
||||
});
|
||||
|
||||
test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => {
|
||||
const badge = {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
};
|
||||
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await test.step("preview a color and apply it to the sidebar badge", async () => {
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
await chooseHostColor(page, "Teal");
|
||||
await expectHostAppearancePreview(page, { serverId: badge.serverId, hostName, color: "teal" });
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
await expectHostBadgeTinted(page, { ...badge, color: "teal" });
|
||||
await expectHostBadgeTinted(page, { ...badge, hostName, color: "teal" });
|
||||
});
|
||||
|
||||
test("icon only keeps the badge and drops the name", async ({ page, twoHostSidebar }) => {
|
||||
const badge = {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
};
|
||||
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await test.step("keep the icon-only preference through reload", async () => {
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
await chooseHostBadgeDisplay(page, "Icon only");
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
await expectHostBadgeIconOnly(page, badge);
|
||||
await expectHostBadgeIconOnly(page, { ...badge, hostName });
|
||||
await reloadPreservingHostRegistry(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expectHostBadgeIconOnly(page, { ...badge, hostName });
|
||||
});
|
||||
|
||||
test("hiding one host leaves the other host's badge visible", async ({
|
||||
page,
|
||||
twoHostSidebar,
|
||||
}) => {
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await test.step("hide this badge without hiding the other host", async () => {
|
||||
await openHostAppearanceSettings(page, badge.serverId);
|
||||
await chooseHostBadgeDisplay(page, "Hidden");
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
await expectNoHostBadge(page, {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
hostName: SECONDARY_HOST_LABEL,
|
||||
});
|
||||
await expectNoHostBadge(page, { ...badge, hostName });
|
||||
await expectHostBadgeName(page, {
|
||||
serverId: twoHostSidebar.primaryServerId,
|
||||
workspaceId: twoHostSidebar.primaryWorkspaceId,
|
||||
hostName: PRIMARY_HOST_LABEL,
|
||||
});
|
||||
});
|
||||
|
||||
test("name, color, and display survive a reload", async ({ page, twoHostSidebar }) => {
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await renameHostFromSettings(page, "Build Box");
|
||||
await chooseHostColor(page, "Amber");
|
||||
await chooseHostBadgeDisplay(page, "Icon only");
|
||||
await leaveHostAppearanceSettings(page);
|
||||
|
||||
await reloadPreservingHostRegistry(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await expectHostBadgeIconOnly(page, {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
workspaceId: twoHostSidebar.secondaryWorkspaceId,
|
||||
hostName: "Build Box",
|
||||
});
|
||||
});
|
||||
|
||||
test("the settings preview shows the badge as configured", async ({ page, twoHostSidebar }) => {
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
await renameHostFromSettings(page, "Build Box");
|
||||
await chooseHostColor(page, "Emerald");
|
||||
|
||||
await expectHostAppearancePreview(page, {
|
||||
serverId: twoHostSidebar.secondaryServerId,
|
||||
hostName: "Build Box",
|
||||
color: "emerald",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,17 +13,13 @@ import {
|
||||
pressNewTabShortcut,
|
||||
pressDirectNewTabShortcut,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
sampleTabsDuringTransition,
|
||||
expectTabTitleFits,
|
||||
terminalSurfaceLocator,
|
||||
} from "../support/helpers/launcher";
|
||||
import { expectComposerVisible, composerLocator } from "../support/helpers/composer";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
setupDeterministicPrompt,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
import { expectTerminalSurfaceVisible } from "../support/helpers/terminal-perf";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalOutputContains,
|
||||
@@ -43,7 +39,7 @@ const EMPTY_PROMPT_PROFILE: TerminalProfile = {
|
||||
id: "e2e-empty-prompt",
|
||||
name: "Empty Prompt",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo prompt-args: "$#"; sleep 10', "profile-name", "{{{prompt}}}"],
|
||||
args: ["-c", 'echo prompt-args: "$#"; exec cat', "profile-name", "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
async function tabTestIds(tabs: Locator): Promise<(string | null)[]> {
|
||||
@@ -126,48 +122,40 @@ test.describe("Tab creation", () => {
|
||||
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(newTabCountBefore);
|
||||
});
|
||||
|
||||
test("Cmd+T creates a New tab without creating an agent", async ({ page }) => {
|
||||
test("opens the menu, then creates independent New tabs without creating agents", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
|
||||
await openNewTabMenuWithShortcut(page);
|
||||
|
||||
await expect.poll(() => countTabsOfKind(page, "draft")).toBe(countBefore);
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const countBefore = await countTabsOfKind(page, "new_tab");
|
||||
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
|
||||
});
|
||||
|
||||
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
const newTabs = page
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
.filter({ hasText: "New tab" });
|
||||
await expect(newTabs.first()).toBeVisible();
|
||||
const countBefore = await newTabs.count();
|
||||
const draftCount = await countTabsOfKind(page, "draft");
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 1);
|
||||
const firstIds = await tabTestIds(newTabs);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 2);
|
||||
const ids = await tabTestIds(newTabs);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids.filter((id) => !firstIds.includes(id))).toHaveLength(1);
|
||||
await test.step("opening the plus menu leaves the current tabs intact", async () => {
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect(newTabs).toHaveCount(countBefore);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
await test.step("two shortcuts open two independent launchers", async () => {
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 1);
|
||||
const firstIds = await tabTestIds(newTabs);
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(newTabs).toHaveCount(countBefore + 2);
|
||||
const ids = await tabTestIds(newTabs);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(ids).toEqual(expect.arrayContaining(firstIds));
|
||||
expect(await countTabsOfKind(page, "draft")).toBe(draftCount);
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("New tab exposes shortcuts and supports arrow navigation after refocus", async ({
|
||||
@@ -268,110 +256,6 @@ test.describe("Tab creation", () => {
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
});
|
||||
|
||||
test("never shades the New tab button", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const tabRow = page.getByTestId("workspace-tabs-row").filter({ visible: true }).first();
|
||||
const scrollArea = tabRow.getByTestId("workspace-tabs-scroll");
|
||||
const newTabButtonInScroll = await scrollArea.getByTestId("workspace-new-tab-button").count();
|
||||
const scrollShades = await tabRow
|
||||
.locator('[data-testid^="workspace-tabs-scroll-shade-"]')
|
||||
.count();
|
||||
|
||||
expect(newTabButtonInScroll === 0 || scrollShades === 0).toBe(true);
|
||||
await expect(tabRow.getByTestId("workspace-new-tab-button")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Terminal Title Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Terminal title propagation", () => {
|
||||
// OSC title escape sequence propagation is inherently flaky — the terminal
|
||||
// must process the sequence, emit a title change event, and the tab bar
|
||||
// must re-render before the assertion deadline. Allow retries.
|
||||
test.describe.configure({ retries: 2 });
|
||||
|
||||
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await workspace.client.createTerminal(
|
||||
workspace.repoPath,
|
||||
"title-test",
|
||||
undefined,
|
||||
{
|
||||
workspaceId: workspace.workspaceId,
|
||||
},
|
||||
);
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
// Navigate to workspace and open a terminal
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await terminalSurfaceLocator(page).click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Send OSC 0 (set window title) escape sequence
|
||||
const testTitle = `E2E-Title-${Date.now()}`;
|
||||
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
// Wait for the tab to reflect the new title
|
||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||
} finally {
|
||||
await workspace.client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await workspace.client.createTerminal(
|
||||
workspace.repoPath,
|
||||
"debounce-test",
|
||||
undefined,
|
||||
{
|
||||
workspaceId: workspace.workspaceId,
|
||||
},
|
||||
);
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await terminalSurfaceLocator(page).click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Fire many rapid title changes — only the last should stick
|
||||
const finalTitle = `Final-${Date.now()}`;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
|
||||
delay: 0,
|
||||
});
|
||||
}
|
||||
await terminalSurfaceLocator(page).pressSequentially(
|
||||
`printf '\\033]0;${finalTitle}\\007'\n`,
|
||||
{ delay: 0 },
|
||||
);
|
||||
|
||||
// The tab should eventually settle on the final title
|
||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||
} finally {
|
||||
await workspace.client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -4,17 +4,21 @@ import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace
|
||||
test.describe("Delayed workspace creation", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
for (const launch of ["chat", "terminal", "empty"] as const) {
|
||||
test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "leave");
|
||||
});
|
||||
test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree");
|
||||
});
|
||||
test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "stay");
|
||||
});
|
||||
}
|
||||
// Exercise each submit handler's navigation guard. Checkout isolation shares
|
||||
// those handlers; ordinary successful launches live in the creation journeys.
|
||||
test("chat worktree creation keeps the workspace chosen while it was pending", async ({
|
||||
page,
|
||||
}) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "chat", "leave", "worktree");
|
||||
});
|
||||
|
||||
test("terminal creation keeps the workspace chosen while it was pending", async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "terminal", "leave");
|
||||
});
|
||||
|
||||
test("empty creation keeps the workspace chosen while it was pending", async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "empty", "leave");
|
||||
});
|
||||
|
||||
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
|
||||
|
||||
@@ -3,19 +3,13 @@ import { withButtonShowcase } from "../support/helpers/plugin-buttons";
|
||||
|
||||
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withButtonShowcase(page, testInfo, async (buttons) => {
|
||||
await withButtonShowcase(page, async (buttons) => {
|
||||
await buttons.verifyObservationLifetime();
|
||||
await buttons.openWideMenusAndPopovers();
|
||||
await buttons.runAndUpdateActions();
|
||||
await buttons.openCompactSheets();
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin button observations follow mounted content and error lifetimes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
await withButtonShowcase(page, testInfo, (buttons) => buttons.verifyObservationLifetime());
|
||||
});
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
beginReplicaCacheObservation,
|
||||
finishReplicaCacheObservation,
|
||||
installReplicaCacheMeasurement,
|
||||
measureReplicaCacheRestore,
|
||||
type ReplicaCacheMeasurementReport,
|
||||
} from "../support/helpers/replica-cache-measurement";
|
||||
|
||||
const RUN_MEASUREMENT = process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1";
|
||||
const TARGET_URL = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL;
|
||||
const REPORT_PATH = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_REPORT;
|
||||
const OBSERVATION_MS = Number(process.env.PASEO_REPLICA_CACHE_MEASUREMENT_MS ?? 60_000);
|
||||
const HYDRATED_SELECTOR =
|
||||
process.env.PASEO_REPLICA_CACHE_HYDRATED_SELECTOR ??
|
||||
'[data-testid="agent-chat-scroll"], [data-testid^="sidebar-workspace-row-"]';
|
||||
|
||||
const measurementDescribe = RUN_MEASUREMENT ? test.describe : test.describe.skip;
|
||||
|
||||
measurementDescribe("Replica cache live measurement", () => {
|
||||
test("observes persistence and cached restore without mutating the daemon", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(OBSERVATION_MS + 90_000);
|
||||
expect(TARGET_URL, "PASEO_REPLICA_CACHE_MEASUREMENT_URL must be a full app URL").toBeTruthy();
|
||||
expect(REPORT_PATH, "PASEO_REPLICA_CACHE_MEASUREMENT_REPORT must be set").toBeTruthy();
|
||||
|
||||
await installReplicaCacheMeasurement(page);
|
||||
await page.goto(TARGET_URL!, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
||||
|
||||
const observation = await beginReplicaCacheObservation(page);
|
||||
await page.waitForTimeout(OBSERVATION_MS);
|
||||
const observed = await finishReplicaCacheObservation(page, observation, TARGET_URL!);
|
||||
const restoreMs = await measureReplicaCacheRestore(page, TARGET_URL!, HYDRATED_SELECTOR);
|
||||
const report: ReplicaCacheMeasurementReport = { ...observed, restoreMs };
|
||||
|
||||
await mkdir(path.dirname(REPORT_PATH!), { recursive: true });
|
||||
await writeFile(REPORT_PATH!, `${JSON.stringify(report, null, 2)}\n`);
|
||||
await testInfo.attach("replica-cache-measurement", {
|
||||
body: JSON.stringify(report, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
console.log(`[measurement] Replica cache: ${JSON.stringify(report)}`);
|
||||
|
||||
expect(report.observationMs).toBeGreaterThanOrEqual(OBSERVATION_MS);
|
||||
});
|
||||
});
|
||||
@@ -21,52 +21,47 @@ import {
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
test("connections section shows the seeded connection endpoint", async ({ page }) => {
|
||||
test("visits host settings and opens the label editor", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const port = getE2EDaemonPort();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await test.step("connections section shows the seeded connection endpoint", async () => {
|
||||
await expectSettingsHeader(page, "Connections");
|
||||
await expectHostConnectionsCard(page, port);
|
||||
});
|
||||
await test.step("agents section shows the inject MCP toggle", async () => {
|
||||
await openHostSection(page, serverId, "agents");
|
||||
await expectSettingsHeader(page, "Agents");
|
||||
await expectHostInjectMcpCard(page);
|
||||
});
|
||||
await test.step("providers section shows the providers card", async () => {
|
||||
await expectHostProvidersCard(page, serverId);
|
||||
await expectSettingsHeader(page, "Providers");
|
||||
});
|
||||
await test.step("host section shows the host label and restart/remove action cards", async () => {
|
||||
await openHostSection(page, serverId, "host");
|
||||
await expectSettingsHeader(page, "Overview");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
await test.step("clicking the label pencil reveals the inline editor", async () => {
|
||||
await openHostSection(page, serverId, "host");
|
||||
|
||||
await expectSettingsHeader(page, "Connections");
|
||||
await expectHostConnectionsCard(page, port);
|
||||
});
|
||||
await expectHostLabelDisplayed(page);
|
||||
await clickEditHostLabel(page);
|
||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
await test.step("host section does not render daemon lifecycle controls for a remote daemon", async () => {
|
||||
await openHostSection(page, serverId, "host");
|
||||
|
||||
test("agents section shows the inject MCP toggle", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await openHostSection(page, serverId, "agents");
|
||||
await expectSettingsHeader(page, "Agents");
|
||||
await expectHostInjectMcpCard(page);
|
||||
});
|
||||
|
||||
test("providers section shows the providers card", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await expectHostProvidersCard(page, serverId);
|
||||
await expectSettingsHeader(page, "Providers");
|
||||
});
|
||||
|
||||
test("host section shows the host label and restart/remove action cards", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await openHostSection(page, serverId, "host");
|
||||
await expectSettingsHeader(page, "Overview");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
await expectHostNoDaemonLifecycleRow(page);
|
||||
});
|
||||
await test.step("settings sidebar exposes the flat App and Host section rows", async () => {
|
||||
await expectRetiredSidebarSectionsAbsent(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("a failed remote daemon update remains visible in the host UI", async ({
|
||||
@@ -94,40 +89,6 @@ test.describe("Settings host page", () => {
|
||||
await expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "host");
|
||||
|
||||
await expectHostLabelDisplayed(page);
|
||||
await clickEditHostLabel(page);
|
||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||
});
|
||||
|
||||
test("host section does not render daemon lifecycle controls for a remote daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "host");
|
||||
|
||||
// TODO: add a local-daemon fixture for positive daemon lifecycle coverage.
|
||||
await expectHostNoDaemonLifecycleRow(page);
|
||||
});
|
||||
|
||||
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await expectRetiredSidebarSectionsAbsent(page);
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -72,6 +72,9 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectSettingsHeader(page, "Appearance");
|
||||
await expectAppearanceContent(page);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
@@ -79,19 +82,13 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
});
|
||||
|
||||
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await expectAddHostMethodOptions(page);
|
||||
});
|
||||
|
||||
test("direct connection advanced URI round-trips SSL and password into the form", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await expectAddHostMethodOptions(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
|
||||
await toggleHostAdvanced(page);
|
||||
@@ -115,20 +112,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expectDirectHostUriHidden(page);
|
||||
});
|
||||
|
||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("pressing Escape closes settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("Escape lets settings dropdowns and modals close before leaving settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -155,66 +138,45 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expect(page.getByText("Add connection", { exact: true })).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||
test("opens compact app and host details and returns through the settings list", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
|
||||
await expectCompactSettingsList(page);
|
||||
|
||||
await expectSettingsBackButton(page);
|
||||
await goBackInSettings(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
await test.step("open app details and return to the list", async () => {
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectAppRoute(page, buildSettingsSectionRoute("diagnostics"));
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
});
|
||||
|
||||
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectAppRoute(page, buildSettingsSectionRoute("diagnostics"));
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await expectSettingsBackButton(page);
|
||||
});
|
||||
|
||||
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openSettingsSection(page, "about");
|
||||
await expectAppRoute(page, buildSettingsSectionRoute("about"));
|
||||
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
await expectSettingsBackButton(page);
|
||||
});
|
||||
|
||||
test("tapping a host section row pushes /settings/hosts/[serverId]/connections", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openCompactSettingsHost(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
});
|
||||
|
||||
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page, buildOpenProjectRoute());
|
||||
|
||||
await openCompactSettingsHost(page);
|
||||
await goBackInSettings(page);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectSettingsSidebarVisible(page);
|
||||
await test.step("open host details and return through the list", async () => {
|
||||
await openCompactSettingsHost(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await goBackInSettings(page);
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expectSettingsSidebarVisible(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await goBackInSettings(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
});
|
||||
|
||||
test("host picker settings opens Overview and backs through the settings list", async ({
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { CDPSession, Page, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
type WireDirection = "sent" | "received";
|
||||
type WirePhase = "startup" | "workspace_clicks";
|
||||
|
||||
interface ParsedWireMessage {
|
||||
type: string | null;
|
||||
requestId: string | null;
|
||||
entryCount: number | null;
|
||||
hasMore: boolean | null;
|
||||
providerEntries: ProviderSnapshotWireEntry[] | null;
|
||||
}
|
||||
|
||||
type WireFrameRecord = ParsedWireMessage & {
|
||||
phase: WirePhase;
|
||||
direction: WireDirection;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
interface ProviderSnapshotWireEntry {
|
||||
provider: string;
|
||||
status: string | null;
|
||||
modelCount: number;
|
||||
modeCount: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
interface WebSocketFrameEvent {
|
||||
requestId: string;
|
||||
response: {
|
||||
opcode: number;
|
||||
payloadData: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WireSummary {
|
||||
totalFrames: number;
|
||||
totalBytes: number;
|
||||
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
|
||||
byPhase: Record<
|
||||
WirePhase,
|
||||
{
|
||||
frames: number;
|
||||
bytes: number;
|
||||
byType: Array<{ type: string; frames: number; bytes: number }>;
|
||||
rpcCounts: Array<{ requestType: string; count: number }>;
|
||||
rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>;
|
||||
}
|
||||
>;
|
||||
fetchPages: Array<{
|
||||
phase: WirePhase;
|
||||
type: string;
|
||||
requestId: string | null;
|
||||
entries: number | null;
|
||||
hasMore: boolean | null;
|
||||
bytes: number;
|
||||
}>;
|
||||
clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>;
|
||||
providerSnapshots: Array<{
|
||||
phase: WirePhase;
|
||||
type: string;
|
||||
requestId: string | null;
|
||||
totalModels: number;
|
||||
totalModes: number;
|
||||
bytes: number;
|
||||
providers: ProviderSnapshotWireEntry[];
|
||||
}>;
|
||||
providerSnapshotTotals: Array<{
|
||||
phase: WirePhase;
|
||||
provider: string;
|
||||
frames: number;
|
||||
bytes: number;
|
||||
maxModels: number;
|
||||
maxModes: number;
|
||||
statuses: string[];
|
||||
}>;
|
||||
fork: {
|
||||
sourceHome: string | null;
|
||||
targetHome: string | null;
|
||||
copiedFiles: number | null;
|
||||
copiedBytes: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
function extractWorkspaceTestIds(elements: Element[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const element of elements.slice(0, 3)) {
|
||||
const value = element.getAttribute("data-testid");
|
||||
if (value) {
|
||||
result.push(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class WireMonitor {
|
||||
private phase: WirePhase = "startup";
|
||||
private session: CDPSession | null = null;
|
||||
readonly records: WireFrameRecord[] = [];
|
||||
|
||||
async start(page: Page): Promise<void> {
|
||||
this.session = await page.context().newCDPSession(page);
|
||||
await this.session.send("Network.enable");
|
||||
this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => {
|
||||
this.record("sent", event);
|
||||
});
|
||||
this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => {
|
||||
this.record("received", event);
|
||||
});
|
||||
}
|
||||
|
||||
setPhase(phase: WirePhase): void {
|
||||
this.phase = phase;
|
||||
}
|
||||
|
||||
hasCompletedStartupFetches(): boolean {
|
||||
return (
|
||||
this.records.some(
|
||||
(record) =>
|
||||
record.direction === "received" &&
|
||||
record.type === "fetch_agents_response" &&
|
||||
record.hasMore === false,
|
||||
) &&
|
||||
this.records.some(
|
||||
(record) =>
|
||||
record.direction === "received" &&
|
||||
record.type === "fetch_workspaces_response" &&
|
||||
record.hasMore === false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary {
|
||||
return {
|
||||
totalFrames: this.records.length,
|
||||
totalBytes: sumBytes(this.records),
|
||||
byDirection: {
|
||||
sent: summarizeDirection(this.records, "sent"),
|
||||
received: summarizeDirection(this.records, "received"),
|
||||
},
|
||||
byPhase: {
|
||||
startup: summarizePhase(this.records, "startup"),
|
||||
workspace_clicks: summarizePhase(this.records, "workspace_clicks"),
|
||||
},
|
||||
fetchPages: this.records
|
||||
.filter(
|
||||
(record) =>
|
||||
record.direction === "received" &&
|
||||
(record.type === "fetch_agents_response" ||
|
||||
record.type === "fetch_workspaces_response"),
|
||||
)
|
||||
.map((record) => ({
|
||||
phase: record.phase,
|
||||
type: record.type ?? "unknown",
|
||||
requestId: record.requestId,
|
||||
entries: record.entryCount,
|
||||
hasMore: record.hasMore,
|
||||
bytes: record.bytes,
|
||||
})),
|
||||
clickedWorkspaces,
|
||||
providerSnapshots: this.records
|
||||
.filter((record) => record.direction === "received" && record.providerEntries)
|
||||
.map((record) => ({
|
||||
phase: record.phase,
|
||||
type: record.type ?? "unknown",
|
||||
requestId: record.requestId,
|
||||
totalModels: sumProviderModels(record.providerEntries ?? []),
|
||||
totalModes: sumProviderModes(record.providerEntries ?? []),
|
||||
bytes: record.bytes,
|
||||
providers: record.providerEntries ?? [],
|
||||
})),
|
||||
providerSnapshotTotals: summarizeProviderSnapshots(this.records),
|
||||
fork: {
|
||||
sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null,
|
||||
targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null,
|
||||
copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES),
|
||||
copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private record(direction: WireDirection, event: WebSocketFrameEvent): void {
|
||||
if (event.response.opcode !== 1) {
|
||||
this.records.push({
|
||||
phase: this.phase,
|
||||
direction,
|
||||
bytes: Buffer.byteLength(event.response.payloadData),
|
||||
type: `opcode:${event.response.opcode}`,
|
||||
requestId: null,
|
||||
entryCount: null,
|
||||
hasMore: null,
|
||||
providerEntries: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.records.push({
|
||||
phase: this.phase,
|
||||
direction,
|
||||
bytes: Buffer.byteLength(event.response.payloadData, "utf8"),
|
||||
...parseWireMessage(event.response.payloadData),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseWireMessage(payloadData: string): ParsedWireMessage {
|
||||
try {
|
||||
const parsed = JSON.parse(payloadData) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return emptyParsedWireMessage();
|
||||
}
|
||||
const envelope = parsed as {
|
||||
type?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const unwrapped =
|
||||
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? envelope.message
|
||||
: parsed;
|
||||
const message = unwrapped as {
|
||||
type?: unknown;
|
||||
requestId?: unknown;
|
||||
payload?: {
|
||||
requestId?: unknown;
|
||||
entries?: unknown;
|
||||
agents?: unknown;
|
||||
pageInfo?: { hasMore?: unknown };
|
||||
};
|
||||
};
|
||||
const entries = readMessageEntries(message.payload);
|
||||
const hasMore = readMessageHasMore(message.payload);
|
||||
const messageType = typeof message.type === "string" ? message.type : null;
|
||||
const providerEntries = isProviderSnapshotMessage(messageType)
|
||||
? parseProviderSnapshotEntries(message.payload)
|
||||
: null;
|
||||
return {
|
||||
type: messageType,
|
||||
requestId: readMessageRequestId(message),
|
||||
entryCount: entries ? entries.length : null,
|
||||
hasMore,
|
||||
providerEntries,
|
||||
};
|
||||
} catch {
|
||||
return emptyParsedWireMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function readMessageRequestId(message: {
|
||||
requestId?: unknown;
|
||||
payload?: { requestId?: unknown };
|
||||
}): string | null {
|
||||
if (typeof message.requestId === "string") {
|
||||
return message.requestId;
|
||||
}
|
||||
if (typeof message.payload?.requestId === "string") {
|
||||
return message.payload.requestId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isProviderSnapshotMessage(messageType: string | null): boolean {
|
||||
return (
|
||||
messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update"
|
||||
);
|
||||
}
|
||||
|
||||
function readMessageEntries(
|
||||
payload:
|
||||
| {
|
||||
entries?: unknown;
|
||||
agents?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
): unknown[] | null {
|
||||
if (Array.isArray(payload?.entries)) {
|
||||
return payload.entries;
|
||||
}
|
||||
if (Array.isArray(payload?.agents)) {
|
||||
return payload.agents;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readMessageHasMore(
|
||||
payload: { pageInfo?: { hasMore?: unknown } } | undefined,
|
||||
): boolean | null {
|
||||
return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null;
|
||||
}
|
||||
|
||||
function emptyParsedWireMessage(): ParsedWireMessage {
|
||||
return {
|
||||
type: null,
|
||||
requestId: null,
|
||||
entryCount: null,
|
||||
hasMore: null,
|
||||
providerEntries: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const entries = (payload as { entries?: unknown }).entries;
|
||||
if (!Array.isArray(entries)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerEntries: ProviderSnapshotWireEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const provider = (entry as { provider?: unknown }).provider;
|
||||
if (typeof provider !== "string") {
|
||||
continue;
|
||||
}
|
||||
const models = (entry as { models?: unknown }).models;
|
||||
const modes = (entry as { modes?: unknown }).modes;
|
||||
const status = (entry as { status?: unknown }).status;
|
||||
providerEntries.push({
|
||||
provider,
|
||||
status: typeof status === "string" ? status : null,
|
||||
modelCount: Array.isArray(models) ? models.length : 0,
|
||||
modeCount: Array.isArray(modes) ? modes.length : 0,
|
||||
bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"),
|
||||
});
|
||||
}
|
||||
|
||||
return providerEntries.length > 0 ? providerEntries : null;
|
||||
}
|
||||
|
||||
function sumBytes(records: WireFrameRecord[]): number {
|
||||
return records.reduce((sum, record) => sum + record.bytes, 0);
|
||||
}
|
||||
|
||||
function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) {
|
||||
const selected = records.filter((record) => record.direction === direction);
|
||||
return {
|
||||
frames: selected.length,
|
||||
bytes: sumBytes(selected),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizePhase(records: WireFrameRecord[], phase: WirePhase) {
|
||||
const selected = records.filter((record) => record.phase === phase);
|
||||
return {
|
||||
frames: selected.length,
|
||||
bytes: sumBytes(selected),
|
||||
byType: summarizeByType(selected),
|
||||
rpcCounts: summarizeRpcCounts(selected),
|
||||
rpcs: summarizeRpcs(selected),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeByType(records: WireFrameRecord[]): Array<{
|
||||
type: string;
|
||||
frames: number;
|
||||
bytes: number;
|
||||
}> {
|
||||
const byType = new Map<string, { frames: number; bytes: number }>();
|
||||
for (const record of records) {
|
||||
const type = record.type ?? "unknown";
|
||||
const current = byType.get(type) ?? { frames: 0, bytes: 0 };
|
||||
current.frames += 1;
|
||||
current.bytes += record.bytes;
|
||||
byType.set(type, current);
|
||||
}
|
||||
return [...byType.entries()]
|
||||
.map(([type, value]) => Object.assign({ type }, value))
|
||||
.sort((left, right) => right.bytes - left.bytes);
|
||||
}
|
||||
|
||||
function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] {
|
||||
const responsesByRequestId = new Map<string, string>();
|
||||
for (const record of records) {
|
||||
if (record.direction !== "received" || !record.requestId) {
|
||||
continue;
|
||||
}
|
||||
responsesByRequestId.set(record.requestId, record.type ?? "unknown");
|
||||
}
|
||||
|
||||
return records
|
||||
.filter((record) => record.direction === "sent" && record.requestId && record.type)
|
||||
.map((record) => ({
|
||||
requestType: record.type ?? "unknown",
|
||||
requestId: record.requestId ?? "",
|
||||
responseType: responsesByRequestId.get(record.requestId ?? "") ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function summarizeRpcCounts(
|
||||
records: WireFrameRecord[],
|
||||
): Array<{ requestType: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const record of records) {
|
||||
if (record.direction !== "sent" || !record.type || !record.requestId) {
|
||||
continue;
|
||||
}
|
||||
counts.set(record.type, (counts.get(record.type) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([requestType, count]) => ({ requestType, count }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.count - left.count || left.requestType.localeCompare(right.requestType),
|
||||
);
|
||||
}
|
||||
|
||||
function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number {
|
||||
return entries.reduce((sum, entry) => sum + entry.modelCount, 0);
|
||||
}
|
||||
|
||||
function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number {
|
||||
return entries.reduce((sum, entry) => sum + entry.modeCount, 0);
|
||||
}
|
||||
|
||||
function summarizeProviderSnapshots(
|
||||
records: WireFrameRecord[],
|
||||
): WireSummary["providerSnapshotTotals"] {
|
||||
const byProvider = new Map<
|
||||
string,
|
||||
{
|
||||
phase: WirePhase;
|
||||
provider: string;
|
||||
frames: number;
|
||||
bytes: number;
|
||||
maxModels: number;
|
||||
maxModes: number;
|
||||
statuses: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const record of records) {
|
||||
if (record.direction !== "received" || !record.providerEntries) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of record.providerEntries) {
|
||||
const key = `${record.phase}:${entry.provider}`;
|
||||
const current = byProvider.get(key) ?? {
|
||||
phase: record.phase,
|
||||
provider: entry.provider,
|
||||
frames: 0,
|
||||
bytes: 0,
|
||||
maxModels: 0,
|
||||
maxModes: 0,
|
||||
statuses: new Set<string>(),
|
||||
};
|
||||
current.frames += 1;
|
||||
current.bytes += entry.bytes;
|
||||
current.maxModels = Math.max(current.maxModels, entry.modelCount);
|
||||
current.maxModes = Math.max(current.maxModes, entry.modeCount);
|
||||
if (entry.status) {
|
||||
current.statuses.add(entry.status);
|
||||
}
|
||||
byProvider.set(key, current);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byProvider.values()]
|
||||
.map((entry) => ({
|
||||
phase: entry.phase,
|
||||
provider: entry.provider,
|
||||
frames: entry.frames,
|
||||
bytes: entry.bytes,
|
||||
maxModels: entry.maxModels,
|
||||
maxModes: entry.maxModes,
|
||||
statuses: [...entry.statuses].sort(),
|
||||
}))
|
||||
.sort((left, right) => right.bytes - left.bytes);
|
||||
}
|
||||
|
||||
async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise<void> {
|
||||
await testInfo.attach("startup-wire-metrics.json", {
|
||||
body: JSON.stringify(summary, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("ad hoc startup wire metrics", () => {
|
||||
test.skip(
|
||||
process.env.E2E_WIRE_METRICS !== "1",
|
||||
"Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.",
|
||||
);
|
||||
|
||||
test("measures startup hydration and workspace navigation websocket traffic", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const monitor = new WireMonitor();
|
||||
await monitor.start(page);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page, 120_000);
|
||||
await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true);
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
const workspaceTestIds = await page
|
||||
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
|
||||
.evaluateAll(extractWorkspaceTestIds);
|
||||
|
||||
monitor.setPhase("workspace_clicks");
|
||||
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];
|
||||
for (const testId of workspaceTestIds) {
|
||||
const row = page.getByTestId(testId);
|
||||
if (!(await row.isVisible().catch(() => false))) {
|
||||
continue;
|
||||
}
|
||||
const beforeFrames = monitor.records.length;
|
||||
const beforeBytes = sumBytes(monitor.records);
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await page.waitForTimeout(1_000);
|
||||
clickedWorkspaces.push({
|
||||
testId,
|
||||
frames: monitor.records.length - beforeFrames,
|
||||
bytes: sumBytes(monitor.records) - beforeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
const summary = monitor.summarize(clickedWorkspaces);
|
||||
await attachSummary(testInfo, summary);
|
||||
console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN");
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
console.log("PASEO_STARTUP_WIRE_METRICS_END");
|
||||
|
||||
expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0);
|
||||
expect(clickedWorkspaces.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerEditable,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "../support/helpers/composer";
|
||||
|
||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function expectIdleComposer(page: Page): Promise<void> {
|
||||
await expectComposerEditable(page);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
|
||||
await expectIdleComposer(page);
|
||||
await page.waitForTimeout(1_000);
|
||||
await expectIdleComposer(page);
|
||||
}
|
||||
|
||||
test.describe("User message UI contract", () => {
|
||||
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "user-message-contract-e2e-",
|
||||
title: "User message contract e2e",
|
||||
});
|
||||
const prompts = [
|
||||
"emit 1 coalesced agent stream updates for user message contract turn one.",
|
||||
"emit 1 coalesced agent stream updates for user message contract turn two.",
|
||||
"emit 1 coalesced agent stream updates for user message contract turn three.",
|
||||
];
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
for (let index = 0; index < prompts.length; index += 1) {
|
||||
const prompt = prompts[index]!;
|
||||
await submitMessage(page, prompt);
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("stress-update-0", { exact: true }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expectUserMessageCount(page, index + 1);
|
||||
await expectNoLoadingRegressionAfterIdle(page);
|
||||
}
|
||||
|
||||
await fillComposerDraft(page, "append");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await expectUserMessageCount(page, 3);
|
||||
await expectIdleComposer(page);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -185,6 +185,9 @@ test.describe("Workspace navigation regression", () => {
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
// The reconnect toast belongs to the visible agent panel, which mounts
|
||||
// after the tab strip. Drop the connection only after that panel is ready.
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await daemonGate.drop();
|
||||
await daemonGate.waitForBlockedConnection();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { writeFile, rm } from "node:fs/promises";
|
||||
import { writeFile, rm, rename } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { copyPluginExample } from "./helpers/plugin-fixture";
|
||||
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
|
||||
@@ -23,9 +23,11 @@ export const test = base.extend<{
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const plugin = await copyPluginExample("lifecycle-logger");
|
||||
const gate = path.join(plugin.directory, "startup.txt");
|
||||
const failure = path.join(plugin.directory, "startup-failure.txt");
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await writeFile(gate, "hold");
|
||||
await writeFile(failure, "fail");
|
||||
await writeFile(
|
||||
path.join(plugin.directory, "index.server.ts"),
|
||||
`
|
||||
@@ -52,7 +54,8 @@ export default function contribute(server) {
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
await provide({
|
||||
release: () => rm(gate, { force: true }),
|
||||
fail: () => writeFile(gate, "fail"),
|
||||
// Replacing the command must not expose a truncated, empty file to the hook.
|
||||
fail: () => rename(failure, gate),
|
||||
});
|
||||
} finally {
|
||||
await rm(gate, { force: true });
|
||||
|
||||
@@ -39,82 +39,6 @@ export interface PromptJumpRequestTracker {
|
||||
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
|
||||
}
|
||||
|
||||
export interface TimelineRequestTracker {
|
||||
nextRequest(): Promise<{
|
||||
direction: string | null;
|
||||
cursor: { epoch: string; seq: number } | null;
|
||||
}>;
|
||||
requests(): Array<{
|
||||
direction: string | null;
|
||||
cursor: { epoch: string; seq: number } | null;
|
||||
}>;
|
||||
waitForResponse(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function trackAgentTimelineRequests(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<TimelineRequestTracker> {
|
||||
type Request = ReturnType<TimelineRequestTracker["requests"]>[number];
|
||||
const seen: Request[] = [];
|
||||
const waiters: Array<(request: Request) => void> = [];
|
||||
let responseSeen = false;
|
||||
let resolveResponse: (() => void) | null = null;
|
||||
const response = new Promise<void>((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_request" &&
|
||||
sessionMessage.agentId === agentId
|
||||
) {
|
||||
const rawCursor = sessionMessage.cursor;
|
||||
const cursor =
|
||||
rawCursor &&
|
||||
typeof rawCursor === "object" &&
|
||||
typeof (rawCursor as { epoch?: unknown }).epoch === "string" &&
|
||||
typeof (rawCursor as { seq?: unknown }).seq === "number"
|
||||
? {
|
||||
epoch: (rawCursor as { epoch: string }).epoch,
|
||||
seq: (rawCursor as { seq: number }).seq,
|
||||
}
|
||||
: null;
|
||||
const request = {
|
||||
direction: typeof sessionMessage.direction === "string" ? sessionMessage.direction : null,
|
||||
cursor,
|
||||
};
|
||||
seen.push(request);
|
||||
waiters.shift()?.(request);
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
||||
if (
|
||||
sessionMessage?.type === "fetch_agent_timeline_response" &&
|
||||
payload?.agentId === agentId
|
||||
) {
|
||||
responseSeen = true;
|
||||
resolveResponse?.();
|
||||
}
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
nextRequest() {
|
||||
const request = seen[0];
|
||||
if (request) return Promise.resolve(request);
|
||||
return new Promise((resolve) => waiters.push(resolve));
|
||||
},
|
||||
requests: () => [...seen],
|
||||
waitForResponse: () => (responseSeen ? Promise.resolve() : response),
|
||||
};
|
||||
}
|
||||
|
||||
export async function trackPromptJumpRequests(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function expectGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
): Promise<void> {
|
||||
const pill = page.getByTestId("composer-github-attachment-pill").first();
|
||||
const pill = page.getByTestId("composer-github-attachment-pill").filter({ hasText: input.title });
|
||||
await expect(pill).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pill).toContainText(`#${input.number}`);
|
||||
await expect(pill).toContainText(input.title);
|
||||
@@ -188,7 +188,7 @@ export async function openGithubPickerFromMenu(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */
|
||||
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "change_request:1"), and click it. */
|
||||
export async function selectGithubOption(
|
||||
page: Page,
|
||||
searchTerm: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
@@ -9,7 +9,6 @@ import { openCommandCenter } from "./command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
const PLUGIN_ID = "button-showcase";
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
@@ -166,17 +165,11 @@ async function command(page: Page, title: string) {
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(
|
||||
async function expectSurfaceVisible(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||
) {
|
||||
await expect(subject).toBeInViewport();
|
||||
await waitForSettledPosition(subject);
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file, animations: "disabled" });
|
||||
await testInfo.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function expectDetails(page: Page) {
|
||||
@@ -214,7 +207,7 @@ async function holdButton(page: Page, button: Locator) {
|
||||
await page.mouse.down();
|
||||
}
|
||||
|
||||
async function openCompactOverflowWithHeaderChrome(page: Page, testInfo: TestInfo) {
|
||||
async function openCompactOverflowWithHeaderChrome(page: Page) {
|
||||
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
|
||||
const action = page.getByRole("button", { name: "Deploy application", exact: true });
|
||||
const overflow = page
|
||||
@@ -230,13 +223,12 @@ async function openCompactOverflowWithHeaderChrome(page: Page, testInfo: TestInf
|
||||
await expect(action).toHaveCSS("background-color", highlight);
|
||||
await holdButton(page, overflow);
|
||||
await expect(overflow).toHaveCSS("background-color", highlight);
|
||||
await capture(page, testInfo, "12c-compact-overflow-pressed");
|
||||
await expectSurfaceVisible(page);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function withButtonShowcase(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
run: (buttons: {
|
||||
openWideMenusAndPopovers(): Promise<void>;
|
||||
runAndUpdateActions(): Promise<void>;
|
||||
@@ -300,15 +292,9 @@ export async function withButtonShowcase(
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveText(
|
||||
"Healthy app after button failure",
|
||||
);
|
||||
await showcase.disable();
|
||||
await press(page, "Close deployment details");
|
||||
await expect.poll(() => released.has(recovered)).toBe(true);
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await showcase.setTitle("Healthy app after plugin unload");
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveText(
|
||||
"Healthy app after plugin unload",
|
||||
);
|
||||
await showcase.setTitle("Button showcase");
|
||||
}),
|
||||
openWideMenusAndPopovers: () =>
|
||||
test.step("wide buttons have placement-owned labels and chevrons", async () => {
|
||||
@@ -322,23 +308,19 @@ export async function withButtonShowcase(
|
||||
.getByRole("button", { name: "Composer checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await capture(page, testInfo, "01-wide-buttons");
|
||||
await expectSurfaceVisible(page);
|
||||
await press(page, "Header checks");
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"02-header-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Environment");
|
||||
await expect(page.getByRole("menuitem", { name: "Staging", exact: true })).toBeVisible();
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"03-header-submenu",
|
||||
page.getByRole("menuitem", { name: "Staging", exact: true }),
|
||||
);
|
||||
await choose(page, "Staging");
|
||||
@@ -346,10 +328,8 @@ export async function withButtonShowcase(
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"03b-wide-overflow",
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
);
|
||||
await choose(page, "Open deployment logs");
|
||||
@@ -357,35 +337,27 @@ export async function withButtonShowcase(
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Header status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"04-header-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer checks");
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"05-composer-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Deployment details");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"06-composer-menu-content",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"07-composer-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
@@ -396,17 +368,15 @@ export async function withButtonShowcase(
|
||||
await expect(
|
||||
page.getByText("Deployment failed. Try again.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"08-action-error",
|
||||
page.getByText("Deployment failed. Try again.", { exact: true }),
|
||||
);
|
||||
await press(page, "Deploy application");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Deploy application", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "09-action-pending");
|
||||
await expectSurfaceVisible(page);
|
||||
await command(page, "Finish deployment");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Deploy application", exact: true }),
|
||||
@@ -415,7 +385,7 @@ export async function withButtonShowcase(
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Run review", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "09b-composer-pending");
|
||||
await expectSurfaceVisible(page);
|
||||
await command(page, "Finish review");
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
|
||||
"Reviewed",
|
||||
@@ -424,10 +394,10 @@ export async function withButtonShowcase(
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Run review", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "10-disabled-buttons");
|
||||
await expectSurfaceVisible(page);
|
||||
await command(page, "Enable example buttons");
|
||||
await command(page, "Use icon-only header");
|
||||
await capture(page, testInfo, "11-icon-only-header");
|
||||
await expectSurfaceVisible(page);
|
||||
await command(page, "Hide review button");
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
|
||||
0,
|
||||
@@ -456,30 +426,24 @@ export async function withButtonShowcase(
|
||||
"Button showcase with a long workspace title",
|
||||
);
|
||||
await expectHeaderTextTruncated(page);
|
||||
await capture(page, testInfo, "12-compact-buttons");
|
||||
await openCompactOverflowWithHeaderChrome(page, testInfo);
|
||||
await expectSurfaceVisible(page);
|
||||
await openCompactOverflowWithHeaderChrome(page);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"13-compact-header-overflow",
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
);
|
||||
await choose(page, "Header checks");
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"14-compact-header-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Deployment details");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"15-compact-header-content",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
@@ -490,28 +454,22 @@ export async function withButtonShowcase(
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"15b-compact-header-direct-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Run checks");
|
||||
await showcase.setTitle("Button showcase");
|
||||
await press(page, "Composer status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"16-compact-composer-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer checks");
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"17-compact-composer-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Run checks");
|
||||
@@ -529,16 +487,18 @@ export async function withButtonShowcase(
|
||||
).toHaveCount(0);
|
||||
await showcase.setTitle("Button showcase");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await capture(page, testInfo, "18-dark-buttons");
|
||||
await expectSurfaceVisible(page);
|
||||
await press(page, "Header status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
testInfo,
|
||||
"19-dark-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
const observation = page.getByLabel("Content observation", { exact: true });
|
||||
await expect(observation).not.toHaveText("");
|
||||
const owner = (await observation.textContent())!;
|
||||
await showcase.disable();
|
||||
await expect.poll(() => released.has(owner)).toBe(true);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Header status", exact: true }),
|
||||
).toHaveCount(0);
|
||||
@@ -546,6 +506,10 @@ export async function withButtonShowcase(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0);
|
||||
await showcase.setTitle("Healthy app after plugin unload");
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveText(
|
||||
"Healthy app after plugin unload",
|
||||
);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import type { CDPSession, Page } from "@playwright/test";
|
||||
|
||||
const REPLICA_CACHE_DATABASE = "paseo-replica-row-store";
|
||||
const REPLICA_CACHE_ROWS_STORE = "rows";
|
||||
const PERSIST_FRAME_NAMES = [
|
||||
"captureSessions",
|
||||
"captureHost",
|
||||
"queueReplicaChanges",
|
||||
"apply",
|
||||
"safeParse",
|
||||
"stringify",
|
||||
"setItem",
|
||||
"measuredPut",
|
||||
] as const;
|
||||
const PERSIST_STACK_ANCHORS = new Set([
|
||||
"captureSessions",
|
||||
"captureHost",
|
||||
"queueReplicaChanges",
|
||||
"persist",
|
||||
"flushPending",
|
||||
"measuredPut",
|
||||
]);
|
||||
|
||||
export interface ReplicaCacheMeasurementReport {
|
||||
targetUrl: string;
|
||||
observationMs: number;
|
||||
persistCpuMs: number;
|
||||
persistCpuFrames: Record<string, number>;
|
||||
flushes: number;
|
||||
bytesWritten: {
|
||||
total: number;
|
||||
perFlush: number[];
|
||||
};
|
||||
redundantWrites: number;
|
||||
storageDurationMs: number;
|
||||
longTasks: {
|
||||
count: number;
|
||||
totalDurationMs: number;
|
||||
maximumDurationMs: number;
|
||||
durationsMs: number[];
|
||||
};
|
||||
restoreMs: number | null;
|
||||
}
|
||||
|
||||
interface BrowserMeasurementState {
|
||||
flushes: Array<{ bytes: number; durationMs: number }>;
|
||||
longTasks: number[];
|
||||
redundantWrites: number;
|
||||
}
|
||||
|
||||
interface ProfileNode {
|
||||
id: number;
|
||||
callFrame: { functionName: string; url: string };
|
||||
parent?: number;
|
||||
}
|
||||
|
||||
interface SamplingProfile {
|
||||
nodes: ProfileNode[];
|
||||
samples?: number[];
|
||||
timeDeltas?: number[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__replicaCacheMeasurement?: BrowserMeasurementState;
|
||||
__replicaCacheRestoreReadStartedAt?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export async function installReplicaCacheMeasurement(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ databaseName, storeName }) => {
|
||||
const encoder = new TextEncoder();
|
||||
const transactionWrites = new WeakMap<
|
||||
IDBTransaction,
|
||||
Array<
|
||||
| { type: "put"; key: string; value: string; bytes: number; startedAt: number }
|
||||
| { type: "delete"; key: string; bytes: 0; startedAt: number }
|
||||
>
|
||||
>();
|
||||
const lastValues = new Map<string, string>();
|
||||
|
||||
const resolveStorageKey = (
|
||||
store: IDBObjectStore,
|
||||
value: unknown,
|
||||
explicitKey: IDBValidKey | undefined,
|
||||
): unknown => {
|
||||
if (explicitKey !== undefined) return explicitKey;
|
||||
if (value === null || typeof value !== "object") return null;
|
||||
if (Array.isArray(store.keyPath)) {
|
||||
return store.keyPath.map((part) => Reflect.get(value, part));
|
||||
}
|
||||
return typeof store.keyPath === "string" ? Reflect.get(value, store.keyPath) : null;
|
||||
};
|
||||
|
||||
window.__replicaCacheMeasurement = {
|
||||
flushes: [],
|
||||
longTasks: [],
|
||||
redundantWrites: 0,
|
||||
};
|
||||
|
||||
const observeTransaction = (transaction: IDBTransaction) => {
|
||||
let writes = transactionWrites.get(transaction);
|
||||
if (writes) return writes;
|
||||
writes = [];
|
||||
transactionWrites.set(transaction, writes);
|
||||
transaction.addEventListener("complete", () => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state || writes!.length === 0) return;
|
||||
let bytes = 0;
|
||||
let startedAt = Number.POSITIVE_INFINITY;
|
||||
for (const write of writes!) {
|
||||
bytes += write.bytes;
|
||||
startedAt = Math.min(startedAt, write.startedAt);
|
||||
if (write.type === "put") {
|
||||
if (lastValues.get(write.key) === write.value) state.redundantWrites += 1;
|
||||
lastValues.set(write.key, write.value);
|
||||
} else {
|
||||
lastValues.delete(write.key);
|
||||
}
|
||||
}
|
||||
state.flushes.push({ bytes, durationMs: performance.now() - startedAt });
|
||||
});
|
||||
return writes;
|
||||
};
|
||||
|
||||
const originalPut = IDBObjectStore.prototype.put;
|
||||
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
|
||||
const request =
|
||||
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
|
||||
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
|
||||
const rowPayload =
|
||||
value && typeof value === "object" ? Reflect.get(value, "payload") : undefined;
|
||||
let serialized = JSON.stringify(value);
|
||||
if (typeof value === "string") serialized = value;
|
||||
if (typeof rowPayload === "string") serialized = rowPayload;
|
||||
const storageKey = JSON.stringify(resolveStorageKey(this, value, key));
|
||||
observeTransaction(this.transaction).push({
|
||||
type: "put",
|
||||
key: `${this.name}:${storageKey}`,
|
||||
value: serialized,
|
||||
bytes: encoder.encode(serialized).byteLength,
|
||||
startedAt: performance.now(),
|
||||
});
|
||||
return request;
|
||||
};
|
||||
const originalDelete = IDBObjectStore.prototype.delete;
|
||||
IDBObjectStore.prototype.delete = function measuredDelete(query: IDBValidKey | IDBKeyRange) {
|
||||
const request = originalDelete.call(this, query);
|
||||
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
|
||||
observeTransaction(this.transaction).push({
|
||||
type: "delete",
|
||||
key: `${this.name}:${JSON.stringify(query)}`,
|
||||
bytes: 0,
|
||||
startedAt: performance.now(),
|
||||
});
|
||||
return request;
|
||||
};
|
||||
|
||||
const markRestoreRead = (store: IDBObjectStore) => {
|
||||
if (
|
||||
store.transaction.db.name === databaseName &&
|
||||
store.name === storeName &&
|
||||
store.transaction.mode === "readonly" &&
|
||||
window.__replicaCacheRestoreReadStartedAt === undefined
|
||||
) {
|
||||
window.__replicaCacheRestoreReadStartedAt = performance.now();
|
||||
}
|
||||
};
|
||||
const originalGet = IDBObjectStore.prototype.get;
|
||||
IDBObjectStore.prototype.get = function measuredGet(query: IDBValidKey | IDBKeyRange) {
|
||||
markRestoreRead(this);
|
||||
return originalGet.call(this, query);
|
||||
};
|
||||
const originalGetAll = IDBObjectStore.prototype.getAll;
|
||||
IDBObjectStore.prototype.getAll = function measuredGetAll(
|
||||
query?: IDBValidKey | IDBKeyRange | null,
|
||||
count?: number,
|
||||
) {
|
||||
markRestoreRead(this);
|
||||
return originalGetAll.call(this, query, count);
|
||||
};
|
||||
const originalOpenCursor = IDBObjectStore.prototype.openCursor;
|
||||
IDBObjectStore.prototype.openCursor = function measuredOpenCursor(
|
||||
query?: IDBValidKey | IDBKeyRange | null,
|
||||
direction?: IDBCursorDirection,
|
||||
) {
|
||||
markRestoreRead(this);
|
||||
return originalOpenCursor.call(this, query, direction);
|
||||
};
|
||||
|
||||
if (typeof PerformanceObserver !== "undefined") {
|
||||
const observer = new PerformanceObserver((entries) => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state) return;
|
||||
for (const entry of entries.getEntries()) state.longTasks.push(entry.duration);
|
||||
});
|
||||
try {
|
||||
observer.observe({ type: "longtask", buffered: true });
|
||||
} catch {
|
||||
// Chromium exposes Long Tasks; leave an empty list on runtimes that do not.
|
||||
}
|
||||
}
|
||||
},
|
||||
{ databaseName: REPLICA_CACHE_DATABASE, storeName: REPLICA_CACHE_ROWS_STORE },
|
||||
);
|
||||
}
|
||||
|
||||
export async function beginReplicaCacheObservation(
|
||||
page: Page,
|
||||
): Promise<{ cdp: CDPSession; startedAt: number }> {
|
||||
await page.evaluate(() => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state) throw new Error("Replica cache measurement is not installed");
|
||||
state.flushes = [];
|
||||
state.longTasks = [];
|
||||
state.redundantWrites = 0;
|
||||
});
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Profiler.enable");
|
||||
await cdp.send("Profiler.setSamplingInterval", { interval: 100 });
|
||||
await cdp.send("Profiler.start");
|
||||
return { cdp, startedAt: Date.now() };
|
||||
}
|
||||
|
||||
export async function finishReplicaCacheObservation(
|
||||
page: Page,
|
||||
observation: { cdp: CDPSession; startedAt: number },
|
||||
targetUrl: string,
|
||||
): Promise<Omit<ReplicaCacheMeasurementReport, "restoreMs">> {
|
||||
const result = (await observation.cdp.send("Profiler.stop")) as { profile: SamplingProfile };
|
||||
await observation.cdp.detach();
|
||||
const browser = await page.evaluate(() => {
|
||||
const state = window.__replicaCacheMeasurement;
|
||||
if (!state) throw new Error("Replica cache measurement is not installed");
|
||||
return state;
|
||||
});
|
||||
const cpu = summarizePersistCpu(result.profile);
|
||||
const bytesPerFlush = browser.flushes.map((flush) => flush.bytes);
|
||||
const longTaskDurations = browser.longTasks;
|
||||
return {
|
||||
targetUrl,
|
||||
observationMs: Date.now() - observation.startedAt,
|
||||
persistCpuMs: cpu.totalMs,
|
||||
persistCpuFrames: cpu.byFrame,
|
||||
flushes: browser.flushes.length,
|
||||
bytesWritten: {
|
||||
total: bytesPerFlush.reduce((sum, bytes) => sum + bytes, 0),
|
||||
perFlush: bytesPerFlush,
|
||||
},
|
||||
redundantWrites: browser.redundantWrites,
|
||||
storageDurationMs: browser.flushes.reduce((sum, flush) => sum + flush.durationMs, 0),
|
||||
longTasks: {
|
||||
count: longTaskDurations.length,
|
||||
totalDurationMs: longTaskDurations.reduce((sum, duration) => sum + duration, 0),
|
||||
maximumDurationMs: Math.max(0, ...longTaskDurations),
|
||||
durationsMs: longTaskDurations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function measureReplicaCacheRestore(
|
||||
page: Page,
|
||||
targetUrl: string,
|
||||
hydratedSelector: string,
|
||||
): Promise<number | null> {
|
||||
await page.routeWebSocket(/\/ws(?:\?|$)/, async (socket) => {
|
||||
await socket.close({ code: 1000, reason: "Hold reconnect until cached replica paints" });
|
||||
});
|
||||
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
|
||||
const restoredAt = await page.waitForFunction(
|
||||
(selector) => {
|
||||
const startedAt = window.__replicaCacheRestoreReadStartedAt;
|
||||
if (startedAt === undefined) return null;
|
||||
const element = document.querySelector(selector);
|
||||
if (!element || !(element instanceof HTMLElement) || element.offsetParent === null)
|
||||
return null;
|
||||
return performance.now();
|
||||
},
|
||||
hydratedSelector,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
const restoredAtMs = await restoredAt.jsonValue();
|
||||
const startedAtMs = await page.evaluate(() => window.__replicaCacheRestoreReadStartedAt ?? null);
|
||||
return typeof restoredAtMs === "number" && startedAtMs !== null
|
||||
? restoredAtMs - startedAtMs
|
||||
: null;
|
||||
}
|
||||
|
||||
function summarizePersistCpu(profile: SamplingProfile): {
|
||||
totalMs: number;
|
||||
byFrame: Record<string, number>;
|
||||
} {
|
||||
const nodes = new Map(profile.nodes.map((node) => [node.id, node]));
|
||||
const byFrame: Record<string, number> = {};
|
||||
let totalMicroseconds = 0;
|
||||
for (const [index, sampleId] of (profile.samples ?? []).entries()) {
|
||||
const delta = profile.timeDeltas?.[index] ?? 0;
|
||||
const stackFunctions = new Set<string>();
|
||||
let node = nodes.get(sampleId);
|
||||
while (node) {
|
||||
stackFunctions.add(node.callFrame.functionName);
|
||||
node = node.parent === undefined ? undefined : nodes.get(node.parent);
|
||||
}
|
||||
if (![...stackFunctions].some((name) => PERSIST_STACK_ANCHORS.has(name))) continue;
|
||||
const matched = PERSIST_FRAME_NAMES.filter((name) => stackFunctions.has(name));
|
||||
if (matched.length === 0) continue;
|
||||
totalMicroseconds += delta;
|
||||
for (const pattern of matched) byFrame[pattern] = (byFrame[pattern] ?? 0) + delta / 1_000;
|
||||
}
|
||||
return { totalMs: totalMicroseconds / 1_000, byFrame };
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export { holdDaemonHydration };
|
||||
|
||||
interface LongTimelineAgentOptions {
|
||||
turns: number;
|
||||
/** Live turns stream for ten seconds unless a test needs one that outlasts it. */
|
||||
liveTurns?: "ten-second-stream" | "thirty-minute-stream";
|
||||
}
|
||||
|
||||
export interface LongTimelineAgent extends MockAgentWorkspace {
|
||||
@@ -23,6 +25,8 @@ export interface LongTimelineAgent extends MockAgentWorkspace {
|
||||
firstOlderPagePrompt: string;
|
||||
initialTailAnchorPrompt: string;
|
||||
initialTailOldestPrompt: string;
|
||||
/** The newest prompt on the first older page, adjacent to the initial tail. */
|
||||
newestOlderPagePrompt: string;
|
||||
oldestPrompt: string;
|
||||
newestPrompt: string;
|
||||
}
|
||||
@@ -40,12 +44,6 @@ interface TimelineViewportSnapshot {
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
interface PersistedCanonicalTimelineRange {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export interface TimelinePresentationSnapshot {
|
||||
marker: string;
|
||||
position: TimelinePromptPositionSnapshot;
|
||||
@@ -57,11 +55,6 @@ interface TimelinePromptPositionSnapshot {
|
||||
top: number;
|
||||
}
|
||||
|
||||
interface OlderHistoryLoadingOperation {
|
||||
animationStartTime: number | null;
|
||||
marker: string;
|
||||
}
|
||||
|
||||
interface OlderHistoryPages {
|
||||
expectRequestedPages(count: number): Promise<void>;
|
||||
expectSettledWithRequestedPages(count: number): Promise<void>;
|
||||
@@ -82,7 +75,7 @@ export async function seedLongMockAgentTimeline(
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-pagination-",
|
||||
title: "Timeline pagination regression",
|
||||
model: "ten-second-stream",
|
||||
model: options.liveTurns ?? "ten-second-stream",
|
||||
});
|
||||
|
||||
for (let index = 0; index < options.turns; index += 1) {
|
||||
@@ -96,6 +89,7 @@ export async function seedLongMockAgentTimeline(
|
||||
firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)),
|
||||
initialTailAnchorPrompt: promptForTurn(Math.max(0, options.turns - 5)),
|
||||
initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)),
|
||||
newestOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 21)),
|
||||
oldestPrompt: promptForTurn(0),
|
||||
newestPrompt: promptForTurn(options.turns - 1),
|
||||
};
|
||||
@@ -304,34 +298,6 @@ export async function reloadAgentTimelineFromPersistedReplica(
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
}
|
||||
|
||||
export async function waitForPersistedCanonicalTimelineRange(
|
||||
page: Page,
|
||||
agentId: string,
|
||||
): Promise<PersistedCanonicalTimelineRange> {
|
||||
const readRange = async () => {
|
||||
const cache = await readReplicaCache(page);
|
||||
if (cache?.version !== 6) return null;
|
||||
const range = cache.hosts
|
||||
?.flatMap((host) => host.timelines)
|
||||
.find((timeline) => timeline.agentId === agentId)?.range;
|
||||
if (
|
||||
typeof range?.epoch !== "string" ||
|
||||
typeof range.startSeq !== "number" ||
|
||||
typeof range.endSeq !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq };
|
||||
};
|
||||
|
||||
await expect.poll(readRange).not.toBeNull();
|
||||
const range = await readRange();
|
||||
if (!range) {
|
||||
throw new Error(`Persisted canonical timeline range is missing for ${agentId}`);
|
||||
}
|
||||
return range;
|
||||
}
|
||||
|
||||
export async function holdNextOlderTimelinePage(
|
||||
page: Page,
|
||||
agent: LongTimelineAgent,
|
||||
@@ -457,49 +423,6 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
|
||||
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
|
||||
}
|
||||
|
||||
export async function rememberOlderHistoryLoadingOperation(
|
||||
page: Page,
|
||||
): Promise<OlderHistoryLoadingOperation> {
|
||||
const slot = page.getByTestId("load-older-history-spinner");
|
||||
await expect(slot).toBeVisible();
|
||||
const marker = `older-history-loading-${Date.now()}`;
|
||||
await slot.evaluate((element, operationMarker) => {
|
||||
const candidates = [element, ...Array.from(element.querySelectorAll("*"))];
|
||||
const animated = candidates.find(
|
||||
(candidate) => getComputedStyle(candidate).animationName !== "none",
|
||||
);
|
||||
if (!(animated instanceof HTMLElement)) {
|
||||
throw new Error("Expected the older-history loader to contain an animated element");
|
||||
}
|
||||
animated.dataset.olderHistoryLoadingOperation = operationMarker;
|
||||
}, marker);
|
||||
const animated = page.locator(`[data-older-history-loading-operation="${marker}"]`);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const startTime = await animated.evaluate((element) => element.getAnimations()[0]?.startTime);
|
||||
return typeof startTime === "number" ? startTime : null;
|
||||
})
|
||||
.not.toBeNull();
|
||||
const animationStartTime = await animated.evaluate((element) => {
|
||||
const startTime = element.getAnimations()[0]?.startTime;
|
||||
return typeof startTime === "number" ? startTime : null;
|
||||
});
|
||||
return { animationStartTime, marker };
|
||||
}
|
||||
|
||||
export async function expectSameOlderHistoryLoadingOperation(
|
||||
page: Page,
|
||||
operation: OlderHistoryLoadingOperation,
|
||||
): Promise<void> {
|
||||
const animated = page.locator(`[data-older-history-loading-operation="${operation.marker}"]`);
|
||||
await expect(animated).toBeVisible();
|
||||
const animationStartTime = await animated.evaluate((element) => {
|
||||
const startTime = element.getAnimations()[0]?.startTime;
|
||||
return typeof startTime === "number" ? startTime : null;
|
||||
});
|
||||
expect(animationStartTime).toBe(operation.animationStartTime);
|
||||
}
|
||||
|
||||
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
|
||||
@@ -1000,6 +1000,49 @@ test("ensureConnected reconnects immediately without leaving the scheduled retry
|
||||
}
|
||||
});
|
||||
|
||||
test("connect during a scheduled retry reconnects immediately without leaving the retry armed", async () => {
|
||||
useHeartbeatClock();
|
||||
try {
|
||||
const first = createMockTransport();
|
||||
const second = createMockTransport();
|
||||
const third = createMockTransport();
|
||||
const transports = [first, second, third];
|
||||
let transportIndex = 0;
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_explicit_reconnect",
|
||||
reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 },
|
||||
transportFactory: () => {
|
||||
const transport = transports[transportIndex];
|
||||
if (!transport) throw new Error("unexpected extra reconnect");
|
||||
transportIndex += 1;
|
||||
return transport.transport;
|
||||
},
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const initialConnect = client.connect();
|
||||
first.triggerOpen();
|
||||
await initialConnect;
|
||||
first.triggerClose({ code: 1001, reason: "daemon restarted" });
|
||||
expect(client.getConnectionState().status).toBe("disconnected");
|
||||
|
||||
const reconnect = client.connect();
|
||||
expect(client.getConnectionState().status).toBe("connecting");
|
||||
expect(transportIndex).toBe(2);
|
||||
|
||||
second.triggerOpen();
|
||||
await reconnect;
|
||||
expect(client.getConnectionState().status).toBe("connected");
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
|
||||
expect(client.getConnectionState().status).toBe("connected");
|
||||
expect(transportIndex).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("disabling reconnect cancels a pending retry until explicitly resumed", async () => {
|
||||
useHeartbeatClock();
|
||||
try {
|
||||
|
||||
@@ -1256,6 +1256,12 @@ export class DaemonClient {
|
||||
if (this.connectionState.status === "connecting") {
|
||||
return;
|
||||
}
|
||||
// This attempt supersedes any retry the last disconnect scheduled. Left
|
||||
// armed, that retry would tear down the connection this attempt opens.
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const password = normalizePassword(this.config.password);
|
||||
@@ -1464,10 +1470,6 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
if (this.connectionState.status === "connecting") return;
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
if (this.connectPromise) {
|
||||
this.attemptConnect();
|
||||
return;
|
||||
|
||||
@@ -110,6 +110,29 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page }
|
||||
await page.keyboard.press("Shift+?");
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await test.step("show the cleared shortcut in help, then bind new keys", async () => {
|
||||
const help = await openCheatSheet(page);
|
||||
const row = help.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`);
|
||||
await expect(row.getByText("Not set", { exact: true })).toBeVisible();
|
||||
await expect(help.getByText("?", { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "shortcuts");
|
||||
|
||||
await openRowMenu(page);
|
||||
await bind.click();
|
||||
await page.keyboard.press("Alt+Shift+K");
|
||||
await page.getByText("Done", { exact: true }).click();
|
||||
await expect(page.getByText("⌥⇧K", { exact: true })).toBeVisible();
|
||||
const reboundHelp = await openCheatSheet(page);
|
||||
const reboundRow = reboundHelp.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`);
|
||||
await expect(reboundRow.getByText("⌥⇧K", { exact: true })).toBeVisible();
|
||||
await expect(reboundRow.getByText("?", { exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "shortcuts");
|
||||
});
|
||||
|
||||
await openRowMenu(page);
|
||||
await page.getByTestId(`shortcut-reset-${SHORTCUTS_ROW}`).click();
|
||||
await expect(notSet).toHaveCount(0);
|
||||
@@ -123,46 +146,3 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page }
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("an unassigned shortcut lists no keys in the shortcuts cheat sheet", async ({ page }) => {
|
||||
await openShortcutsSettings(page);
|
||||
|
||||
await openRowMenu(page);
|
||||
await page.getByTestId(`shortcut-clear-${SHORTCUTS_ROW}`).click();
|
||||
await expect(page.getByText("Not set", { exact: true })).toBeVisible();
|
||||
|
||||
const dialog = await openCheatSheet(page);
|
||||
|
||||
// Addressed by testID rather than by filtering on its own text: an anchored
|
||||
// `hasText` matches only the innermost node holding exactly that label, so a
|
||||
// row-scoped assertion about anything *else* in the row finds nothing.
|
||||
const row = dialog.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`);
|
||||
await expect(row).toBeVisible();
|
||||
// No badge pill, blank or otherwise, for a shortcut with no keys.
|
||||
await expect(dialog.getByText("?", { exact: true })).toHaveCount(0);
|
||||
// It says so, rather than leaving a silent gap where the keys were. Same words
|
||||
// the settings row uses for the same state.
|
||||
await expect(row.getByText("Not set", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a rebound shortcut lists its new keys in the shortcuts cheat sheet", async ({ page }) => {
|
||||
await openShortcutsSettings(page);
|
||||
|
||||
const defaultKeys = page.getByText("?", { exact: true });
|
||||
await expect(defaultKeys).toBeVisible();
|
||||
|
||||
await page.getByTestId(`shortcut-actions-${SHORTCUTS_ROW}`).click();
|
||||
await page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`).click();
|
||||
await page.keyboard.press("Alt+Shift+K");
|
||||
await page.getByText("Done", { exact: true }).click();
|
||||
|
||||
const reboundKeys = page.getByText("⌥⇧K", { exact: true });
|
||||
await expect(reboundKeys).toBeVisible();
|
||||
await expect(defaultKeys).toHaveCount(0);
|
||||
|
||||
const dialog = await openCheatSheet(page);
|
||||
const row = dialog.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`);
|
||||
await expect(row.getByText("⌥⇧K", { exact: true })).toBeVisible();
|
||||
// The whole point: the cheat sheet stops advertising the shipped default.
|
||||
await expect(row.getByText("?", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -49,17 +49,6 @@ test.describe("Desktop updates", () => {
|
||||
await expect(page.getByTestId("host-page-update-button")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
|
||||
await installDesktopRuntime(page, {
|
||||
serverId: getServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await expectUpdateBanner(page, "1.2.3");
|
||||
});
|
||||
|
||||
test("clicking install shows the installing state on the callout", async ({ page }) => {
|
||||
await installDesktopRuntime(page, {
|
||||
serverId: getServerId(),
|
||||
@@ -105,9 +94,7 @@ test.describe("Desktop updates", () => {
|
||||
});
|
||||
|
||||
test.describe("Desktop daemon management", () => {
|
||||
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("cancelling the management confirmation preserves the enabled daemon", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await installDesktopRuntime(page, {
|
||||
serverId,
|
||||
@@ -124,22 +111,6 @@ test.describe("Desktop daemon management", () => {
|
||||
await expectDaemonManagementEnabled(page);
|
||||
});
|
||||
|
||||
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await installDesktopRuntime(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
ownedByDesktop: true,
|
||||
confirmShouldAccept: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await expectDaemonManagementEnabled(page);
|
||||
await toggleDaemonManagement(page, "disable");
|
||||
await expectDaemonManagementEnabled(page);
|
||||
});
|
||||
|
||||
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await installDesktopRuntime(page, {
|
||||
@@ -176,93 +147,34 @@ test.describe("Desktop daemon management", () => {
|
||||
await expectDaemonStatusLogPath(page, realState.logPath);
|
||||
});
|
||||
|
||||
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
await installDesktopRuntime(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
ownedByDesktop: true,
|
||||
daemonPid: realState.pid,
|
||||
daemonVersion: realState.version,
|
||||
daemonLogPath: realState.logPath,
|
||||
confirmShouldAccept: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await expectDaemonStatusPid(page, realState.pid);
|
||||
|
||||
await toggleDaemonManagement(page, "disable");
|
||||
await expectDaemonManagementDisabled(page);
|
||||
await expectDaemonStatusPid(page, null);
|
||||
|
||||
await toggleDaemonManagement(page, "enable");
|
||||
await expectDaemonManagementEnabled(page);
|
||||
const newPid = realState.pid !== null ? realState.pid + 1000 : 11000;
|
||||
await expectDaemonStatusPid(page, newPid);
|
||||
});
|
||||
|
||||
test("pausing management preserves an attached legacy desktop-managed daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
await installDesktopRuntime(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
ownedByDesktop: false,
|
||||
daemonPid: realState.pid,
|
||||
confirmShouldAccept: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
const dialog = await interceptDaemonManagementConfirmDialog(page);
|
||||
expect(dialog).toEqual({
|
||||
title: "Pause built-in daemon",
|
||||
message: "Pause automatic daemon management? The attached daemon will keep running.",
|
||||
});
|
||||
await expectDaemonManagementDisabled(page);
|
||||
await expectDaemonStatusPid(page, realState.pid);
|
||||
|
||||
await toggleDaemonManagement(page, "enable");
|
||||
await expectDaemonManagementEnabled(page);
|
||||
await expectDaemonStatusPid(page, realState.pid);
|
||||
});
|
||||
|
||||
for (const ownedByDesktop of [false, true]) {
|
||||
for (const confirmShouldAccept of [false, true]) {
|
||||
test(`${confirmShouldAccept ? "confirming" : "cancelling"} Stop identifies the ${ownedByDesktop ? "owned" : "attached"} daemon`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
const daemonHome = process.env.E2E_PASEO_HOME!;
|
||||
await installDesktopRuntime(page, {
|
||||
serverId,
|
||||
daemonPid: realState.pid,
|
||||
daemonHome,
|
||||
ownedByDesktop,
|
||||
confirmShouldAccept,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
const dialog = await interceptDaemonStopConfirmDialog(page);
|
||||
expect(dialog).toEqual({
|
||||
title: "Stop local daemon?",
|
||||
message: [
|
||||
ownedByDesktop
|
||||
? "This daemon was launched by this Desktop session."
|
||||
: "This daemon was not launched by this Desktop session.",
|
||||
`Home: ${daemonHome}`,
|
||||
`Supervisor PID: ${realState.pid}`,
|
||||
"Running agent work will be interrupted.",
|
||||
].join("\n"),
|
||||
});
|
||||
await expectDaemonStatusPid(page, confirmShouldAccept ? null : realState.pid);
|
||||
for (const confirmShouldAccept of [false, true]) {
|
||||
test(`${confirmShouldAccept ? "confirming" : "cancelling"} Stop identifies the owned daemon`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
const daemonHome = process.env.E2E_PASEO_HOME!;
|
||||
await installDesktopRuntime(page, {
|
||||
serverId,
|
||||
daemonPid: realState.pid,
|
||||
daemonHome,
|
||||
ownedByDesktop: true,
|
||||
confirmShouldAccept,
|
||||
});
|
||||
}
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
const dialog = await interceptDaemonStopConfirmDialog(page);
|
||||
expect(dialog).toEqual({
|
||||
title: "Stop local daemon?",
|
||||
message: [
|
||||
"This daemon was launched by this Desktop session.",
|
||||
`Home: ${daemonHome}`,
|
||||
`Supervisor PID: ${realState.pid}`,
|
||||
"Running agent work will be interrupted.",
|
||||
].join("\n"),
|
||||
});
|
||||
await expectDaemonStatusPid(page, confirmShouldAccept ? null : realState.pid);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop",
|
||||
"test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs",
|
||||
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
|
||||
"test": "vitest run --exclude \"e2e/**\"",
|
||||
"test": "install-electron && vitest run --exclude \"e2e/**\"",
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Writable } from "node:stream";
|
||||
@@ -367,7 +366,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
describe.runIf(process.platform === "win32")(
|
||||
"OpenCodeServerManager Windows OpenCode npm install",
|
||||
() => {
|
||||
test("starts the helper server from opencode.exe instead of the npm opencode.cmd shim", async () => {
|
||||
test("resolves the Windows npm shim to its native helper executable", async () => {
|
||||
const detectedOpenCode = await findExecutable("opencode");
|
||||
expect(detectedOpenCode, "Windows CI must install opencode-ai before server tests").not.toBe(
|
||||
null,
|
||||
@@ -376,27 +375,21 @@ describe.runIf(process.platform === "win32")(
|
||||
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-real-windows-"));
|
||||
const opencodeHomeDir = path.join(tempDir, "opencode-home");
|
||||
const managedProcesses = new FakeManagedProcesses();
|
||||
const runtime = new FakeOpenCodeServerRuntime([4604], { autoAnnounce: true });
|
||||
const manager = new OpenCodeServerManager({
|
||||
logger: createTestLogger(),
|
||||
managedProcesses,
|
||||
managedProcesses: runtime.managedProcesses,
|
||||
resolveHomeDir: () => opencodeHomeDir,
|
||||
portAllocator: runtime.allocatePort,
|
||||
spawnServerProcess: runtime.spawnServerProcess,
|
||||
terminateProcess: runtime.terminateProcess,
|
||||
});
|
||||
let acquiredPort: number | null = null;
|
||||
|
||||
try {
|
||||
const acquisition = await manager.acquireDedicated({
|
||||
OPENCODE_AUTH_CONTENT: "{}",
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
OPENCODE_DISABLE_AUTOCOMPACT: "1",
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
||||
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
||||
OPENCODE_PURE: "1",
|
||||
OPENCODE_TEST_HOME: path.join(tempDir, "test-home"),
|
||||
});
|
||||
acquiredPort = acquisition.server.port;
|
||||
|
||||
const records = await managedProcesses.list();
|
||||
// Resolve the actual npm installation. Generation lifecycle coverage above
|
||||
// owns process readiness; this regression owns the Windows launch command.
|
||||
const acquisition = await manager.acquireCurrent();
|
||||
const records = await runtime.managedProcesses.list();
|
||||
expect(records).toHaveLength(1);
|
||||
const record = records[0]!;
|
||||
expect(path.extname(record.command).toLowerCase()).toBe(".exe");
|
||||
@@ -404,15 +397,13 @@ describe.runIf(process.platform === "win32")(
|
||||
path.normalize("node_modules/opencode-ai/bin/opencode.exe").toLowerCase(),
|
||||
);
|
||||
expect(record.command.toLowerCase()).not.toBe(detectedOpenCode!.toLowerCase());
|
||||
expect(record.args).toEqual(["serve", "--port", String(acquiredPort)]);
|
||||
expect(record.args).toEqual(["serve", "--port", String(acquisition.server.port)]);
|
||||
await acquisition.release();
|
||||
} finally {
|
||||
await manager.shutdown().catch(() => undefined);
|
||||
if (acquiredPort !== null) {
|
||||
await waitForClosedPort(acquiredPort, 5_000);
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
await manager.shutdown();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -610,37 +601,3 @@ class FakeManagedProcesses implements ManagedProcessRegistry {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForClosedPort(port: number, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!(await canConnectToPort(port))) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(`OpenCode helper server still accepts connections on port ${port}`);
|
||||
}
|
||||
|
||||
function canConnectToPort(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port });
|
||||
let settled = false;
|
||||
const settle = (connected: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
resolve(connected);
|
||||
};
|
||||
|
||||
socket.once("connect", () => settle(true));
|
||||
socket.once("error", () => settle(false));
|
||||
socket.setTimeout(500, () => settle(false));
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -10,13 +10,21 @@ afterEach(async () => {
|
||||
|
||||
test("the Hub CLI connects, reports status, and disconnects through the daemon", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
const connected = await relationship.beginConnect().result;
|
||||
const connected = await relationship.runCli([
|
||||
"hub",
|
||||
"connect",
|
||||
"https://hub.test",
|
||||
"--api-key",
|
||||
"hub-contract-api-key:ceremony-token",
|
||||
"--permission",
|
||||
"hub.execute",
|
||||
]);
|
||||
relationship.connectLatestSocket();
|
||||
|
||||
const status = await relationship.status();
|
||||
const status = await relationship.runCli(["hub", "status"]);
|
||||
const enrollment = relationship.enrollmentAttempts()[0];
|
||||
const secret = relationship.relationshipFile()?.credential?.secret;
|
||||
const disconnected = await relationship.disconnect();
|
||||
const disconnected = await relationship.runCli(["hub", "disconnect"]);
|
||||
|
||||
expect(connected.state).toBe("connecting");
|
||||
expect(status.state).toBe("connected");
|
||||
|
||||
@@ -297,7 +297,7 @@ describe("Hub relationship", () => {
|
||||
version: 2,
|
||||
relationship: { permissions: ["hub.execute"] },
|
||||
});
|
||||
expect(await relationship.status()).toMatchObject({ permissions: "hub.execute" });
|
||||
expect(await relationship.status()).toMatchObject({ permissions: ["hub.execute"] });
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -419,7 +419,7 @@ describe("Hub relationship", () => {
|
||||
|
||||
expect(disconnected).toMatchObject({
|
||||
state: "not_connected",
|
||||
hub: null,
|
||||
hubOrigin: null,
|
||||
warning: expect.stringContaining("server-side revocation may remain pending"),
|
||||
});
|
||||
expect(reconnected.state).toBe("connecting");
|
||||
@@ -483,14 +483,14 @@ describe("Hub relationship", () => {
|
||||
|
||||
const disconnected = await relationship.disconnect(true);
|
||||
|
||||
expect(disconnected).toMatchObject({ state: "not_connected", hub: null });
|
||||
expect(disconnected).toMatchObject({ state: "not_connected", hubOrigin: null });
|
||||
expect(relationship.revocationAttempts()).toBe(0);
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
|
||||
relationship.completeEnrollment();
|
||||
await connecting.result;
|
||||
expect(relationship.socketAttempts()).toBe(0);
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null });
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null });
|
||||
});
|
||||
|
||||
test("daemon restart reconnects the same durable relationship", async () => {
|
||||
@@ -811,9 +811,9 @@ describe("Hub relationship", () => {
|
||||
expect(status).toMatchObject({
|
||||
state: "revoked",
|
||||
daemonId: enrollment.daemonId,
|
||||
hub: "https://hub.test",
|
||||
permissions: "hub.execute",
|
||||
error: reason,
|
||||
hubOrigin: "https://hub.test",
|
||||
permissions: ["hub.execute"],
|
||||
lastError: reason,
|
||||
});
|
||||
expect(persisted?.state).toBe("revoked");
|
||||
expect(persisted?.relationship).toMatchObject({
|
||||
@@ -848,10 +848,10 @@ describe("Hub relationship", () => {
|
||||
|
||||
expect(disconnected).toMatchObject({
|
||||
state: "not_connected",
|
||||
hub: null,
|
||||
hubOrigin: null,
|
||||
warning: expect.stringContaining("server-side revocation may remain pending"),
|
||||
});
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null });
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null });
|
||||
expect(relationship.revocationAttempts()).toBe(1);
|
||||
expect(relationship.socketAttempts()).toBe(1);
|
||||
expect(relationship.pendingRelationshipRetries()).toBe(0);
|
||||
@@ -864,10 +864,14 @@ describe("Hub relationship", () => {
|
||||
|
||||
const disconnected = await relationship.disconnect();
|
||||
|
||||
expect(disconnected).toMatchObject({ state: "not_connected", hub: null, error: null });
|
||||
expect(disconnected).toMatchObject({
|
||||
state: "not_connected",
|
||||
hubOrigin: null,
|
||||
lastError: null,
|
||||
});
|
||||
expect(disconnected).not.toHaveProperty("warning");
|
||||
expect(relationship.revocationAttempts()).toBe(1);
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected", error: null });
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected", lastError: null });
|
||||
});
|
||||
|
||||
test("force disconnect removes local authority without notifying the Hub", async () => {
|
||||
@@ -876,7 +880,7 @@ describe("Hub relationship", () => {
|
||||
|
||||
const forced = await relationship.disconnect(true);
|
||||
|
||||
expect(forced).toMatchObject({ state: "not_connected", hub: null });
|
||||
expect(forced).toMatchObject({ state: "not_connected", hubOrigin: null });
|
||||
expect(forced).not.toHaveProperty("warning");
|
||||
expect(relationship.revocationAttempts()).toBe(0);
|
||||
expect(relationship.loggableValues(forced)).not.toContain(
|
||||
@@ -895,7 +899,7 @@ describe("Hub relationship", () => {
|
||||
|
||||
await relationship.startStoppedDaemon();
|
||||
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null });
|
||||
expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null });
|
||||
expect(relationship.relationshipFile()).toBeNull();
|
||||
expect(relationship.revocationAttempts()).toBe(0);
|
||||
expect(relationship.pendingRelationshipRetries()).toBe(0);
|
||||
|
||||
@@ -425,7 +425,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
|
||||
}
|
||||
}
|
||||
|
||||
interface CliProcess {
|
||||
interface RelationshipOperation {
|
||||
result: Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
@@ -454,7 +454,7 @@ export class HubRelationshipHarness {
|
||||
private host = "";
|
||||
private readonly logs: string[] = [];
|
||||
private readonly providerPrompts: AgentPromptInput[] = [];
|
||||
private readonly cliProcesses = new Set<Promise<unknown>>();
|
||||
private readonly operations = new Set<Promise<unknown>>();
|
||||
private readonly claimedCliSockets = new Set<WebSocket>();
|
||||
private readonly promptsToFail = new Set<string>();
|
||||
private failNextSessionClose = false;
|
||||
@@ -514,37 +514,44 @@ export class HubRelationshipHarness {
|
||||
this.remote.holdEnrollment();
|
||||
}
|
||||
|
||||
beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN, execute = true): CliProcess {
|
||||
beginConnect(
|
||||
token = "ceremony-token",
|
||||
hubUrl = HUB_ORIGIN,
|
||||
execute = true,
|
||||
): RelationshipOperation {
|
||||
return {
|
||||
result: this.runCli([
|
||||
"hub",
|
||||
"connect",
|
||||
hubUrl,
|
||||
"--api-key",
|
||||
`hub-contract-api-key:${token}`,
|
||||
...(execute ? ["--permission", "hub.execute"] : []),
|
||||
]),
|
||||
result: this.withClient(
|
||||
async (client) =>
|
||||
(await client.connectHub(hubUrl, token, execute ? ["hub.execute"] : [])).status,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async status(): Promise<Record<string, unknown>> {
|
||||
return this.runCli(["hub", "status"]);
|
||||
return this.withClient(async (client) => (await client.getHubStatus()).status);
|
||||
}
|
||||
|
||||
async disconnect(force = false): Promise<Record<string, unknown>> {
|
||||
return this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]);
|
||||
return this.withClient(async (client) => {
|
||||
const { status, warning } = await client.disconnectHub(force);
|
||||
return { ...status, ...(warning ? { warning } : {}) };
|
||||
});
|
||||
}
|
||||
|
||||
async grantPermission(permission: string): Promise<Record<string, unknown>> {
|
||||
return this.runCli(["hub", "permissions", "grant", permission]);
|
||||
return this.withClient(
|
||||
async (client) => (await client.updateHubPermissions({ grant: [permission] })).status,
|
||||
);
|
||||
}
|
||||
|
||||
async revokePermission(permission: string): Promise<Record<string, unknown>> {
|
||||
return this.runCli(["hub", "permissions", "revoke", permission]);
|
||||
return this.withClient(
|
||||
async (client) => (await client.updateHubPermissions({ revoke: [permission] })).status,
|
||||
);
|
||||
}
|
||||
|
||||
beginDisconnect(force = false): CliProcess {
|
||||
return { result: this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]) };
|
||||
beginDisconnect(force = false): RelationshipOperation {
|
||||
return { result: this.disconnect(force) };
|
||||
}
|
||||
|
||||
async relationshipStateBecomes(expected: string | null): Promise<void> {
|
||||
@@ -1372,7 +1379,7 @@ export class HubRelationshipHarness {
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.stopDaemon();
|
||||
await Promise.allSettled(this.cliProcesses);
|
||||
await Promise.allSettled(this.operations);
|
||||
await Promise.all(
|
||||
[...this.claimedCliSockets].map((socket) => this.closeClaimedCliSocket(socket)),
|
||||
);
|
||||
@@ -1433,8 +1440,21 @@ export class HubRelationshipHarness {
|
||||
this.daemon = null;
|
||||
}
|
||||
|
||||
private runCli(args: string[]): Promise<Record<string, unknown>> {
|
||||
return this.trackCli(this.executeCli(args));
|
||||
private withClient<T>(action: (client: DaemonClient) => Promise<T>): Promise<T> {
|
||||
return this.trackOperation(
|
||||
(async () => {
|
||||
const client = await this.trustedClient();
|
||||
try {
|
||||
return await action(client);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
runCli(args: string[]): Promise<Record<string, unknown>> {
|
||||
return this.trackOperation(this.executeCli(args));
|
||||
}
|
||||
|
||||
private async executeCli(args: string[]): Promise<Record<string, unknown>> {
|
||||
@@ -1458,11 +1478,11 @@ export class HubRelationshipHarness {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private trackCli<T>(process: Promise<T>): Promise<T> {
|
||||
this.cliProcesses.add(process);
|
||||
private trackOperation<T>(process: Promise<T>): Promise<T> {
|
||||
this.operations.add(process);
|
||||
void process.then(
|
||||
() => this.cliProcesses.delete(process),
|
||||
() => this.cliProcesses.delete(process),
|
||||
() => this.operations.delete(process),
|
||||
() => this.operations.delete(process),
|
||||
);
|
||||
return process;
|
||||
}
|
||||
@@ -1582,7 +1602,6 @@ export class HubRelationshipHarness {
|
||||
appVersion: "0.1.106",
|
||||
});
|
||||
await client.connect();
|
||||
await client.observeAgents().ready;
|
||||
return client;
|
||||
}
|
||||
|
||||
|
||||
@@ -281,11 +281,24 @@ test("browser and desktop tests have exclusive, directory-owned suites", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("non-required Docker and Nix workflows avoid runners with workflow path filters", () => {
|
||||
test("packaging runs on main without allocating pull-request runners", () => {
|
||||
for (const workflowPath of [dockerWorkflowPath, nixWorkflowPath]) {
|
||||
const source = readFileSync(workflowPath, "utf8");
|
||||
const trigger = source.split("jobs:", 1)[0];
|
||||
assert.match(trigger, /^\s+paths:\s*$/m);
|
||||
assert.match(trigger, /push:\s*\n\s+branches: \[main\]/);
|
||||
assert.doesNotMatch(trigger, /pull_request/);
|
||||
assert.doesNotMatch(source, /dorny\/paths-filter/);
|
||||
}
|
||||
});
|
||||
|
||||
test("desktop packaging smokes main pushes and only the pull requests that touch packaging", () => {
|
||||
const source = readFileSync(new URL(".github/workflows/desktop-packages.yml", repoRoot), "utf8");
|
||||
const trigger = source.split("jobs:", 1)[0];
|
||||
assert.match(trigger, /push:\s*\n\s+branches: \[main\]/);
|
||||
assert.match(trigger, /pull_request:\s*\n\s+branches: \[main\]\s*\n\s+paths:/);
|
||||
assert.match(trigger, /- "packages\/desktop\/\*\*"/);
|
||||
assert.doesNotMatch(source, /dorny\/paths-filter/);
|
||||
for (const action of ["actions/checkout", "actions/setup-node", "actions/upload-artifact"]) {
|
||||
assert.match(source, new RegExp(`${action}@[0-9a-f]{40} # v\\d+\\.\\d+\\.\\d+`));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user