Compare commits

...
24 Commits
Author SHA1 Message Date
Filip Hejmowski e4243dbc8c test(core): fix load-sensitive tests and spawn fewer git processes
- process.test: every AppProcess test spawns a real child, so run them
  with it.live. Under TestClock the spawner's 1s post-exit capture
  deadline never fired, and a late close event hung until the timeout.
- browser-idle: give LocationActivity its own clock and advance it by
  the TTL only after the first browser state attached, instead of
  racing a real 2s TTL against slow plugin activation.
- config/reload: wait for the skills change too before asserting it.
- Raise the four file-level 15s defaults to the runner's 30s default;
  they had become the strictest timeouts in the suite.
- The runner passes --timeout=30000 like cli and tui; npm.test's Git
  installs ran past Bun's 5s default on Windows.
- Git fixtures write repo-local settings in one config write instead of
  a git config spawn per setting. Git for Windows spawns cost about 40ms
  each, and core spawned about 2,850 per run.
2026-10-15 22:11:30 +02:00
Filip Hejmowski d471d2c231 chore(ci): stop gating unit tests on library builds
Every workspace package exports its sources, so no test reads a
library's dist, yet each test task waited for its dependencies' builds
(14 builds before core:test could start on a full run). Test tasks now
depend only on transit, which still folds the whole workspace closure
into their hashes, and builds hash their closure the same way.

The unit job therefore no longer proves the libraries build, so the
build job builds the affected libraries on Linux (tsc and Bun.build,
nothing platform-specific). The Windows Turbo cache in the unit job is
removed: with no builds left there, it could only replay test tasks,
and transit gives every scheduled test a new hash, so it could not hit.
2026-10-15 22:11:30 +02:00
Filip Hejmowski ce3389197a chore(ci): run each component suite in its own e2e leg
The components leg ran the session UI and app component suites back to
back on one runner, which made it the slowest Windows e2e leg (session UI
3.2 min plus app 3.5 min plus setup, about 8.8 min, against 7-8 min for
each app shard). Each suite now gets its own leg.

The app suite no longer inherits the session UI suite's Vite optimizer
cache, so it always starts cold. The Storybook dependency pre-bundling
below this change is what keeps a cold start from reloading pages.
2026-10-15 22:11:30 +02:00
Filip Hejmowski c543d18eb7 fix(storybook): pre-bundle component test dependencies at startup
main.ts removes storybook:optimize-deps-plugin and there are no HTML
files under the Vite root, so Vite's startup scan finds nothing and
discovers every dependency on first import. Each discovery re-runs the
optimizer and broadcasts a full reload to every page in both workers,
which aborts tests mid-import ("Execution context was destroyed",
"Failed to fetch dynamically imported module") and is why the app
component suite is flaky on every CI run.

optimizeDeps.entries now covers the stories, the *.worker.ts files (the
scan does not follow new Worker(new URL(...))) and the modules component
tests load by URL, read from the ./ and ../ literals in the specs so a
new spec needs no config change. Based on the approach in #55632.

On top of that, preview.tsx is an entry and the Storybook preview
runtime is included. Without them the preview's addons and the runtime
are still found late, and one reload remains whenever the first browser
request arrives after the startup optimize has finished, which is the
usual case because the webServer is ready once the manager page loads.

The webServer's stdout is now piped, so any "optimized dependencies
changed. reloading" line shows up in CI logs instead of being dropped.
2026-10-15 22:11:30 +02:00
Filip Hejmowski be1d4b6925 test(storybook): record video on first retry and skip axe in component tests
retain-on-failure records and encodes a video for every component test
only to drop it when the test passes, which shows up in CI as 50-70 ms of
context close per passing test. In CI, record it on the first retry
instead, next to the trace; first-attempt failures still keep their
screenshot and error context. Local runs have no retries, so they keep
retain-on-failure.

addon-a11y also loads axe-core and scans the page after every story
render. preview.tsx only marks results as todo and no component test
reads them, so mount now always sets the a11y.manual global, which makes
the addon skip that run. Callers' globals are merged over it, and
browsing Storybook directly still runs axe.
2026-10-15 22:11:30 +02:00
Filip Hejmowski da9d49a44f test(app): run mobile-timeline-scroll in its own parallel project
mobile-timeline-scroll sets its own launchOptions, so its 37 tests get a
separate worker hash and, without fullyParallel, form one serial group.
In CI that group takes 3 to 5 minutes. Unsharded it is always the last
file and mostly runs alone; on shard 2 it starts first and sets the
length of the whole leg.

A second project now runs only that file, with fullyParallel on.
Playwright queues projects in declaration order, so these tests start
after every chromium group and spread over all workers. Both projects
use one regex, so the file always runs in exactly one project; a rename
that does not update the regex quietly returns it to the serial schedule,
without losing or duplicating tests. The spec and its launchOptions are unchanged, the shard
split stays at 225/224 tests, and the performance configs list the same
tests. `--project chromium`, UI mode and the VS Code extension now skip the file
by default.

The cost is more contention for these touch tests, which now run beside
each other on every worker, so CI retries for this file need watching.
2026-10-15 22:11:30 +02:00
Filip Hejmowski 2e873bc40b test(app): serve the built e2e app with a Bun static server
vite preview checks the case of every requested file on case-insensitive
file systems (Windows, macOS) with a synchronous readdir of its parent
directory. The fixture build has about 3100 files in dist/_assets, so each
of the roughly 150 asset requests per page load blocks the one server
that every worker shares. That matches the CI traces, where Windows asset
waits and first navigations grow with the number of tests running.

e2e/utils/serve.ts serves dist with Bun instead and keeps vite preview's
routing rules: files resolve only inside dist, a malformed escape is a
404, and index.html is the fallback only for GET or HEAD requests whose
Accept is missing, empty, text/html or */*, never for /favicon.ico. A
missing script fetched as JavaScript or an API request asking for JSON
still gets a 404. Bun fails on a busy port like --strictPort did, and
reuseExistingServer and the dev-mode command are unchanged.

The performance configs keep vite preview so their baselines stay
comparable.
2026-10-15 22:11:30 +02:00
Filip Hejmowski 4f0ee179fa test(app): block service workers and record video on first retry in e2e
Most app e2e tests let the production build register its PWA service
worker, which then precaches the whole 35 MB build from the shared preview
server while the test runs. No app e2e spec depends on the worker, and
e2e/service-worker keeps that coverage under its own config, so block it
in the shared config. The 11 per-file blocks are now redundant; they are
left alone to keep this change to one file.

retain-on-failure records and encodes a video for every test only to drop
it when the test passes. In CI, record it on the first retry instead,
next to the trace; first-attempt failures still keep their screenshot and
error context. Local runs have no retries, so they keep retain-on-failure.

The performance, uncapped, timeline-stability and projection configs
spread this config and now block service workers too. CI runs none of
them.
2026-10-15 22:11:30 +02:00
Filip Hejmowski 0cd2e829af fix(app): close mobile drawers dismissed while opening
@corvu/drawer 0.2.4 starts its opening transition two frames after it opens. A dismissal before then slid the drawer open while it was closed. A dismissal in that frame started no transition, or cancelled it, so the transitionend that finishes a close never arrived, leaving focus trapped and body pointer-events disabled. Patch it to skip the late opening step once closed, and to finish a close when no transition is running.

Also stop non-fullscreen story pages from scrolling by 32px inside Storybook's padding. Wait for web fonts before the composer overflow story measures the toolbar. Add composer tests that dismiss the variant drawer before and as it starts opening; both fail without the patch.

The overflow story also waits for focus to return to the add button after Escape before measuring, so the menu focus restore cannot land between select-all and Delete.
2026-10-15 22:11:29 +02:00
Filip Hejmowski 9f5ce4674a fix(app): keep timeline anchoring when a scroll settles
TanStack resets isScrolling on a 150ms timer that reports the offset
captured at the last scroll event. If the virtualizer wrote scrollTop
after that event (prepend anchoring or size compensation) and the
browser had not yet dispatched the write's own scroll event, the timer
put the old offset back. The next size compensation was then computed
from it and undid the write, so the transcript jumped to the top of a
newly loaded page or skipped rows while scrolling up.

When a scroll settles, the reconnect-aware offset observer now reports
the element's live scrollTop. Detached elements keep the event offset.
2026-10-15 22:11:29 +02:00
Filip Hejmowski 2573244805 fix(app): stop video double clicks from also entering native fullscreen
Chromium's media controls answer the second click of a double click with a
native fullscreen request for the <video>. The same double click opens the
in-window theatre. Where fullscreen is granted (Chrome, Playwright), the
fullscreened video covers the theatre backdrop, so a backdrop click no longer
closes it. That is the cause of the flaky review.spec.ts video theatre test.

controlslist="nofullscreen" would also remove Chromium's click-to-play,
which it only offers on videos that can go fullscreen. Instead, the video is
held as an open popover while the controls handle a multi-click, and released
before the next frame renders. The fullscreen element ready check refuses an
open popover, so only the fullscreen request fails. Chromium's play/pause
bookkeeping is unchanged. Desktop already denies the fullscreen permission,
so nothing changes there.

The e2e test now also checks that a single click still starts playback, and
that nothing went native fullscreen by the end of the flow.
2026-10-15 22:11:29 +02:00
Filip Hejmowski dbf5e655df test(app): check the first code layout instead of the code font's status
Chromium recreates every CSS-connected FontFace as "unloaded" whenever a
lazily loaded stylesheet with @layer rules becomes active ahead of a sheet
that is already applied. The route's settings and text-input CSS chunks do
this when they finish out of order, which is common on Windows runners. The
downloaded font file is kept, so the first code that lays out still uses
IBM Plex Mono, but the face's status stays "unloaded" and polling
document.fonts.check() timed out.

Wait for document.fonts.ready, then mount one <code> and check once that
its first layout already has the font loaded. A preload that only downloads
the file still fails, because that first layout reports "loading".
2026-10-15 22:11:29 +02:00
Filip Hejmowski 78558202c0 ci: run the test suites of the 11 packages turbo skipped
turbo.json defines a test task per package and no generic one, so the
test scripts of client, gui-extensions, http-recorder, merman, plugin,
plugin-browser, script, sdk, simulation, theme and util never ran in CI
(119 test files). Register them like the other suites.
2026-10-15 22:11:29 +02:00
Filip Hejmowski 25facb9dfe test: fix the suites CI never ran
These suites had no turbo test task, so they rotted unnoticed.

- client: update tests for intended API changes (session.view idle is a
  DateTime, interrupt's continue became resume, new API groups, the
  Effect service no longer imports protocol), and assert the service
  shutdown per platform since Windows cannot catch SIGTERM.
- http-recorder: run the record-mode WebSocket test with CI unset;
  CI=true forces replay mode and the test waited forever for "open".
- util: reset the retry test's socket after the request arrives instead
  of racing the client's write; set TEMP for the child on Windows and
  compare with the native realpath; exclude colocated tests from the
  build, which compiled them into dist where bun test ran them twice.
- sdk: update two tests for intended behaviour changes, keeping the
  unknown-session error on the HTTP transport.
- merman: hold the established layout families to their original
  ceilings and give decision-loops its own, drop the wall-clock
  assertion, and run the cheap label distance check before the clearance
  check (same output, half the audit CPU).
2026-10-15 22:11:29 +02:00
Filip Hejmowski 4003f64688 fix(server): wait for plugins before answering a web search
websearch.query resolved the provider without waiting for the Location's
plugins to activate, so the first query on a cold Location failed with
websearch_provider_not_found for a provider a plugin was about to
register. It now waits like the model and generate handlers do, bounded
by the same 5s activation wait. websearch.providers stays non-blocking.
2026-10-15 21:37:00 +02:00
Filip Hejmowski 7dbe56078b test(tui): wait for filter focus and highlighting before matching frames
command-selection typed into the variant dialog before its filter had
focus (DialogSelect focuses on a 1ms timer). Markdown text is drawn only
after the tree-sitter worker replies, which can take longer than the 20
frames waitForFrame allows on a busy runner. Wait for focus, and add
waitForHighlightedFrame to wait for pending highlights inside the
predicate.
2026-10-15 20:25:32 +02:00
Filip Hejmowski bc65cbe961 fix(ui): stop closed dialogs from blocking shortcuts
The dialog stack keeps a closing dialog for 100ms before disposing it,
and useDialog().active kept returning it, so the global shortcut handler
dropped every shortcut in that window. Reopening the command palette
right after closing it did nothing. active now returns the top dialog
that has not started closing, the same rule the focus trap already uses.
2026-10-15 20:25:31 +02:00
Filip Hejmowski 56782638cb test(app): fix four racy e2e tests
- whats-new: What's New opens as a modal 500ms after the changelog
  response and hides #root, so on Windows start() often looked for the
  Settings button after it was hidden. The dialog tests hold the
  changelog route until Home has been checked.
- subagent-child-navigation: the parent title was measured while the
  loading header could still be swapped for the timeline header, so it
  sometimes measured a detached heading (left 0). Wait for the timeline
  row first.
- session-summary: the tooltip was hovered during the timeline's first
  mount, whose final scroll closes it. Wait for the rows to be revealed.
- tabs: page.clock.pauseAt(new Date()) raced the still-running page
  clock ("Cannot fast-forward to the past"). Install at an explicit time
  and pause at a fixed offset from it.
2026-10-15 20:25:29 +02:00
Filip Hejmowski 9fe5766435 chore(ci): install with the isolated linker on Windows
Windows CI installed with --linker hoisted to work around
oven-sh/bun#28147, where an isolated install re-applied a patched
dependency per peer variant and failed with EPERM while hardlinking. That
issue was fixed in July, and a full test run on Windows with the isolated
linker installs and passes. The install step drops from about 95s to
about 36s on each Windows job.
2026-10-15 15:12:45 +02:00
Filip Hejmowski 77b8f1dfc7 fix(ci): hash every test task's workspace closure and cache Turbo on Windows
A test task's hash only covered its direct dependencies' build tasks. tui
and script have no build task, so core, codemode, plugin-browser and ui
never reached cli#test's hash, and Linux replayed cached cli test passes
from the runner's remote cache for commits that changed core. Every test
task now also depends on a transit task, which runs nothing but folds the
whole workspace closure into its hash. The root package.json is a global
dependency, so a Bun upgrade through packageManager invalidates results.

Windows unit jobs had no Turbo cache at all. Restore one keyed by week on
Windows, save it from pushes to dev, and skip it on manual runs.
2026-10-15 14:57:16 +02:00
Filip Hejmowski 3a0a231d84 chore(ci): skip the bun dependency cache on Windows
On the Windows runners, restoring the ~720 MB bun cache spends about 37s
extracting it with tar onto NTFS, and the warm install still takes about
64s. A cold install without the cache is faster than restore plus warm
install in every paired run we sampled, so Windows skips the restore and
the save. Linux keeps the cache.
2026-10-15 13:18:32 +02:00
Filip Hejmowski 5c4079a926 chore(ci): shard app e2e tests and run component tests in their own leg
Split each OS's e2e job into three legs: two Playwright shards of the app
e2e suite and one leg for the session UI and app component tests. The
service worker suite reads the production build, so it runs on shard 2
after the e2e step that builds it.

Keep "e2e (linux)" and "e2e (windows)" as the names of small aggregator
jobs, since the branch rules require them. They pass only when every leg
passed, or when e2e was not needed, and fail if the affected job failed.
2026-10-15 13:17:40 +02:00
Filip Hejmowski e004ec3f57 chore(ci): run build smoke tests in parallel with unit tests
Move the compiled-service and Node build smoke steps out of the unit job
into a parallel build job. They took about 2.5 minutes after the tests on
each OS and do not depend on anything the tests produce.

Remove the Turbo actions/cache steps. They cached node_modules/.cache/turbo,
which Turbo 2 never writes (its local cache is .turbo/cache), so they never
restored or saved anything. On Windows, hashing the cache key still cost
about 30 seconds before and after the tests.

Check out two commits on pull requests instead of the full history. The
affected-package diff only needs the merge commit and its first parent.
2026-10-15 13:16:38 +02:00
Filip Hejmowski c3ceabf01c fix(storybook): resolve solid-query under the hoisted linker
The Storybook Vite config aliased @tanstack/solid-query to
packages/app/node_modules/@tanstack/solid-query. That path only exists with
bun's isolated linker. Windows CI installs with --linker hoisted, so Vite
returned 404 for every module importing solid-query and the app component
tests timed out on Windows.

Dedupe the package instead; Storybook declares it, so it resolves to one copy
under both linkers and both runtimes.

Also run the e2e job for Storybook-only changes. No app package depends on
Storybook, so those PRs skipped every component test and reported green.
2026-10-15 13:16:03 +02:00
61 changed files with 978 additions and 399 deletions

No files matched your search

+5 -10
View File
@@ -49,8 +49,11 @@ runs:
echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
echo "version=$(bun --version)" >> "$GITHUB_OUTPUT"
# On Windows, extracting the cache archive takes longer than Bun downloading and
# extracting the same packages itself, so Windows installs skip the cache.
- name: Restore Bun dependencies
id: bun-cache
if: runner.os != 'Windows'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
@@ -61,19 +64,11 @@ runs:
shell: bash
- name: Install dependencies
run: |
# Workaround for patched peer variants
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }}
else
bun install --frozen-lockfile ${{ inputs.install-flags }}
fi
run: bun install --frozen-lockfile ${{ inputs.install-flags }}
shell: bash
- name: Save Bun dependencies
if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
if: runner.os != 'Windows' && steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
+119 -58
View File
@@ -25,13 +25,14 @@ jobs:
name: affected packages
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
e2e: ${{ steps.packages.outputs.app == 'true' && github.ref_name != 'dev' && github.head_ref != 'dev' }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
# Pull requests diff against the merge's first parent; pushes diff against the previous dev head.
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
@@ -48,7 +49,8 @@ jobs:
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode/app --output=json > affected.json
# Storybook hosts the component tests, but no app package depends on it.
bun x turbo@2.10.2 ls --affected --filter=@opencode/app --filter=@opencode/storybook --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
@@ -70,7 +72,8 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
# Pull requests diff against the merge's first parent; pushes diff against the previous dev head.
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -93,15 +96,6 @@ jobs:
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests
timeout-minutes: 20
run: |
@@ -134,32 +128,6 @@ jobs:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: |
bun run script/build.ts --single --skip-install
bun run script/service-smoke.ts
- name: Setup Node build runtime
if: always()
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Verify Node build
if: always()
timeout-minutes: 15
working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: |
bun run script/build-node.ts --single --skip-install --outdir=dist/node
bun run script/service-smoke.ts --node
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
@@ -170,9 +138,8 @@ jobs:
working-directory: services/www
run: bun run check:generated
e2e:
name: e2e (${{ matrix.settings.name }})
needs: affected
build:
name: build (${{ matrix.settings.name }})
strategy:
fail-fast: false
matrix:
@@ -182,39 +149,112 @@ jobs:
- name: windows
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Pull requests diff against the merge's first parent; pushes diff against the previous dev head.
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Verify compiled service lifecycle
timeout-minutes: 10
working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: |
bun run script/build.ts --single --skip-install
bun run script/service-smoke.ts
# Unit tests import workspace sources and never build the libraries they use, so this checks those builds. The
# CLI and app closures hold every library a tested package depends on. The builds are tsc and Bun.build with no
# platform-specific steps, and the libraries publish from Linux, so Windows skips them.
- name: Build workspace libraries
if: ${{ !cancelled() && runner.os == 'Linux' }}
timeout-minutes: 10
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
bun turbo build --concurrency=3 --filter='@opencode/cli^...' --filter='@opencode/app^...'
exit 0
fi
bun turbo build --affected --concurrency=3 --filter='@opencode/cli^...' --filter='@opencode/app^...'
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Setup Node build runtime
if: ${{ !cancelled() }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Verify Node build
if: ${{ !cancelled() }}
timeout-minutes: 15
working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: |
bun run script/build-node.ts --single --skip-install --outdir=dist/node
bun run script/service-smoke.ts --node
e2e-suite:
name: e2e (${{ matrix.settings.name }}, ${{ matrix.suite.id }})
needs: affected
if: needs.affected.outputs.e2e == 'true'
strategy:
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
- name: windows
host: blacksmith-4vcpu-windows-2025
# Playwright splits whole spec files across shards by test count, not duration. Every app-N
# entry must use the same total, and the entries must cover 1..total. The service worker suite
# reuses shard 2's production build. Each component suite runs once per OS in its own leg, so the
# two suites' Storybook servers and test time are not added up on one runner.
suite:
- id: app-1
shard: 1/2
- id: app-2
shard: 2/2
- id: session-ui-components
- id: app-components
runs-on: ${{ matrix.settings.host }}
env:
E2E_ENABLED: ${{ needs.affected.outputs.app == 'true' && github.ref_name != 'dev' && github.head_ref != 'dev' }}
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
steps:
- name: Checkout repository
if: env.E2E_ENABLED == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
if: env.E2E_ENABLED == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun
if: env.E2E_ENABLED == 'true'
uses: ./.github/actions/setup-bun
- name: Read Playwright version
if: env.E2E_ENABLED == 'true'
id: playwright-version
run: |
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
if: env.E2E_ENABLED == 'true'
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -222,47 +262,47 @@ jobs:
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: env.E2E_ENABLED == 'true' && runner.os == 'Linux'
if: runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: env.E2E_ENABLED == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests against production build
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:built
if: matrix.suite.shard
run: bun --cwd packages/app test:e2e:built --shard=${{ matrix.suite.shard }}
env:
CI: true
timeout-minutes: 30
timeout-minutes: 15
- name: Verify service worker precaching and upgrades
if: env.E2E_ENABLED == 'true'
if: matrix.suite.shard == '2/2'
working-directory: packages/app
run: bunx playwright test --config e2e/service-worker/playwright.config.ts
timeout-minutes: 5
- name: Run session UI component tests
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
if: matrix.suite.id == 'session-ui-components'
run: bun --cwd packages/session-ui test:components
env:
CI: true
timeout-minutes: 15
- name: Run app component tests
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
if: matrix.suite.id == 'app-components'
run: bun --cwd packages/app test:components
env:
CI: true
timeout-minutes: 15
- name: Upload Playwright artifacts
if: always() && env.E2E_ENABLED == 'true'
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
name: playwright-${{ matrix.settings.name }}-${{ matrix.suite.id }}-${{ github.run_attempt }}
if-no-files-found: ignore
retention-days: 7
path: |
@@ -272,3 +312,24 @@ jobs:
packages/session-ui/component-tests/playwright-report
packages/app/component-tests/test-results
packages/app/component-tests/playwright-report
# Branch rules require one e2e check per OS. These keep those names and pass only when every
# leg above passed, or when no leg was needed; a failing leg on either OS fails both checks.
e2e:
name: e2e (${{ matrix.os }})
needs: [affected, e2e-suite]
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
os: [linux, windows]
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Check browser test results
env:
AFFECTED: ${{ needs.affected.result }}
SUITES: ${{ needs.e2e-suite.result }}
EXPECTED: ${{ needs.affected.outputs.e2e == 'true' && 'success' || 'skipped' }}
run: |
echo "affected packages: $AFFECTED, browser test legs: $SUITES (expected $EXPECTED)"
test "$AFFECTED" = success && test "$SUITES" = "$EXPECTED"
+1
View File
@@ -1083,6 +1083,7 @@
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"@corvu/drawer@0.2.4": "patches/@corvu%2Fdrawer@0.2.4.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
},
+2 -1
View File
@@ -184,6 +184,7 @@
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch",
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch"
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch",
"@corvu/drawer@0.2.4": "patches/@corvu%2Fdrawer@0.2.4.patch"
}
}
+3 -3
View File
@@ -39,7 +39,7 @@ bun run test:e2e:local
bun run test:e2e:local -- --grep "settings"
```
CI builds the app once and runs the same suite against Vite preview, serving production assets from `dist`. Managed built runs never reuse an existing server, so a running dev server cannot silently replace the production build. To run this mode locally:
CI builds the app once and runs the same suite against a static Bun server (`e2e/utils/serve.ts`) that serves the production assets in `dist` with Vite preview's SPA fallback rules. Managed built runs never reuse an existing server, so a running dev server cannot silently replace the production build. To run this mode locally:
```bash
bun run test:e2e:built
@@ -64,9 +64,9 @@ Compiled CLI startup and service lifecycle coverage runs separately in CI via `p
Environment options:
- `PLAYWRIGHT_BUILD=1` (build and preview locally; always enabled when `CI` is set)
- `PLAYWRIGHT_BUILD=1` (build and serve `dist` locally; always enabled when `CI` is set)
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (dev fixture API address, default: `127.0.0.1:4096`; built runs use the app's origin, matching production)
- `PLAYWRIGHT_PORT` (managed dev or preview server port, default: `3000`)
- `PLAYWRIGHT_PORT` (managed dev or static server port, default: `3000`)
- `PLAYWRIGHT_BASE_URL` (use an externally managed app instead of starting a server; otherwise defaults to `http://127.0.0.1:<PLAYWRIGHT_PORT>`)
## Deployment
@@ -22,6 +22,8 @@ for (const direction of ["ltr", "rtl"]) {
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(
alternate === "none" ? 0 : 1,
)
// A late Inter swap resizes the delivery keybind and shifts the toolbar; measure after fonts settle.
await page.evaluate(() => document.fonts.ready.then(() => undefined))
for (const width of [1024, 360]) {
await page.setViewportSize({ width, height: 720 })
@@ -110,6 +112,7 @@ for (const direction of ["ltr", "rtl"]) {
await add.click()
await expect(page.getByRole("menuitem", { name: "Images and files" })).toBeVisible()
await page.keyboard.press("Escape")
await expect(add).toBeFocused()
expect(await add.boundingBox()).toEqual(fixedAdd)
}
@@ -368,3 +371,59 @@ story("keeps default thinking accessible by keyboard without composer hover", as
await expect(control).not.toBeFocused()
await expect(control).toHaveCSS("opacity", "0")
})
story.describe("mobile variant drawer", () => {
story.use({ viewport: { width: 360, height: 720 } })
// Guards patches/@corvu%2Fdrawer@0.2.4.patch. The drawer starts its opening transition two frames after it opens.
// Dismissing it before then used to slide it open anyway, and dismissing it in that frame left it closing off screen
// for good, holding focus and disabling pointer input on the page. Playwright input cannot target those frames, so
// the page dismisses it.
for (const moment of ["before", "as"] as const) {
story(`closes and restores focus when dismissed ${moment} it starts opening`, async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const variant = component.getByRole("button", { name: "Choose model variant" })
const drawer = page.getByRole("dialog", { name: "Choose model variant", exact: true })
const high = drawer.getByRole("button", { name: "high", exact: true })
// Load the lazy drawer first so the dismissal below races only the drawer's own transition.
await variant.click()
await expect(high).toBeInViewport()
const content = await drawer.elementHandle()
if (!content) throw new Error("Missing variant drawer")
await page.keyboard.press("Escape")
await expect(drawer).toBeHidden()
await expect(variant).toBeFocused()
await variant.evaluate(
(trigger: HTMLElement, race) =>
new Promise<void>((resolve) => {
const dismiss = () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))
resolve()
}
// Mutation records arrive before the browser renders the opening transition's first frame.
const observer = new MutationObserver(() => {
if (!race.content.hasAttribute("data-opening")) return
observer.disconnect()
dismiss()
})
if (race.moment === "as") observer.observe(race.content, { attributeFilter: ["data-opening"] })
trigger.click()
if (race.moment === "before") dismiss()
}),
{ content, moment },
)
await expect(drawer).toBeHidden()
await expect(variant).toBeFocused()
await expect(page.locator("body")).not.toHaveCSS("pointer-events", "none")
await variant.click()
await expect(high).toBeInViewport()
})
}
})
@@ -48,6 +48,15 @@ test("lists recent sessions before a query is entered", async ({ page }) => {
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
})
test("reopens from its shortcut as soon as it closes", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
await input.press("Escape")
await expect(dialog).toHaveCount(0)
// A closed dialog stops blocking shortcuts when it leaves the screen, not when it is disposed afterwards.
await page.keyboard.press("ControlOrMeta+Shift+P")
await expect(dialog.getByRole("textbox")).toBeFocused()
})
test("appends search results without resetting the selected command", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const files = Promise.withResolvers<void>()
@@ -890,6 +890,12 @@ test("video files over 25 MB play in the viewer, expand into the theatre overlay
await page.mouse.click(8, 8)
await expect(fullscreen).toHaveAttribute("aria-pressed", "false")
expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained")
// Chromium's controls also answer a double click with native fullscreen, which covers the backdrop. Opting out with
// controlslist="nofullscreen" would end their click-to-play as well.
await video.click()
await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.currentTime)).toBeGreaterThan(0)
expect(await page.evaluate(() => document.fullscreenElement)).toBeNull()
})
test("restores review state and the side-panel tab per session", async ({ page }) => {
@@ -18,6 +18,11 @@ for (const custom of [false, true]) {
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(trigger).toBeEnabled()
// The trigger scrolls with the timeline under its sticky header, and Kobalte closes a tooltip whenever its
// trigger scrolls. A cold timeline keeps scrolling to its bottom until it reveals its rows, so hover after that.
await expect(
page.locator(`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.sourceMessageIDs.at(-1)}"]`),
).toBeVisible()
await trigger.hover()
const tooltip = page.getByRole("tooltip")
await expect(tooltip).toContainText("Summary")
@@ -57,9 +57,13 @@ test("keeps the parent title anchored when opening a subagent", async ({ page })
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
await expectSessionTitle(page, parentTitle)
// The title first renders in the loading header, which the timeline replaces once messages arrive.
// Measuring before then can read the detached heading, whose empty bounds put the start at 0.
const agents = page.getByRole("button", { name: "Used 1 Agent", exact: true })
await expect(agents).toBeVisible()
const start = await titleInlineStart(page.locator("[data-session-title]").getByRole("heading", { name: parentTitle }))
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await agents.click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await expectSessionTitle(page, taskDescription)
+6 -2
View File
@@ -437,8 +437,12 @@ test("tab preview opens cold, switches immediately between tabs, and closes outs
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
const preview = page.locator('[data-component="session-tab-popover"]')
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title, d.title])
await page.clock.install()
await page.clock.pauseAt(new Date())
// Until it is paused the installed clock follows real time, and it starts slightly ahead of the runner's wall clock,
// so `pauseAt(new Date())` can name an instant the page has already passed. Pause at a fixed offset from an explicit
// install time instead; the offset only has to outlast the real time between the two calls.
const start = Date.now()
await page.clock.install({ time: start })
await page.clock.pauseAt(start + 10_000)
await tabB.hover()
await page.clock.runFor(700)
await expect(preview).toHaveCount(0)
@@ -30,7 +30,7 @@ for (const width of [1440, 390]) {
const spacer = content.querySelector('[data-timeline-row="bottom-spacer"]')
;(window as Window & { __coldReveal?: Reveal }).__coldReveal = {
window.__coldReveal = {
pending: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
clipped: [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")].flatMap((row) =>
(row.firstElementChild?.getBoundingClientRect().height ?? 0) > row.getBoundingClientRect().height + 1
@@ -55,7 +55,7 @@ for (const width of [1440, 390]) {
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "hidden")
release.resolve()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
const reveal = await page.evaluate(() => (window as Window & { __coldReveal?: Reveal }).__coldReveal)
const reveal = await page.evaluate(() => window.__coldReveal)
expect(reveal).toMatchObject({ pending: 0, clipped: [], tables: 1, codeBlocks: 4 })
expect(Math.abs(reveal?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
// The gap above the composer is part of the tail.
@@ -274,7 +274,23 @@ test("loads the transcript code font before opening rich history", async ({ page
await page.goto(sessionHref(fixture.sourceID))
await expect(page.getByText("A transcript with no code", { exact: true })).toBeVisible()
expect((await font).ok()).toBe(true)
await expect.poll(() => page.evaluate(() => document.fonts.check('440 13px "IBM Plex Mono"'))).toBe(true)
// Chromium recreates every @font-face as "unloaded", keeping its downloaded file, whenever a lazily loaded
// stylesheet with @layer rules activates ahead of one already applied, so the preloaded face's status alone is
// not stable. Once fonts settle, the first layout of new code must take that file at once, which marks the
// current face loaded; a face that still has to fetch its file reports "loading" here instead.
expect(
await page.evaluate(async () => {
await document.fonts.ready
const code = document.body.appendChild(document.createElement("code"))
code.style.font = "440 13px var(--font-family-mono)"
code.textContent = "code"
code.getBoundingClientRect()
const loaded = document.fonts.check('440 13px "IBM Plex Mono"')
code.remove()
return loaded
}),
).toBe(true)
})
test("waits for the requested session's history before constructing its cold timeline", async ({ page }) => {
@@ -464,6 +480,13 @@ async function expectAtTail(page: Page, lastUserID: string) {
type TabPaint = { first?: { last: boolean; bottomError?: number }; settled: boolean; removed: number; stop(): void }
declare global {
interface Window {
__coldReveal?: Reveal
__tabPaint?: TabPaint
}
}
// Samples painted frames of the timeline showing `sessionID`: the first frame that shows any of its messages, and
// every node of the first settled frame (at the tail, Markdown rendered) that is later removed.
async function sampleTabPaint(page: Page, sessionID: string) {
@@ -482,7 +505,7 @@ async function sampleTabPaint(page: Page, sessionID: string) {
},
}
;(window as Window & { __tabPaint?: TabPaint }).__tabPaint = state
window.__tabPaint = state
new MutationObserver((records) => {
if (!state.settled || !running) return
records.forEach((record) =>
@@ -520,10 +543,11 @@ async function sampleTabPaint(page: Page, sessionID: string) {
)
}
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inView)
.map((element) => element.dataset.messageId!)
.filter((id) => destination.has(id))
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].flatMap((element) => {
const id = element.dataset.messageId
return id && inView(element) && destination.has(id) ? [id] : []
})
const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
const bottomError = spacer ? spacer.bottom - view.bottom : undefined
@@ -556,20 +580,20 @@ async function sampleTabPaint(page: Page, sessionID: string) {
last: history.findLast((message) => message.type === "user")!.id,
},
)
const read = () => page.evaluate(() => (window as Window & { __tabPaint?: TabPaint }).__tabPaint!.first)
const read = () => page.evaluate(() => window.__tabPaint!.first)
return {
async firstPaint() {
await expect.poll(read).toBeDefined()
await expect
.poll(() => page.evaluate(() => (window as Window & { __tabPaint?: TabPaint }).__tabPaint!.settled))
.poll(() => page.evaluate(() => window.__tabPaint!.settled))
.toBe(true)
return (await read())!
},
stop: () =>
page.evaluate(() => {
const state = (window as Window & { __tabPaint?: TabPaint }).__tabPaint!
const state = window.__tabPaint!
state.stop()
return state.removed
+15 -4
View File
@@ -68,8 +68,7 @@ const upgraded = { storage: { "highlights.v1": { version: "1.9.1" } } }
test("What's New shows the highlights since the version seen, pages to Get started, and is seen once shown", async ({
page,
}) => {
const requests = await serveChangelog(page)
await start(page, upgraded)
const requests = await startUpgraded(page)
const dialog = page.getByRole("dialog")
const title = dialog.getByRole("heading", { level: 1 })
const next = dialog.getByRole("button", { name: "Next", exact: true })
@@ -102,8 +101,7 @@ test("What's New shows the highlights since the version seen, pages to Get start
})
test("Don't show these in the future turns release notes off and closes What's New", async ({ page }) => {
await serveChangelog(page)
await start(page, upgraded)
await startUpgraded(page)
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("heading", { level: 1 })).toHaveText("Split panes")
await dialog.getByRole("button", { name: "Don't show these in the future", exact: true }).click()
@@ -222,6 +220,19 @@ async function start(page: Page, seed: SeedInput) {
await expectAppVisible(page.getByRole("button", { name: "Settings", exact: true }))
}
/**
* Starts as a user who last saw 1.9.1 and serves the changelog only once Home renders. What's New is a modal that hides
* Home from assistive technology, so opening it first would hide the Settings button `start` waits for.
*/
async function startUpgraded(page: Page) {
const held = Promise.withResolvers<void>()
const requests = await serveChangelog(page, changelog, held.promise)
await start(page, upgraded)
held.resolve()
return requests
}
function stored(page: Page, key: string) {
return page.evaluate((name) => localStorage.getItem(name), key).then((raw) => JSON.parse(raw ?? "null"))
}
+65
View File
@@ -0,0 +1,65 @@
import { stat } from "node:fs/promises"
import path from "node:path"
import { parseArgs } from "node:util"
// Serves the e2e build with vite preview's routing rules. vite preview checks the case of every requested
// path with a synchronous directory read on case-insensitive file systems (Windows, macOS), and the
// 3000-file _assets directory makes that the bottleneck for every page load across all workers.
const options = parseArgs({ options: { host: { type: "string" }, port: { type: "string" } } }).values
const dist = path.resolve(import.meta.dirname, "../../dist")
// Sent as text like vite's index.html middleware, so a media Range request that falls back gets a plain 200.
const index = await Bun.file(path.join(dist, "index.html")).text()
Bun.serve({
hostname: options.host,
port: Number(options.port),
async fetch(request) {
const pathname = decode(new URL(request.url).pathname)
if (pathname === undefined) return new Response(null, { status: 404 })
const target = path.resolve(dist, `.${pathname}`)
// Like vite's existsSync, a name the file system rejects (too long, NUL) is treated as missing.
const stats = target.startsWith(dist + path.sep) ? await stat(target).catch(() => undefined) : undefined
if (stats?.isFile()) {
const file = Bun.file(target)
return new Response(file, {
// Bun names .aac audio/x-aac, and only keeps the type on HEAD responses when it is set explicitly.
headers: { "content-type": path.extname(target) === ".aac" ? "audio/aac" : file.type },
})
}
if (fallback(request, pathname))
return new Response(index, { headers: { "content-type": "text/html;charset=utf-8" } })
return new Response(null, { status: 404 })
},
})
function decode(pathname: string) {
try {
return decodeURIComponent(pathname)
} catch {
return undefined
}
}
// Mirrors vite's htmlFallbackMiddleware: only page loads get index.html, so a missing script or a fetch
// asking for JSON still sees a 404.
function fallback(request: Request, pathname: string) {
if (request.method !== "GET" && request.method !== "HEAD") return false
if (pathname === "/favicon.ico") return false
// vite's index.html middleware never answers a script load with the page, so a missing chunk stays a 404.
if (request.headers.get("sec-fetch-dest") === "script") return false
const accept = request.headers.get("accept")
return !accept || accept.includes("text/html") || accept.includes("*/*")
}
+20 -3
View File
@@ -10,7 +10,7 @@ if (url.protocol !== "http:") throw new Error("E2E fixtures require an http:// a
const built = !!process.env.CI || process.env.PLAYWRIGHT_BUILD === "1"
// Production connects to its own origin, so fixture URLs must match the preview server.
// Production connects to its own origin, so fixture URLs must match the static server.
if (built) {
process.env.PLAYWRIGHT_SERVER_HOST = url.hostname
process.env.PLAYWRIGHT_SERVER_PORT = url.port || "80"
@@ -21,11 +21,13 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = built
? `bun run build && bun run serve -- --host 127.0.0.1 --port ${port} --strictPort`
? `bun run build && bun e2e/utils/serve.ts --host 127.0.0.1 --port ${port}`
: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const touchScroll = /mobile-timeline-scroll\.spec\.ts/
export default defineConfig({
testDir: "./e2e",
testIgnore: [
@@ -59,12 +61,27 @@ export default defineConfig({
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
// CI retries failures, so the retry records the video; local runs have no retries.
video: process.env.CI ? "on-first-retry" : "retain-on-failure",
// The PWA worker precaches the whole build in every test; e2e/service-worker covers it.
serviceWorkers: "block",
},
// The touch-scroll project exists only for scheduling: in the chromium project, mobile-timeline-scroll
// is one long serial group that ends up running alone on a single worker. Projects queue in
// declaration order, so this one runs the file's tests after every chromium group, spread across all
// workers. `--project chromium`, UI mode and the VS Code extension's default first-project selection
// therefore skip the file.
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
grepInvert: touchScroll,
},
{
name: "chromium-touch-scroll",
use: { ...devices["Desktop Chrome"] },
grep: touchScroll,
fullyParallel: true,
},
],
})
@@ -1,19 +1,13 @@
import { expect, test } from "bun:test"
import { type Virtualizer } from "@tanstack/solid-virtual"
import { Node, Window } from "happy-dom"
import { Virtualizer } from "@tanstack/solid-virtual"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
test("restores a view observed before its first attachment", async () => {
const targetWindow = new Window()
const frame = openFrame()
const targetWindow = frame.targetWindow
const mutations = controlledMutations(targetWindow)
const viewport = targetWindow.document.createElement("div")
const instance = {
scrollElement: viewport,
targetWindow,
scrollOffset: 240,
options: { horizontal: false, isRtl: false, isScrollingResetDelay: 0, useScrollendEvent: false },
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const instance = attachedVirtualizer(viewport, { scrollOffset: 240, isScrollingResetDelay: 0 })
const connections: boolean[] = []
@@ -32,12 +26,13 @@ test("restores a view observed before its first attachment", async () => {
expect(instance.scrollOffset).toBe(0)
} finally {
cleanup()
await targetWindow.happyDOM.close()
frame.close()
}
})
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const targetWindow = new Window()
const frame = openFrame()
const targetWindow = frame.targetWindow
const mutations = controlledMutations(targetWindow)
const route = targetWindow.document.createElement("section")
const viewport = targetWindow.document.createElement("div")
@@ -45,17 +40,7 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
route.append(viewport)
targetWindow.document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow,
scrollOffset: 79_400,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 0,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const instance = attachedVirtualizer(viewport, { scrollOffset: 79_400, isScrollingResetDelay: 0 })
const calls: [number, boolean][] = []
@@ -80,12 +65,13 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
expect(calls).toEqual([[0, false]])
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
frame.close()
}
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
const targetWindow = new Window()
const frame = openFrame()
const targetWindow = frame.targetWindow
const mutations = controlledMutations(targetWindow)
const animation = controlledAnimationFrames(targetWindow)
const route = targetWindow.document.createElement("section")
@@ -93,17 +79,7 @@ test("keeps checking until stale reset-delay callbacks can no longer win", async
route.append(viewport)
targetWindow.document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow,
scrollOffset: 79_400,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 20,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const instance = attachedVirtualizer(viewport, { scrollOffset: 79_400, isScrollingResetDelay: 20 })
const calls: number[] = []
@@ -127,7 +103,39 @@ test("keeps checking until stale reset-delay callbacks can no longer win", async
expect(animation.pending()).toBe(0)
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
frame.close()
}
})
test("settles on the live offset after a write whose scroll event is still pending", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
document.body.append(route)
const instance = attachedVirtualizer(viewport, { scrollOffset: 0, isScrollingResetDelay: 10 })
const calls: [number, boolean][] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
calls.push([offset, isScrolling])
instance.scrollOffset = offset
})
try {
viewport.scrollTop = 100
viewport.dispatchEvent(new Event("scroll"))
// A prepend anchor write lands before the reset delay; the browser has not dispatched its scroll event.
viewport.scrollTop = 2_500
await new Promise((resolve) => setTimeout(resolve, 25))
expect(calls).toEqual([
[100, true],
[2_500, false],
])
} finally {
cleanup?.()
route.remove()
}
})
@@ -137,17 +145,7 @@ test("cleanup suppresses queued delegated callbacks, reconnect checks, and later
route.append(viewport)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 0,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 10,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const instance = attachedVirtualizer(viewport, { scrollOffset: 0, isScrollingResetDelay: 10 })
const calls: [number, boolean][] = []
@@ -170,49 +168,71 @@ test("cleanup suppresses queued delegated callbacks, reconnect checks, and later
route.remove()
})
type FrameWindow = {
requestAnimationFrame(callback: () => void): unknown
performance: { now(): number }
// A real virtualizer attached to the viewport the way its mount does (scroll element plus the window that owns it),
// without starting its own rect and offset observers, so each test drives the observer under test directly.
function attachedVirtualizer(viewport: HTMLDivElement, state: { scrollOffset: number; isScrollingResetDelay: number }) {
const instance = new Virtualizer<HTMLDivElement, HTMLDivElement>({
count: 0,
getScrollElement: () => viewport,
estimateSize: () => 0,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
isScrollingResetDelay: state.isScrollingResetDelay,
})
instance.scrollElement = viewport
instance.targetWindow = viewport.ownerDocument.defaultView
instance.scrollOffset = state.scrollOffset
return instance
}
async function frames(count: number, targetWindow: FrameWindow = window) {
// A separate window whose MutationObserver and animation frames a test can replace without touching the global
// window. happy-dom loads a srcdoc frame synchronously, so its window is ready once the frame is attached.
function openFrame() {
const frame = document.createElement("iframe")
frame.srcdoc = ""
document.body.append(frame)
const targetWindow = frame.contentDocument?.defaultView
if (!targetWindow) throw new Error("Frame window did not load")
return { targetWindow, close: () => frame.remove() }
}
async function frames(count: number, targetWindow: Window = window) {
for (let index = 0; index < count; index++) {
await new Promise<void>((resolve) => targetWindow.requestAnimationFrame(() => resolve()))
}
}
function controlledMutations(targetWindow: Window) {
let emit: (record: MutationRecord) => void = () => {
function controlledMutations(targetWindow: Window & typeof globalThis) {
let flush: () => void = () => {
throw new Error("Mutation observer is not active")
}
class ControlledMutationObserver {
// Delivers each operation's records as soon as it happens instead of at the next microtask.
class ControlledMutationObserver extends targetWindow.MutationObserver {
constructor(callback: MutationCallback) {
emit = (record) => callback([record], this as unknown as MutationObserver)
}
observe() {}
disconnect() {}
takeRecords() {
return []
super(callback)
flush = () => callback(this.takeRecords(), this)
}
}
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
return {
append(parent: Node, node: Node) {
parent.appendChild(node)
emit(record(parent, [node], []))
flush()
},
remove(node: Node) {
const parent = node.parentNode
if (!parent) throw new Error("Mutation target has no parent")
parent.removeChild(node)
emit(record(parent, [], [node]))
flush()
},
}
}
@@ -6,14 +6,18 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
onReconnect?: () => void,
) {
let active = true
const element = instance.scrollElement
// TanStack resets isScrolling on a timer that reports the offset of the last scroll event. A virtualizer
// write since then (prepend anchoring, size compensation) may not have dispatched its scroll event yet, and
// compensating from that stale offset would undo the write, so a settled scroll reads the live scrollTop
// (vertical only, like the reconnect check below).
const deliver = (offset: number, isScrolling: boolean) => {
if (!active) return
callback(offset, isScrolling)
callback(isScrolling || !element?.isConnected ? offset : element.scrollTop, isScrolling)
}
const cleanupOffset = observeElementOffset(instance, deliver)
const element = instance.scrollElement
const targetWindow = instance.targetWindow
const root = element?.closest("main") ?? element?.ownerDocument.body
@@ -27,7 +27,7 @@ test.each([
expect(config.webServer.url).toBe(config.use.baseURL)
expect(config.webServer.command).toBe(
built
? "bun run build && bun run serve -- --host 127.0.0.1 --port 4321 --strictPort"
? "bun run build && bun e2e/utils/serve.ts --host 127.0.0.1 --port 4321"
: "bun run dev -- --host 127.0.0.1 --port 4321 --strictPort",
)
expect(config.webServer.reuseExistingServer).toBe(!built)
+4 -1
View File
@@ -249,7 +249,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const created = yield* client.session.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
})
yield* client.session.view({ sessionID: Session.ID.make("ses_test"), idle: session.data.time.idle })
yield* client.session.view({
sessionID: Session.ID.make("ses_test"),
idle: DateTime.makeUnsafe(session.data.time.idle),
})
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
yield* client.session.switchModel({
sessionID: Session.ID.make("ses_test"),
@@ -1,8 +1,15 @@
import { expect } from "bun:test"
import { mkdtemp, rename, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { waitForExit } from "./service-timing"
// Windows has no catchable SIGTERM: process.kill ends the target at once with exit code 1, so the
// service's SIGTERM handler (record the signal, unregister a lingering server, exit 0) runs only on POSIX.
export const catchesSigterm = process.platform !== "win32"
export const stopSignal = catchesSigterm ? "SIGTERM" : undefined
export const stoppedExitCode = catchesSigterm ? 0 : 1
export async function serviceFixture() {
const directory = await mkdtemp(join(tmpdir(), "opencode-client-service-"))
const registration = join(directory, "service.json")
@@ -64,6 +71,12 @@ export async function serviceFixture() {
track(pid: number) {
pids.add(pid)
},
// The signal the service recorded while shutting down, if its handler ran.
signal() {
return Bun.file(registration + ".signal")
.text()
.catch(() => undefined)
},
async waitForFile(file = registration) {
for (let attempt = 0; attempt < 600; attempt++) {
if (await Bun.file(file).exists()) return
@@ -92,7 +105,13 @@ export async function serviceFixture() {
}
}
export async function expectPortAvailable(url: string) {
const server = Bun.serve({ port: Number(new URL(url).port), fetch: () => new Response() })
await server.stop(true)
// The service listens on an ephemeral port. Once it stops, any process may reuse that port for an outgoing connection
// (Windows hands them out from 49152 up, and other suites run alongside), so binding it again proves nothing. What the
// tests need is that nothing accepts connections there anymore.
export async function expectNotListening(url: string) {
const outcome = await fetch(url).then(
(response) => `listening (${response.status})`,
() => "no response",
)
expect(outcome).toBe("no response")
}
@@ -39,7 +39,6 @@ describe("public import boundaries", () => {
const effectService = await bundleInputs("@opencode/client/effect/service", "bun")
expect(within(effectService.eager, effect).length).toBeGreaterThan(0)
expect(within(effectService.eager, protocol).length).toBeGreaterThan(0)
expect(within(effectService.all, core)).toEqual([])
expect(within(effectService.all, server)).toEqual([])
})
+46 -35
View File
@@ -1,6 +1,12 @@
import { expect, test } from "bun:test"
import { Service, type EnsureReason } from "../src/promise/service"
import { expectPortAvailable, serviceFixture } from "./fixture/service-fixture"
import {
catchesSigterm,
expectNotListening,
serviceFixture,
stopSignal,
stoppedExitCode,
} from "./fixture/service-fixture"
import { accelerate } from "./fixture/service-timing"
const ensure = accelerate(Service.ensure)
@@ -213,7 +219,7 @@ test("evicts an unresponsive registered service before starting its replacement"
fixture.track(replacement.pid)
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(await existing.exited).toBe(stoppedExitCode)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
})
@@ -231,7 +237,7 @@ test("recovers when the starting service it waits for is stopped", async () => {
const replacement = await Bun.file(registration).json()
fixture.track(replacement.pid)
expect(await starting.exited).toBe(0)
expect(await starting.exited).toBe(stoppedExitCode)
expect(replacement.pid).not.toBe(starting.pid)
expect(endpoint.url).toBe(replacement.url)
})
@@ -267,7 +273,7 @@ test("stops the registered service even when terminal handoff fails", async () =
await Service.stop({ file: registration, pty: "handoff" })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await fixture.signal()).toBe(stopSignal)
expect(await Bun.file(registration).exists()).toBe(false)
expect((await Bun.file(registration + ".pty-handoff").json()).handoff).toBeNull()
})
@@ -280,7 +286,7 @@ test("signals the registered service process", async () => {
await Service.stop({ file: registration })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await fixture.signal()).toBe(stopSignal)
expect(await Bun.file(registration).exists()).toBe(false)
})
@@ -293,9 +299,9 @@ test("stop escalates when the registration disappears before the process exits",
await stop({ file: registration })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await fixture.signal()).toBe(stopSignal)
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
await expectNotListening(original.url)
expect(await Bun.file(registration).exists()).toBe(false)
await existing.exited
}, 15_000)
@@ -315,34 +321,39 @@ test.skipIf(process.platform === "win32")(
15_000,
)
test("stop waits for the original process while preserving a newly registered successor", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "15000")
await fixture.waitForFile()
const original = await Bun.file(registration).json()
// Default timing keeps the grace period open long enough for the successor to register first.
const stopping = Service.stop({ file: registration })
try {
await fixture.waitForFile(registration + ".unregistered")
const successor = fixture.spawn("graceful")
// On Windows the original process cannot outlive SIGTERM, so no successor can register before it exits.
test.skipIf(!catchesSigterm)(
"stop waits for the original process while preserving a newly registered successor",
async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "15000")
await fixture.waitForFile()
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(replacement.pid).toBe(successor.pid)
const original = await Bun.file(registration).json()
// Default timing keeps the grace period open long enough for the successor to register first.
const stopping = Service.stop({ file: registration })
await stopping
try {
await fixture.waitForFile(registration + ".unregistered")
const successor = fixture.spawn("graceful")
await fixture.waitForFile()
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(replacement.pid).toBe(successor.pid)
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
expect(await Bun.file(registration).json()).toEqual(replacement)
expect(await fetch(new URL("/api/info", replacement.url)).then((response) => response.json())).toMatchObject({
pid: successor.pid,
})
expect(successor.exitCode).toBe(null)
} finally {
existing.kill("SIGKILL")
await stopping
}
}, 20_000)
await stopping
expect(() => process.kill(original.pid, 0)).toThrow()
await expectNotListening(original.url)
expect(await Bun.file(registration).json()).toEqual(replacement)
expect(await fetch(new URL("/api/info", replacement.url)).then((response) => response.json())).toMatchObject({
pid: successor.pid,
})
expect(successor.exitCode).toBe(null)
} finally {
existing.kill("SIGKILL")
await stopping
}
},
20_000,
)
+6 -6
View File
@@ -41,17 +41,17 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.message)).toEqual(["list"])
expect(Object.keys(client.integration)).toEqual(["list", "get", "wellknown", "connect", "oauth", "command"])
expect(Object.keys(client.integration.wellknown)).toEqual(["add"])
expect(Object.keys(client.integration.connect)).toEqual(["key"])
expect(Object.keys(client.integration.connect)).toEqual(["key", "external"])
expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["get", "base", "status", "branches", "diff"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find", "write"])
expect(Object.keys(client.vcs)).toEqual(["init", "get", "base", "status", "branch", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
expect(Object.keys(client.pty.connect)).toEqual(["token"])
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
expect(client.experimental.persistentPty.read).toBeFunction()
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "update"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
@@ -971,7 +971,7 @@ test("session methods use the public HTTP contract", async () => {
const context = await client.session.context({ sessionID: "ses_test" })
const log = []
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
const interrupted = await client.session.interrupt({ sessionID: "ses_test", continue: true })
const interrupted = await client.session.interrupt({ sessionID: "ses_test", resume: true })
const message = await client.session.message.get({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
@@ -999,7 +999,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/experimental/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"],
["GET", "http://localhost:3000/api/experimental/session/ses_test/log?after=0"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt?continue=true"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt?resume=true"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
])
const viewBody = requests.find((request) => request.url.endsWith("/api/session/ses_test/view"))?.init?.body
@@ -4,7 +4,7 @@ import { Effect } from "effect"
import { Service } from "../src/effect/service"
import { ensure, stop } from "../src/promise/service"
import type { EnsureOptions, EnsureReason, StopOptions } from "../src/service"
import { serviceFixture } from "./fixture/service-fixture"
import { serviceFixture, stopSignal, stoppedExitCode } from "./fixture/service-fixture"
import { accelerate } from "./fixture/service-timing"
const effect = accelerate(Service.ensure)
@@ -71,8 +71,8 @@ for (const client of clients) {
const replacement = await Bun.file(fixture.registration).json()
fixture.track(replacement.pid)
expect(await owner.exited).toBe(0)
expect(await Bun.file(fixture.registration + ".signal").text()).toBe("SIGTERM")
expect(await owner.exited).toBe(stoppedExitCode)
expect(await fixture.signal()).toBe(stopSignal)
expect(await Bun.file(fixture.registration + ".handoff-request").exists()).toBe(true)
expect(starts).toEqual(["version-mismatch"])
expect(replacement.pid).not.toBe(owner.pid)
@@ -87,8 +87,8 @@ for (const client of clients) {
await Bun.write(fixture.registration + ".missing-health", "")
await client.stop({ file: fixture.registration })
expect(await owner.exited).toBe(0)
expect(await Bun.file(fixture.registration + ".signal").text()).toBe("SIGTERM")
expect(await owner.exited).toBe(stoppedExitCode)
expect(await fixture.signal()).toBe(stopSignal)
expect(await Bun.file(fixture.registration).exists()).toBe(false)
})
}
+45 -34
View File
@@ -3,7 +3,13 @@ import { expect, test } from "bun:test"
import { Effect, FileSystem } from "effect"
import { writeFile } from "node:fs/promises"
import { Service, type EnsureReason } from "../src/effect/service"
import { expectPortAvailable, serviceFixture } from "./fixture/service-fixture"
import {
catchesSigterm,
expectNotListening,
serviceFixture,
stopSignal,
stoppedExitCode,
} from "./fixture/service-fixture"
import { accelerate } from "./fixture/service-timing"
const ensure = accelerate(Service.ensure)
@@ -95,7 +101,7 @@ test("replaces an incompatible registered service", async () => {
const replacement = await Bun.file(registration).json()
fixture.track(replacement.pid)
expect(await existing.exited).toBe(0)
expect(await existing.exited).toBe(stoppedExitCode)
expect(replacement.version).toBe("2.1.0-next.1")
expect(endpoint.url).toBe(replacement.url)
expect(starts).toEqual(["version-mismatch"])
@@ -127,7 +133,7 @@ test("recovers when the starting service it waits for is stopped", async () => {
const replacement = await Bun.file(registration).json()
fixture.track(replacement.pid)
expect(await starting.exited).toBe(0)
expect(await starting.exited).toBe(stoppedExitCode)
expect(replacement.pid).not.toBe(starting.pid)
expect(endpoint.url).toBe(replacement.url)
})
@@ -187,7 +193,7 @@ test("evicts an unresponsive registered service before starting its replacement"
fixture.track(replacement.pid)
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(await existing.exited).toBe(stoppedExitCode)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
expect(await status(endpoint.url)).toMatchObject({ version: "test", pid: replacement.pid })
@@ -201,7 +207,7 @@ test("signals an unresponsive registered service process", async () => {
await run(Service.stop({ file: registration }))
await process.exited
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await fixture.signal()).toBe(stopSignal)
expect(await Bun.file(registration).exists()).toBe(false)
})
@@ -214,9 +220,9 @@ test("stop escalates when the registration disappears before the process exits",
await run(stop({ file: registration }))
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await fixture.signal()).toBe(stopSignal)
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
await expectNotListening(original.url)
expect(await Bun.file(registration).exists()).toBe(false)
await existing.exited
}, 15_000)
@@ -236,35 +242,40 @@ test.skipIf(process.platform === "win32")(
15_000,
)
test("stop waits for the original process while preserving a newly registered successor", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "15000")
await fixture.waitForFile()
const original = await Bun.file(registration).json()
// Default timing keeps the grace period open long enough for the successor to register first.
const stopping = run(Service.stop({ file: registration }))
try {
await fixture.waitForFile(registration + ".unregistered")
const successor = fixture.spawn("graceful")
// On Windows the original process cannot outlive SIGTERM, so no successor can register before it exits.
test.skipIf(!catchesSigterm)(
"stop waits for the original process while preserving a newly registered successor",
async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "15000")
await fixture.waitForFile()
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(replacement.pid).toBe(successor.pid)
const original = await Bun.file(registration).json()
// Default timing keeps the grace period open long enough for the successor to register first.
const stopping = run(Service.stop({ file: registration }))
await stopping
try {
await fixture.waitForFile(registration + ".unregistered")
const successor = fixture.spawn("graceful")
await fixture.waitForFile()
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(replacement.pid).toBe(successor.pid)
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
expect(await Bun.file(registration).json()).toEqual(replacement)
expect(await status(replacement.url)).toMatchObject({ pid: successor.pid })
expect(successor.exitCode).toBe(null)
} finally {
existing.kill("SIGKILL")
await stopping
}
}, 20_000)
await stopping
expect(() => process.kill(original.pid, 0)).toThrow()
await expectNotListening(original.url)
expect(await Bun.file(registration).json()).toEqual(replacement)
expect(await status(replacement.url)).toMatchObject({ pid: successor.pid })
expect(successor.exitCode).toBe(null)
} finally {
existing.kill("SIGKILL")
await stopping
}
},
20_000,
)
test("signals an incompatible service before starting its replacement", async () => {
await using fixture = await serviceFixture()
@@ -281,7 +292,7 @@ test("signals an incompatible service before starting its replacement", async ()
const replacement = await Bun.file(registration).json()
fixture.track(replacement.pid)
expect(await existing.exited).toBe(0)
expect(await existing.exited).toBe(stoppedExitCode)
expect(endpoint.url).toBe(replacement.url)
})
+2 -1
View File
@@ -35,7 +35,8 @@ const environment = {
}
const child = Bun.spawn({
cmd: [process.execPath, "test", "--only-failures", ...process.argv.slice(2)],
// The 30s default timeout matches cli and tui; npm.test's Git package installs ran past Bun's 5s default on Windows.
cmd: [process.execPath, "test", "--only-failures", "--timeout=30000", ...process.argv.slice(2)],
env: environment,
stdin: "inherit",
stdout: "inherit",
+22 -2
View File
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { Context, Effect, Fiber, RcMap, Schedule } from "effect"
import { Clock, Context, Duration, Effect, Fiber, Layer, RcMap, Schedule } from "effect"
import { Browser } from "@opencode/plugin-browser/rpc"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
@@ -21,6 +21,22 @@ import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// LocationActivity measures idleness on this clock. Its sweep still sleeps in real time, but only the test moves the
// clock past the TTL, so slow plugin activation on a busy machine cannot evict the location before the browser
// attachment exists, as a short real TTL could.
const timeToLive = Duration.minutes(1)
const idle = { millis: 0 }
const live = Clock.Clock.defaultValue()
const activityClock: Clock.Clock = {
currentTimeMillisUnsafe: () => live.currentTimeMillisUnsafe() + idle.millis,
currentTimeMillis: Effect.sync(() => live.currentTimeMillisUnsafe() + idle.millis),
currentTimeNanosUnsafe: () => live.currentTimeNanosUnsafe() + BigInt(idle.millis) * 1_000_000n,
currentTimeNanos: Effect.sync(() => live.currentTimeNanosUnsafe() + BigInt(idle.millis) * 1_000_000n),
monotonicTimeNanosUnsafe: () => live.monotonicTimeNanosUnsafe(),
monotonicTimeNanos: live.monotonicTimeNanos,
sleep: (duration) => live.sleep(duration),
}
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Session.node, LocationServiceMap.node, LocationActivity.node]),
@@ -30,7 +46,9 @@ const it = testEffect(
LocationActivity.node.replace(
makeGlobalNode({
service: LocationActivity.Service,
layer: LocationActivity.layer({ timeToLive: "2 seconds", sweepInterval: "100 millis" }),
layer: LocationActivity.layer({ timeToLive, sweepInterval: "100 millis" }).pipe(
Layer.provide(Layer.succeed(Clock.Clock, activityClock)),
),
deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node],
}),
),
@@ -62,6 +80,8 @@ it.live(
}),
Effect.timeout("5 seconds"),
)
// The location has now been idle for its whole TTL; the next sweep evicts it.
idle.millis += Duration.toMillis(timeToLive)
// Real idle cleanup must end the long-lived request, not leave a second registry behind it.
expect(yield* Fiber.join(pending).pipe(Effect.timeout("10 seconds"))).toMatchObject({ type: "rpc.unavailable" })
+3 -1
View File
@@ -309,7 +309,9 @@ describe("config plugin reloads", () => {
(yield* integrations.get(Integration.ID.make("second"))) !== undefined &&
(yield* references.list()).some((reference) => reference.name === "second") &&
(yield* providers.get(Provider.ID.make("first"))) === undefined &&
(yield* providers.get(Provider.ID.make("second"))) !== undefined
(yield* providers.get(Provider.ID.make("second"))) !== undefined &&
(yield* skills.list()).every((skill) => skill.id !== "first") &&
(yield* skills.list()).some((skill) => skill.id === "second")
)
}),
)
@@ -19,6 +19,7 @@ import { Location } from "@opencode/core/location"
import { Plugin } from "@opencode/core/plugin"
import { PluginSupervisor } from "@opencode/core/plugin/supervisor"
import { AbsolutePath } from "@opencode/core/schema"
import { initRepo } from "../fixture/git"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
@@ -267,12 +268,7 @@ function withTmp<A, E, R>(
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
}
if (options?.vcs !== "git") return { tmp, vcs: undefined }
await $`git init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
await $`git config user.name Test`.cwd(tmp.path).quiet()
await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
await initRepo(tmp.path)
await options.init?.(tmp.path)
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
}),
+18 -8
View File
@@ -10,13 +10,27 @@ import { tmpdir } from "./tmpdir"
const exec = promisify(execFile)
// Appended to the new repository's own config instead of running one `git config` process per key, which costs
// tens of milliseconds each on Windows. Repositories created by production code still have no identity.
const repositoryConfig = `[core]
fsmonitor = false
[commit]
gpgsign = false
[user]
email = test@opencode.test
name = Test
`
export async function initGit(directory: string, ...args: string[]) {
await $`git init ${args} ${directory}`.quiet()
await fs.appendFile(path.join(directory, ".git", "config"), repositoryConfig)
}
export async function gitRemote(root: string) {
const origin = path.join(root, "origin.git")
const source = path.join(root, "source")
await git(root, "init", "--bare", origin)
await git(root, "init", source)
await git(source, "config", "user.email", "test@example.com")
await git(source, "config", "user.name", "Test")
await initGit(source)
await fs.writeFile(path.join(source, "README.md"), "one\n")
await git(source, "add", "README.md")
await git(source, "commit", "-m", "initial")
@@ -48,11 +62,7 @@ export function read(file: string) {
}
export async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await initGit(directory)
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
}
+10 -4
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Credential } from "@opencode/core/credential"
@@ -8,6 +9,7 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
import { Bus } from "@opencode/core/bus"
import { Integration } from "@opencode/core/integration"
import { State } from "@opencode/core/state"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
@@ -31,17 +33,18 @@ const failingIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [Credential.node.replace(failingCredentialNode)]),
)
// Bounded by elapsed time rather than attempts, so a slow child process start on a busy machine still fits.
function eventually<A, E, R>(
effect: Effect.Effect<A, E, R>,
predicate: (value: A) => boolean,
remaining = 1000,
deadline = Date.now() + 10_000,
): Effect.Effect<A, E | Error, R> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
if (Date.now() > deadline) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
return yield* eventually(effect, predicate, deadline)
})
}
@@ -224,6 +227,8 @@ describe("Integration", () => {
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("company")
const methodID = Integration.MethodID.make("login")
// The command prints its secret only after the test has seen it pending, however slow the machine is.
const release = path.join((yield* tmpdirScoped()).path, "release")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
@@ -234,7 +239,7 @@ describe("Integration", () => {
command: [
process.execPath,
"-e",
'console.error("https://example.com/login"); await Bun.sleep(50); console.log("secret")',
`console.error("https://example.com/login"); while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10); console.log("secret")`,
],
},
}),
@@ -246,6 +251,7 @@ describe("Integration", () => {
(status) => status.status === "pending" && status.message?.includes("https://example.com/login") === true,
)
expect(pending).toMatchObject({ status: "pending", message: "https://example.com/login\n" })
yield* Effect.promise(() => Bun.write(release, ""))
expect(
yield* eventually(
@@ -28,7 +28,7 @@ import { advance } from "../lib/clock"
import { testEffect } from "../lib/effect"
// Real Location boot with plugin-directory discovery, so local plugin files are loaded and reloaded.
setDefaultTimeout(15_000)
setDefaultTimeout(30_000)
// Package resolution can be held open so overlapping activations become observable.
const npm = {
+22 -19
View File
@@ -10,6 +10,8 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
import { AppProcess } from "@opencode/util/process"
import { testEffect } from "../lib/effect"
// Every test spawns a real child, so all of them use the live clock. The spawner's 1s post-exit capture deadline
// never fires under TestClock, and a child whose close event arrives late would hang until the test timeout.
const it = testEffect(LayerNode.compile(AppProcess.node))
const NODE = process.execPath
@@ -31,7 +33,7 @@ const waitForFile = (file: string) =>
describe("AppProcess", () => {
describe("run", () => {
it.effect(
it.live(
"captures stdout and exit code zero",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -54,14 +56,15 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"captures stdout and stderr in emission order",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const script = [
'process.stdout.write("out 1\\n")',
'setTimeout(() => process.stderr.write("err 1\\n"), 10)',
'setTimeout(() => process.stdout.write("out 2\\n"), 20)',
// Gaps wide enough that a busy test machine still reads each write before the next one.
'setTimeout(() => process.stderr.write("err 1\\n"), 100)',
'setTimeout(() => process.stdout.write("out 2\\n"), 200)',
].join(";")
const result = yield* svc.run(cmd("-e", script), { combineOutput: true })
expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n")
@@ -70,7 +73,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"non-zero exit returns RunResult; caller can require success",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -79,7 +82,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"requireSuccess fails on non-zero exit",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -92,7 +95,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"requireSuccess succeeds on exit 0",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -101,7 +104,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"requireExitIn allowlists multiple exit codes",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -116,7 +119,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"truncates stdout when maxOutputBytes is set",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -129,7 +132,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"truncates stderr when maxErrorBytes is set",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -142,7 +145,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"result includes command description",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -197,7 +200,7 @@ describe("AppProcess", () => {
})
describe("inherited platform methods", () => {
it.effect(
it.live(
"string returns stdout as string",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -206,7 +209,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"lines returns the platform's array of lines",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -219,7 +222,7 @@ describe("AppProcess", () => {
describe("run with stdin option", () => {
const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
it.effect(
it.live(
"feeds a string to stdin and returns it on stdout",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -229,7 +232,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"feeds a Uint8Array to stdin",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -240,7 +243,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"feeds a Stream of Uint8Array chunks to stdin",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -252,7 +255,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"completes correctly with empty input",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -262,7 +265,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"carries existing Command options like env",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -275,7 +278,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"carries existing Command options like cwd",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
+2 -5
View File
@@ -12,6 +12,7 @@ import { ProjectSchema } from "@opencode/core/project/schema"
import { ProjectTable } from "@opencode/core/project/sql"
import { AbsolutePath } from "@opencode/core/schema"
import { Hash } from "@opencode/util/hash"
import { initGit } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -326,11 +327,7 @@ function real(value: string) {
}
async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) {
await $`git init`.cwd(dir).quiet()
await $`git config core.fsmonitor false`.cwd(dir).quiet()
await $`git config commit.gpgsign false`.cwd(dir).quiet()
await $`git config user.email test@opencode.test`.cwd(dir).quiet()
await $`git config user.name Test`.cwd(dir).quiet()
await initGit(dir)
if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet()
if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
}
+1 -1
View File
@@ -16,7 +16,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
// Cold Git setup and cloning can exceed Bun's five-second default on Windows.
setDefaultTimeout(15_000)
setDefaultTimeout(30_000)
describe("RepositoryCache", () => {
it.live("persists the daily throttle across cache recreation and serializes competing refreshes", () =>
@@ -18,7 +18,7 @@ import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// Real Location boot with plugin discovery, so the first request races the initial plugin activation.
setDefaultTimeout(15_000)
setDefaultTimeout(30_000)
const it = testEffect(
AppNodeBuilder.build(
+3 -8
View File
@@ -36,6 +36,7 @@ import { Workspace } from "@opencode/core/workspace"
import { Expected } from "./lib/session-message"
import { testEffect } from "./lib/effect"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { initGit } from "./fixture/git"
import { offlineModels } from "./fixture/models"
import { promptLocationNode } from "./fixture/prompt-location"
import { globalProjectNode } from "./lib/project"
@@ -158,11 +159,7 @@ describe("Session.create", () => {
.pipe(Effect.orDie)
if (!before) return yield* Effect.die(new Error("created session not found"))
yield* Effect.promise(async () => {
await $`git init -q`.cwd(directory)
await $`git config user.email test@example.com`.cwd(directory)
await $`git config user.name Test`.cwd(directory)
})
yield* Effect.promise(() => initGit(directory))
const unborn = yield* projects.resolve(ref.directory)
const unbornRoot = yield* session.get(created.id)
const unbornNested = yield* session.get(child.id)
@@ -269,9 +266,7 @@ describe("Session.create", () => {
yield* Effect.promise(async () => {
for (const root of [nested, outer]) {
await $`git init -q`.cwd(root)
await $`git config user.email test@example.com`.cwd(root)
await $`git config user.name Test`.cwd(root)
await initGit(root)
await $`git commit --allow-empty -qm initial`.cwd(root)
}
})
+1 -1
View File
@@ -19,7 +19,7 @@ import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// Every test boots a real Location, so shell start waits for cold plugin activation before spawning.
setDefaultTimeout(15_000)
setDefaultTimeout(30_000)
class ExecutionControl extends Context.Service<
ExecutionControl,
+3 -10
View File
@@ -17,6 +17,7 @@ import { VcsGitPlugin } from "@opencode/core/plugin/vcs/git"
import type { VcsDefinition, VcsDiffInput } from "@opencode/plugin/effect/vcs"
import { FileSystem } from "@opencode/schema/filesystem"
import { VcsEvent } from "@opencode/schema/vcs-event"
import { initGit } from "./fixture/git"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it, testEffect } from "./lib/effect"
@@ -61,7 +62,7 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>, input: { scope?: string } = {}) =>
withTmp((directory) =>
Effect.promise(async () => {
await initRepo(directory)
await initGit(directory, "-b", "main")
if (input.scope) await fs.mkdir(path.join(directory, input.scope), { recursive: true })
}).pipe(
Effect.andThen(
@@ -78,14 +79,6 @@ const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>, inpu
),
)
async function initRepo(directory: string) {
await $`git init -b main`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
}
async function commitAll(directory: string, message: string) {
await $`git add -A`.cwd(directory).quiet()
await $`git commit -m ${message}`.cwd(directory).quiet()
@@ -586,7 +579,7 @@ describe("Vcs", () => {
Effect.gen(function* () {
const count = 40
yield* Effect.promise(async () => {
await initRepo(directory)
await initGit(directory, "-b", "main")
await fs.writeFile(path.join(directory, "keep.txt"), "one\n")
await fs.writeFile(path.join(directory, ".gitignore"), "*.log\n")
await commitAll(directory, "initial")
@@ -606,6 +606,19 @@ function ArtifactVideo(props: MediaProps) {
playsinline
onError={() => props.onError()}
src={url()}
onClick={(event) => {
// Chromium's controls answer the second click of a double click with a native fullscreen request, which
// covers the theatre the double click opens. Opting out with controlslist="nofullscreen" would also end
// their click-to-play. The fullscreen element ready check refuses an open popover, so the video is one
// while its controls handle this click, and is released before the next frame renders.
const el = event.currentTarget
// In native fullscreen, the controls' own exit must run; showPopover would throw there.
if (event.detail < 2 || document.fullscreenElement === el) return
el.setAttribute("popover", "manual")
el.showPopover?.()
requestAnimationFrame(() => el.removeAttribute("popover"))
}}
onDblClick={toggle}
onPlay={() => setPaused(false)}
onPause={() => setPaused(true)}
+13 -11
View File
@@ -129,18 +129,20 @@ describe("WebSocket", () => {
),
)
await Effect.runPromise(
Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
const options = { headers: { authorization: "Bearer fixture" } }
const socket = constructor("wss://echo.example.test/options", options)
yield* Effect.callback<void>((resume) => {
socket.addEventListener("open", () => {
socket.close()
resume(Effect.void)
await withEnvironment("CI", undefined, () =>
Effect.runPromise(
Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
const options = { headers: { authorization: "Bearer fixture" } }
const socket = constructor("wss://echo.example.test/options", options)
yield* Effect.callback<void>((resume) => {
socket.addEventListener("open", () => {
socket.close()
resume(Effect.void)
})
})
})
}).pipe(Effect.scoped, Effect.provide(recorder)),
}).pipe(Effect.scoped, Effect.provide(recorder)),
),
)
expect(received).toEqual({ headers: { authorization: "Bearer fixture" } })
+24 -13
View File
@@ -20,7 +20,6 @@ test("audits deterministic flowchart and state layout families", () => {
expect(audits.length).toBeGreaterThanOrEqual(fixtures.length)
expect(violations).toEqual([])
expect(elapsedMs).toBeLessThan(35_000)
for (const audit of audits.filter((audit) => audit.fixture.kind === "state")) {
expect(audit.viewport).toBe(audit.fixture.profile === "short" ? 60 : audit.fixture.profile === "unicode" ? 80 : 120)
}
@@ -34,18 +33,30 @@ test("audits deterministic flowchart and state layout families", () => {
.filter((audit) => audit.fixture.id === "flowchart/deployment-architecture/curated")
.map((audit) => audit.viewport),
).toEqual([60, 80, 120])
expect(summary.total.area.max).toBeLessThanOrEqual(11_011)
expect(summary.total.area.p95).toBeLessThanOrEqual(5_313)
expect(summary.total.bends.max).toBeLessThanOrEqual(30)
expect(summary.total.bends.p95).toBeLessThanOrEqual(11)
expect(summary.total.crossings.total).toBeLessThanOrEqual(40)
expect(summary.total.crossings.max).toBeLessThanOrEqual(3)
expect(summary.total.routeLength.max).toBeLessThanOrEqual(930)
expect(summary.total.routeLength.p95).toBeLessThanOrEqual(364)
expect(summary.total.sharedRouteCells.max).toBeLessThanOrEqual(547)
expect(summary.total.sharedRouteCells.p95).toBeLessThanOrEqual(122)
expect(summary.total.overflow.max).toBeLessThanOrEqual(170)
expect(summary.total.overflow.p95).toBeLessThanOrEqual(99)
// decision-loops is a deliberately crossing-heavy family; ceiling it separately so it cannot absorb the
// p95 headroom of the established families.
const established = summarizeAudits(audits.filter((audit) => audit.fixture.family !== "decision-loops")).total
expect(established.area.max).toBeLessThanOrEqual(11_011)
expect(established.area.p95).toBeLessThanOrEqual(5_313)
expect(established.bends.max).toBeLessThanOrEqual(30)
expect(established.bends.p95).toBeLessThanOrEqual(11)
expect(established.crossings.total).toBeLessThanOrEqual(40)
expect(established.crossings.max).toBeLessThanOrEqual(3)
expect(established.routeLength.max).toBeLessThanOrEqual(930)
expect(established.routeLength.p95).toBeLessThanOrEqual(364)
expect(established.sharedRouteCells.max).toBeLessThanOrEqual(547)
expect(established.sharedRouteCells.p95).toBeLessThanOrEqual(122)
expect(established.overflow.max).toBeLessThanOrEqual(170)
expect(established.overflow.p95).toBeLessThanOrEqual(99)
const decisionLoops = summarizeAudits(audits.filter((audit) => audit.fixture.family === "decision-loops")).total
expect(decisionLoops.runs).toBeGreaterThan(0)
expect(decisionLoops.area.max).toBeLessThanOrEqual(8_784)
expect(decisionLoops.bends.max).toBeLessThanOrEqual(24)
expect(decisionLoops.crossings.total).toBeLessThanOrEqual(24)
expect(decisionLoops.crossings.max).toBeLessThanOrEqual(8)
expect(decisionLoops.routeLength.max).toBeLessThanOrEqual(526)
expect(decisionLoops.sharedRouteCells.max).toBeLessThanOrEqual(352)
expect(decisionLoops.overflow.max).toBeLessThanOrEqual(84)
expect(summary.state.crossings.total).toBe(0)
for (const fixture of [...Map.groupBy(fixtures, (candidate) => `${candidate.kind}/${candidate.family}`).values()].map(
+2 -1
View File
@@ -1348,9 +1348,10 @@ function placeStateTransitionLabels(
const dy = distance - Math.abs(dx)
for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
const candidateX = x + dx
if (!isClear(candidateX, candidateY)) continue
// Reject by path distance before isClear: it is far cheaper and rules out most of the spiral.
const pathDistance = labelDistanceToPath(candidateX, candidateY, width, plan.label!.lines.length, plan.path)
if (pathDistance < 2 || pathDistance > 8) continue
if (!isClear(candidateX, candidateY)) continue
x = candidateX
y = candidateY
break search
+2 -2
View File
@@ -44,8 +44,8 @@ test("Promise event streams support cancellation", async () => {
expect(await events.next()).toMatchObject({ value: { type: "server.connected" }, done: false })
const pending = events.next()
controller.abort()
const error = await pending.catch((error: unknown) => error)
expect(error).toMatchObject({ name: "ClientError", reason: "Transport" })
// A caller abort ends only this subscriber; client tests pin closing the shared transport after the last one.
expect(await pending).toEqual({ done: true, value: undefined })
await events.return?.()
}
})
+6 -1
View File
@@ -45,9 +45,14 @@ for (const entrypoint of ["create", "layer"] as const) {
expect(yield* client.events.subscribe().pipe(Stream.take(1), Stream.runCollect)).toMatchObject([
{ type: "server.connected" },
])
expect(yield* client.sessions.get({ sessionID: Session.ID.create() }).pipe(Effect.flip)).toMatchObject({
const missing = Session.ID.create()
// Error responses must decode over the SDK transport too; sessions.get is served by core without HTTP.
expect(yield* client.sessions.export({ sessionID: missing }).pipe(Effect.flip)).toMatchObject({
_tag: "SessionNotFoundError",
})
expect(yield* client.sessions.get({ sessionID: missing }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.NotFoundError",
})
// Binding the SDK's transport must not change the caller's surrounding context.
expect(yield* FetchHttpClient.Fetch).toBe(ambient)
}).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
+3 -1
View File
@@ -3,7 +3,7 @@ import { InvalidRequestError, ServiceUnavailableError } from "@opencode/protocol
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { Api } from "../api"
import { response } from "../location"
import { awaitPlugins, response } from "../location"
export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (handlers) =>
Effect.gen(function* () {
@@ -18,6 +18,8 @@ export const WebSearchHandler = HttpApiBuilder.group(Api, "server.websearch", (h
.handle(
"websearch.query",
Effect.fn("server.websearch.query")(function* (request) {
// A cold Location must finish activating plugins before their providers can be resolved.
yield* awaitPlugins
const websearch = yield* WebSearch.Service
return yield* response(
websearch.query(request.payload).pipe(
+34 -5
View File
@@ -1,4 +1,5 @@
import { defineMain } from "storybook-solidjs-vite"
import { existsSync, readdirSync, readFileSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import tailwindcss from "@tailwindcss/vite"
@@ -11,6 +12,22 @@ const app = path.resolve(here, "../../app/src")
const extensions = path.resolve(here, "../../gui-extensions/src")
const mocks = path.resolve(here, "./mocks")
// Component tests import real modules by URL (`source("../src/…")`, `new URL("./x.fixture.tsx", import.meta.url)`),
// past the mock aliases. Read those paths from the specs so the dependency optimizer crawls exactly what they load.
const packages = path.resolve(here, "../..")
const componentTestModules = readdirSync(packages)
.map((name) => path.join(packages, name, "component-tests"))
.filter((dir) => existsSync(dir))
.flatMap((dir) =>
readdirSync(dir, { recursive: true, encoding: "utf8" })
.filter((file) => file.endsWith(".spec.ts"))
.flatMap((file) =>
Array.from(readFileSync(path.join(dir, file), "utf8").matchAll(/["'](\.{1,2}\/[^"']+\.tsx?)["']/g), (match) =>
path.resolve(dir, path.dirname(file), match[1]!),
),
),
)
export default defineMain({
framework: {
name: "storybook-solidjs-vite",
@@ -32,16 +49,28 @@ export default defineMain({
],
async viteFinal(config) {
const { mergeConfig, searchForWorkspaceRoot } = await import("vite")
const entry = (file: string) =>
path.relative(config.root ?? process.cwd(), path.resolve(here, file)).replaceAll("\\", "/")
const merged = mergeConfig(config, {
plugins: [tailwindcss(), playgroundCss()],
optimizeDeps: {
// Without Storybook's optimizer plugin (removed below), Vite finds dependencies on first import and reloads
// every open page, aborting component tests mid-import. Crawl the stories, the workers (the scan does not
// follow `new Worker(new URL(…))`), the modules component tests load, and the preview with the Storybook
// runtime that its virtual entry imports, so the first browser request never discovers a new dependency.
entries: [
entry("../../{ui,session-ui,app,gui-extensions}/src/**/*.{stories.tsx,worker.ts}"),
...new Set(componentTestModules.map(entry)),
entry("./preview.tsx"),
],
include: ["storybook/internal/preview/runtime"],
},
resolve: {
dedupe: ["solid-js", "solid-js/web", "@solidjs/meta"],
// Storybook declares @tanstack/solid-query itself, so dedupe resolves one copy under both bun linkers. A path
// into app/node_modules exists only with the isolated linker; Windows CI installs hoisted.
dedupe: ["solid-js", "solid-js/web", "@solidjs/meta", "@tanstack/solid-query"],
alias: [
{ find: "@solidjs/router", replacement: path.resolve(mocks, "solid-router.tsx") },
{
find: "@tanstack/solid-query",
replacement: path.resolve(here, "../../app/node_modules/@tanstack/solid-query"),
},
{ find: /^@\/providers\/models\/selection$/, replacement: path.resolve(mocks, "app/context/local.ts") },
{ find: /^@\/workspaces\/files\/model$/, replacement: path.resolve(mocks, "app/context/file.ts") },
{ find: /^@\/shell\/state\/layout$/, replacement: path.resolve(mocks, "app/context/layout.ts") },
+3 -1
View File
@@ -79,7 +79,9 @@ const frame = createJSXDecorator((Story, context) => {
<MarkedProvider>
<div
style={{
"min-height": "100vh",
// Outside fullscreen Storybook adds 1rem of padding around the story. A full-viewport frame inside it
// makes every story page scroll by 32px, so an incidental scroll can move the story under a test.
"min-height": fullscreen ? "100vh" : "calc(100vh - 2rem)",
padding: fullscreen ? "0" : "24px",
"background-color": "var(--background-base)",
color: "var(--text-base)",
+4 -1
View File
@@ -19,12 +19,15 @@ export function componentConfig(directory: string) {
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
// Vite reports dependency re-optimization ("optimized dependencies changed. reloading") on stdout; keep it in logs.
stdout: "pipe",
},
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
// CI retries failures, so the retry records the video; local runs have no retries.
video: process.env.CI ? "on-first-retry" : "retain-on-failure",
},
projects: [{ name: "components", use: { ...devices["Desktop Chrome"] } }],
})
+8 -8
View File
@@ -26,14 +26,14 @@ export const story = test.extend<{
.join(";"),
)
}
if (options?.globals) {
query.set(
"globals",
Object.entries(options.globals)
.map(([key, value]) => `${key}:${value}`)
.join(";"),
)
}
// No test reads addon-a11y results, so skip its axe run after every render. Switching preview.tsx to
// `a11y.test: "error"` would therefore not be enforced by these tests. Caller globals still win.
query.set(
"globals",
Object.entries({ "a11y.manual": "!true", ...options?.globals })
.map(([key, value]) => `${key}:${value}`)
.join(";"),
)
await page.goto(`/iframe.html?${query}`)
const root = page.locator("#storybook-root")
await expect(root).toBeVisible({ timeout: 30_000 })
+15 -6
View File
@@ -7,6 +7,7 @@ import path from "node:path"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import { createAppFixture } from "./fixture/app"
import { waitForHighlightedFrame } from "./fixture/frame"
import type { PluginInfo } from "@opencode/client"
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
@@ -622,7 +623,8 @@ test.each([80, 120])("completes custom Markdown and ordinary fences in a session
return undefined
},
})
const streaming = await setup.waitForFrame(
const streaming = await waitForHighlightedFrame(
setup,
(frame) =>
frame.includes("initial") &&
frame.includes("DiagramStart") &&
@@ -660,7 +662,8 @@ test.each([80, 120])("completes custom Markdown and ordinary fences in a session
tokens: session.tokens,
},
})
const frame = await setup.waitForFrame(
const frame = await waitForHighlightedFrame(
setup,
(frame) => frame.includes("MARKDOWN_END") && frame.includes("initial final") && frame.includes("2ms"),
)
expect(frame).toContain("DiagramStart")
@@ -754,13 +757,17 @@ test("keeps assistant footer metrics current after prepend, same-length refresh,
},
})
const initial = await setup.waitForFrame((frame) => frame.includes("Original answer") && frame.includes("20.0 tok/s"))
const initial = await waitForHighlightedFrame(
setup,
(frame) => frame.includes("Original answer") && frame.includes("20.0 tok/s"),
)
expect(initial).toContain("Current input")
expect(initial).toContain("4.0s \u00b7 20.0 tok/s")
expect(initial).not.toContain("Prepended input")
setup.mockInput.pressKey("g", { ctrl: true })
const prepended = await setup.waitForFrame(
const prepended = await waitForHighlightedFrame(
setup,
(frame) =>
frame.includes("Prepended input") &&
frame.includes("Prepended instructions") &&
@@ -775,7 +782,8 @@ test("keeps assistant footer metrics current after prepend, same-length refresh,
// Refresh the latest page: length stays four, but the retained assistant moves from index three to one.
refresh = true
setup.events.disconnect()
const refreshed = await setup.waitForFrame(
const refreshed = await waitForHighlightedFrame(
setup,
(frame) =>
frame.includes("Later input") &&
frame.includes("Later answer") &&
@@ -795,7 +803,8 @@ test("keeps assistant footer metrics current after prepend, same-length refresh,
durable: { aggregateID: session.id, seq: 1, version: 1 },
data: { sessionID: session.id, to: "msg_0004" },
})
const reverted = await setup.waitForFrame(
const reverted = await waitForHighlightedFrame(
setup,
(frame) => frame.includes("Original answer") && !frame.includes("Later input") && !frame.includes("Later answer"),
)
expect(reverted).toContain("Current input")
+7 -1
View File
@@ -71,7 +71,13 @@ test("custom commands commit the captured agent, model and variant before execut
await setup.mockInput.typeText("second")
await setup.renderOnce()
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Select variant") && frame.includes("low"))
// DialogSelect focuses its filter on a timer; typing earlier is dropped and Enter picks "Default".
await setup.waitForFrame(
(frame) =>
frame.includes("Select variant") &&
frame.includes("low") &&
setup.renderer.currentFocusedRenderable instanceof InputRenderable,
)
await setup.mockInput.typeText("low")
await setup.renderOnce()
setup.mockInput.pressEnter()
+25
View File
@@ -0,0 +1,25 @@
import { CodeRenderable, type Renderable } from "@opentui/core"
import type { TestRendererSetup, TestWaitForOptions } from "@opentui/core/testing"
// Markdown and code paint nothing until the shared tree-sitter worker answers. That round trip is
// not tied to frames, so a plain waitForFrame can spend its whole pass budget while text is still
// pending on a loaded runner. Awaiting in-flight highlights inside the predicate keeps the passes
// for render-driven work.
export function waitForHighlightedFrame(
setup: Pick<TestRendererSetup, "renderer" | "waitForFrame">,
predicate: (frame: string) => boolean,
options?: TestWaitForOptions,
) {
return setup.waitForFrame(async (frame) => {
if (predicate(frame)) return true
await Promise.all(highlighting(setup.renderer.root).map((node) => node.highlightingDone))
return false
}, options)
}
function highlighting(node: Renderable): CodeRenderable[] {
return [
...(node instanceof CodeRenderable && node.isHighlighting ? [node] : []),
...node.getChildren().flatMap(highlighting),
]
}
@@ -6,6 +6,7 @@ import { Global } from "@opencode/util/global"
import type { SessionMessageInfo } from "@opencode/client"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import { waitForHighlightedFrame } from "./fixture/frame"
import { mkdir } from "node:fs/promises"
test.each([
@@ -107,10 +108,10 @@ test.each([
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await setup.waitForFrame((frame) => frame.includes("Final marker"))
await waitForHighlightedFrame(setup, (frame) => frame.includes("Final marker"))
expect(setup.captureCharFrame()).not.toContain("Second thought body")
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("Thought:"))
await waitForHighlightedFrame(setup, (frame) => frame.includes("Thought:"))
await setup.waitForVisualIdle({ quietFrames: 3 })
const find = (node: Renderable): ScrollBoxRenderable | undefined =>
node instanceof ScrollBoxRenderable && node.getRenderable("msg_b")
@@ -131,9 +132,9 @@ test.each([
.findIndex((line) => line.includes("Thought:")) - initial.viewport.y
if (lines > 0) expect(summaryOffset).toBe(0)
setup.mockInput.pressKey("2", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Other session content"))
await waitForHighlightedFrame(setup, (frame) => frame.includes("Other session content"))
setup.mockInput.pressKey("1", { ctrl: true })
await setup.waitForFrame((frame) => {
await waitForHighlightedFrame(setup, (frame) => {
const viewport = find(setup.renderer.root)
return (
!!viewport &&
@@ -155,24 +156,24 @@ test.each([
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
await setup.waitForVisualIdle({ quietFrames: 3 })
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("Second response"))
await waitForHighlightedFrame(setup, (frame) => frame.includes("Second response"))
await setup.waitForVisualIdle({ quietFrames: 3 })
expect(setup.captureCharFrame()).not.toContain("Second thought body")
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("Thought:"))
await waitForHighlightedFrame(setup, (frame) => frame.includes("Thought:"))
await setup.waitForVisualIdle({ quietFrames: 3 })
const header = setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Thought:"))
await setup.mockMouse.click(8, header)
await setup.waitForFrame((frame) => frame.includes("First thought body"))
await waitForHighlightedFrame(setup, (frame) => frame.includes("First thought body"))
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
await setup.waitForVisualIdle({ quietFrames: 3 })
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
await setup.waitForVisualIdle({ quietFrames: 3 })
setup.mockInput.pressKey("n", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("Second thought body"))
await waitForHighlightedFrame(setup, (frame) => frame.includes("Second thought body"))
await setup.waitForVisualIdle({ quietFrames: 3 })
const scroll = find(setup.renderer.root)
if (!scroll) throw new Error("Missing transcript scrollbox")
@@ -183,9 +184,9 @@ test.each([
expect(titleLine - scroll.viewport.y).toBe(1)
expect(scroll.scrollTop).toBeGreaterThan(0)
setup.mockInput.pressKey("2", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Other session content"))
await waitForHighlightedFrame(setup, (frame) => frame.includes("Other session content"))
setup.mockInput.pressKey("1", { ctrl: true })
await setup.waitForFrame((frame) => {
await waitForHighlightedFrame(setup, (frame) => {
const viewport = find(setup.renderer.root)
return (
!!viewport && frame.split("\n").findIndex((line) => line.includes("Second title")) - viewport.viewport.y === 1
@@ -203,7 +204,7 @@ test.each([
// Save inside A's second part, not relative to A's earlier text part.
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("First title"))
await waitForHighlightedFrame(setup, (frame) => frame.includes("First title"))
await setup.waitForVisualIdle({ quietFrames: 3 })
const reading = find(setup.renderer.root)
if (!reading) throw new Error("Missing reading viewport")
@@ -220,9 +221,9 @@ test.each([
.findIndex((line) => line.includes("First thought body")) - reading.viewport.y
expect(bodyOffset).toBe(0)
setup.mockInput.pressKey("2", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Other session content"))
await waitForHighlightedFrame(setup, (frame) => frame.includes("Other session content"))
setup.mockInput.pressKey("1", { ctrl: true })
await setup.waitForFrame((frame) => {
await waitForHighlightedFrame(setup, (frame) => {
const viewport = find(setup.renderer.root)
return (
!!viewport &&
+3 -2
View File
@@ -5,6 +5,7 @@ import { Effect, FileSystem } from "effect"
import { Global } from "@opencode/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import { waitForHighlightedFrame } from "./fixture/frame"
test.each([
"bottom",
@@ -110,7 +111,7 @@ test.each([
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await setup.waitForFrame((frame) => frame.includes("History message 0399"))
await waitForHighlightedFrame(setup, (frame) => frame.includes("History message 0399"))
const findScrollBox = (root: Renderable): ScrollBoxRenderable | undefined =>
root instanceof ScrollBoxRenderable && root.getRenderable("message-399")
? root
@@ -223,7 +224,7 @@ test.each([
)
await setup.waitForVisualIdle()
expect(frames).toEqual([before, visible()])
expect(setup.captureCharFrame()).toContain("History message 0001")
await waitForHighlightedFrame(setup, (frame) => frame.includes("History message 0001"))
expect(mounted().map((child) => child.id)).toEqual(messages.slice(0, 40).map((message) => message.id))
expect(scroll.scrollTop).toBe(0)
expect(Math.max(...mountCounts)).toBeLessThanOrEqual(60)
+7 -2
View File
@@ -47,6 +47,9 @@ function init() {
const closing = new Map<string, ReturnType<typeof setTimeout> | undefined>()
// The same ids, reactive, so the top dialog that stays open owns the focus trap during an exit animation.
const [exiting, setExiting] = createSignal<ReadonlySet<string>>(new Set())
// The top dialog that stays open. A dialog without an exit animation leaves the DOM as soon as it starts closing, so
// it stops being the active dialog then, not when it is disposed.
const top = () => stack().findLast((item) => !exiting().has(item.id))
const lock = { value: false }
const state = { disposed: false }
@@ -161,7 +164,7 @@ function init() {
return (
<Kobalte
modal={stack().findLast((item) => !exiting().has(item.id))?.id === id}
modal={top()?.id === id}
open={!closing()}
onOpenChange={(open: boolean) => {
if (!open) dismiss(id)
@@ -231,6 +234,7 @@ function init() {
return {
stack,
top,
close,
show,
push,
@@ -302,8 +306,9 @@ export function useDialog() {
}
return {
/** The top dialog that is open. One that has started closing no longer counts, though it waits to be disposed. */
get active() {
return ctx.stack().at(-1)
return ctx.top()
},
/**
* id lets the caller close this dialog later rather than whichever is on top. Opening is deferred; once
+4 -2
View File
@@ -22,10 +22,12 @@ describe("client retry", () => {
hostname: "127.0.0.1",
port: 0,
socket: {
open(socket) {
// Reset only once the request has arrived. Resetting on accept races the client's request
// write, and on Windows a lost race surfaces as a write or connect failure instead of a
// connection reset.
data(socket) {
socket.terminate()
},
data() {},
},
})
const failures: unknown[] = []
+7 -1
View File
@@ -19,7 +19,9 @@ describe("global", () => {
XDG_CACHE_HOME: directories[1],
XDG_CONFIG_HOME: directories[2],
XDG_STATE_HOME: directories[3],
// os.tmpdir() reads TMPDIR on POSIX and TEMP on Windows.
TMPDIR: directories[4],
TEMP: directories[4],
},
stderr: "pipe",
})
@@ -70,14 +72,18 @@ describe("global", () => {
XDG_CACHE_HOME: directories[1],
XDG_CONFIG_HOME: directories[2],
XDG_STATE_HOME: directories[3],
// os.tmpdir() reads TMPDIR on POSIX and TEMP on Windows.
TMPDIR: directories[4],
TEMP: directories[4],
},
stdout: "pipe",
stderr: "pipe",
})
expect(result.exitCode, result.stderr.toString()).toBe(0)
expect(result.stdout.toString()).toBe(fs.realpathSync(path.join(directories[4], "opencode")))
// The layer canonicalizes through the native realpath, which on Windows also expands 8.3 short
// names such as RUNNER~1 that the JavaScript realpathSync leaves in place.
expect(result.stdout.toString()).toBe(fs.realpathSync.native(path.join(directories[4], "opencode")))
const created = [
path.join(directories[0], "opencode"),
path.join(directories[1], "opencode", "bin"),
+2 -1
View File
@@ -7,5 +7,6 @@
"noEmit": false,
"declaration": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
+50
View File
@@ -0,0 +1,50 @@
diff --git a/dist/index.js b/dist/index.js
index c5248382bac4a58b510b48e023e27d14b89c390c..1af906e990bbc637c1100a8e60f8855b93b87687 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -477,6 +477,9 @@ var DrawerRoot = (props) => {
if (_open) {
setTransitionAwareOpen(true);
afterPaint(() => {
+ // A close requested before this frame has already started closing; opening now would slide the drawer in
+ // while it is closed.
+ if (!open()) return;
batch(() => {
setTransitionState("opening");
setActiveSnapPoint(localProps.defaultSnapPoint);
@@ -493,7 +496,9 @@ var DrawerRoot = (props) => {
});
afterPaint(() => {
const transitionDuration = parseFloat(drawerStyles().transitionDuration);
- if (transitionDuration === 0) {
+ // A close that lands before the opening transition has moved the content either starts no transition or
+ // cancels the pending one, so no transitionend arrives to finish it.
+ if (transitionDuration === 0 || !dialogContext().contentRef().getAnimations().length) {
closeDrawer();
}
});
diff --git a/dist/index.jsx b/dist/index.jsx
index fa3ca91ca526e9c11a10819a8bda27dcbb884af7..1c464bcdf757c4a20194d28971c89a179072251b 100644
--- a/dist/index.jsx
+++ b/dist/index.jsx
@@ -567,6 +567,9 @@ var DrawerRoot = (props) => {
if (_open) {
setTransitionAwareOpen(true);
afterPaint(() => {
+ // A close requested before this frame has already started closing; opening now would slide the drawer in
+ // while it is closed.
+ if (!open()) return;
batch2(() => {
setTransitionState("opening");
setActiveSnapPoint(localProps.defaultSnapPoint);
@@ -587,7 +590,9 @@ var DrawerRoot = (props) => {
const transitionDuration = parseFloat(
drawerStyles().transitionDuration
);
- if (transitionDuration === 0) {
+ // A close that lands before the opening transition has moved the content either starts no transition or
+ // cancels the pending one, so no transitionend arrives to finish it.
+ if (transitionDuration === 0 || !dialogContext().contentRef().getAnimations().length) {
closeDrawer();
}
});
+65 -12
View File
@@ -5,7 +5,8 @@
"bunfig.toml",
".npmrc",
".github/actions/setup-bun/**",
".github/workflows/test.yml"
".github/workflows/test.yml",
"package.json"
],
"globalEnv": ["CI", "OPENCODE_DISABLE_SHARE"],
"globalPassThroughEnv": ["CI", "OPENCODE_DISABLE_SHARE"],
@@ -34,22 +35,29 @@
"dependsOn": ["@opencode/core#typecheck"]
},
"build": {
"dependsOn": [],
"dependsOn": ["transit"],
"outputs": ["dist/**"]
},
// Workspace packages export their sources, so tests never wait for a dependency's build; the build job in
// test.yml checks the library builds. No package has a transit script: it runs nothing and only folds every
// workspace dependency into the test hash.
"transit": {
"dependsOn": ["^transit"]
},
"@opencode/ai#test": {
"dependsOn": ["^build"],
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/codemode#test": {
"dependsOn": ["^build"],
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/core#test": {
"dependsOn": ["^build"],
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/function#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/desktop#test": {
@@ -57,34 +65,79 @@
"cache": false
},
"@opencode/httpapi-codegen#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/server#test": {
"dependsOn": ["^build"],
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/cli#test": {
"dependsOn": ["^build"],
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/tui#test": {
"dependsOn": ["^build"],
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/latex#test": {
"dependsOn": ["^build"],
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/app#test": {
"dependsOn": ["^build"],
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/ui#test": {
"dependsOn": ["^build"],
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/session-ui#test": {
"dependsOn": ["^build"],
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/client#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/gui-extensions#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/http-recorder#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/merman#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/plugin#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/plugin-browser#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/script#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/sdk#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/simulation#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/theme#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/util#test": {
"dependsOn": ["transit"],
"outputs": []
},
"@opencode/sdk#verify:package": {