mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-16 11:20:42 +00:00
Compare commits
24
Commits
dev
...
core-test-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4243dbc8c | ||
|
|
d471d2c231 | ||
|
|
ce3389197a | ||
|
|
c543d18eb7 | ||
|
|
be1d4b6925 | ||
|
|
da9d49a44f | ||
|
|
2e873bc40b | ||
|
|
4f0ee179fa | ||
|
|
0cd2e829af | ||
|
|
9f5ce4674a | ||
|
|
2573244805 | ||
|
|
dbf5e655df | ||
|
|
78558202c0 | ||
|
|
25facb9dfe | ||
|
|
4003f64688 | ||
|
|
7dbe56078b | ||
|
|
bc65cbe961 | ||
|
|
56782638cb | ||
|
|
9fe5766435 | ||
|
|
77b8f1dfc7 | ||
|
|
3a0a231d84 | ||
|
|
5c4079a926 | ||
|
|
e004ec3f57 | ||
|
|
c3ceabf01c |
No files matched your search
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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("*/*")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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")) } }
|
||||
}),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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" } })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"] } }],
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"noEmit": false,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user