mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-17 01:40:19 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 }}
|
||||
|
||||
+120
-56
@@ -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,14 +96,21 @@ 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
|
||||
# Linux replays Turbo tasks from the runner's remote cache, Windows has none. Weekly keys bound how far the cache
|
||||
# grows and how old a replayed entry can be. Manual runs skip it so they execute every task.
|
||||
- name: Get Turbo cache week
|
||||
if: runner.os == 'Windows'
|
||||
id: turbo-week
|
||||
run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore Turbo cache
|
||||
if: runner.os == 'Windows' && github.event_name != 'workflow_dispatch'
|
||||
id: turbo-cache
|
||||
uses: actions/cache/restore@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 }}-
|
||||
path: .turbo/cache
|
||||
key: turbo-${{ runner.os }}-${{ steps.turbo-week.outputs.week }}-${{ github.sha }}
|
||||
restore-keys: turbo-${{ runner.os }}-${{ steps.turbo-week.outputs.week }}-
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
@@ -116,6 +126,13 @@ jobs:
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Save Turbo cache
|
||||
if: runner.os == 'Windows' && github.event_name == 'push'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: .turbo/cache
|
||||
key: ${{ steps.turbo-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Verify published codemode package
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/codemode
|
||||
@@ -134,32 +151,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 +161,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 +172,92 @@ 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 }}
|
||||
|
||||
- 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
|
||||
|
||||
- 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; the component suites run once per OS in their own leg.
|
||||
suite:
|
||||
- id: app-1
|
||||
shard: 1/2
|
||||
- id: app-2
|
||||
shard: 2/2
|
||||
- id: 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 +265,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: ${{ !cancelled() && !matrix.suite.shard }}
|
||||
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: ${{ !cancelled() && !matrix.suite.shard }}
|
||||
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 +315,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"
|
||||
@@ -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>()
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -35,13 +35,11 @@ export default defineMain({
|
||||
const merged = mergeConfig(config, {
|
||||
plugins: [tailwindcss(), playgroundCss()],
|
||||
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") },
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
+61
-11
@@ -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"],
|
||||
@@ -37,19 +38,23 @@
|
||||
"dependsOn": [],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"transit": {
|
||||
"dependsOn": ["^transit"]
|
||||
},
|
||||
"@opencode/ai#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/codemode#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/core#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/function#test": {
|
||||
"dependsOn": ["transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/desktop#test": {
|
||||
@@ -57,34 +62,79 @@
|
||||
"cache": false
|
||||
},
|
||||
"@opencode/httpapi-codegen#test": {
|
||||
"dependsOn": ["transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/server#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/cli#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/tui#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/latex#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/app#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/ui#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/session-ui#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/client#test": {
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/gui-extensions#test": {
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/http-recorder#test": {
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/merman#test": {
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/plugin#test": {
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/plugin-browser#test": {
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/script#test": {
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/sdk#test": {
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/simulation#test": {
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/theme#test": {
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/util#test": {
|
||||
"dependsOn": ["^build", "transit"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode/sdk#verify:package": {
|
||||
|
||||
Reference in new issue
Block a user