diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 294ade23839..c5d64a87822 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,9 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # Keep every run on dev so cancelled checks do not pollute the default branch + # commit history. PRs and other branches still share a group and cancel stale runs. + group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }} cancel-in-progress: true permissions: diff --git a/.gitignore b/.gitignore index a8bee54501a..20f7711342b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ storybook-static /result refs Session.vim -opencode.json +/opencode.json a.out target .scripts diff --git a/bun.lock b/bun.lock index a3b5041e187..ba8b872ab82 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -80,7 +80,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -113,7 +113,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -329,7 +329,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.1", + "version": "7.2.3", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -457,7 +457,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@kilocode/sdk": "workspace:*", "zod": "catalog:", @@ -471,7 +471,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "semver": "^7.6.3", }, @@ -482,7 +482,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.1", + "version": "7.2.3", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -493,7 +493,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.1", + "version": "7.2.3", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -516,7 +516,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -563,7 +563,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "zod": "catalog:", }, diff --git a/package.json b/package.json index 12f0ffc1c74..4def9dd222d 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", - "test": "echo 'do not run tests from root' && exit 1", - "extension": "bun --cwd packages/kilo-vscode script/launch.ts" + "extension": "bun --cwd packages/kilo-vscode script/launch.ts", + "test": "echo 'do not run tests from root' && exit 1" }, "workspaces": { "packages": [ @@ -123,6 +123,6 @@ "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" }, - "version": "7.2.1", + "version": "7.2.3", "peerDependencies": {} } diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md index cb8080fb252..4b62634f0bf 100644 --- a/packages/app/e2e/AGENTS.md +++ b/packages/app/e2e/AGENTS.md @@ -176,6 +176,25 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings - These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles. - Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks. +### Wait on state + +- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass +- Avoid race-prone flows that assume work is finished after an action +- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers +- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state + +### Add hooks + +- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks +- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts` +- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony +- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI + +### Prefer helpers + +- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise +- Use direct locators when the interaction is simple and a helper would not add clarity + ## Writing New Tests 1. Choose appropriate folder or create new one diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index b9e1246e472..0ea7b7acf9f 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -36,6 +36,22 @@ async function terminalID(term: Locator) { throw new Error(`Active terminal missing ${terminalAttr}`) } +export async function terminalConnects(page: Page, input?: { term?: Locator }) { + const term = input?.term ?? page.locator(terminalSelector).first() + const id = await terminalID(term) + return page.evaluate((id) => { + return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0 + }, id) +} + +export async function disconnectTerminal(page: Page, input?: { term?: Locator }) { + const term = input?.term ?? page.locator(terminalSelector).first() + const id = await terminalID(term) + await page.evaluate((id) => { + ;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.() + }, id) +} + async function terminalReady(page: Page, term?: Locator) { const next = term ?? page.locator(terminalSelector).first() const id = await terminalID(next) @@ -591,12 +607,19 @@ export async function seedSessionTask( .flatMap((message) => message.parts) .find((part) => { if (part.type !== "tool" || part.tool !== "task") return false - if (part.state.input?.description !== input.description) return false - return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0 + if (!("state" in part) || !part.state || typeof part.state !== "object") return false + if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false + if (!("description" in part.state.input) || part.state.input.description !== input.description) return false + if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") + return false + if (!("sessionId" in part.state.metadata)) return false + return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0 }) - if (!part) return - const id = part.state.metadata?.sessionId + if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return + if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return + if (!("sessionId" in part.state.metadata)) return + const id = part.state.metadata.sessionId if (typeof id !== "string" || !id) return const child = await sdk.session .get({ sessionID: id }) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index f81f2c1c509..778d8c5088b 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -95,6 +95,9 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin const win = window as E2EWindow win.__opencode_e2e = { ...win.__opencode_e2e, + model: { + enabled: true, + }, terminal: { enabled: true, terminals: {}, diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 64b7bfe5456..80b6c473d6e 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -13,6 +13,9 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl export const sessionTodoListSelector = '[data-slot="session-todo-list"]' export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' +export const promptAgentSelector = '[data-component="prompt-agent-control"]' +export const promptModelSelector = '[data-component="prompt-model-control"]' +export const promptVariantSelector = '[data-component="prompt-variant-control"]' export const settingsLanguageSelectSelector = '[data-action="settings-language"]' export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]' export const settingsThemeSelector = '[data-action="settings-theme"]' diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index aa39826b714..bb353111389 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -1,12 +1,16 @@ import { test, expect } from "../fixtures" -import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions" +import { + composerEvent, + type ComposerDriverState, + type ComposerProbeState, + type ComposerWindow, +} from "../../src/testing/session-composer" +import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions" import { permissionDockSelector, promptSelector, questionDockSelector, sessionComposerDockSelector, - sessionTodoDockSelector, - sessionTodoListSelector, sessionTodoToggleButtonSelector, } from "../selectors" @@ -42,12 +46,8 @@ async function withDockSeed(sdk: Sdk, sessionID: string, fn: () => Promise async function clearPermissionDock(page: any, label: RegExp) { const dock = page.locator(permissionDockSelector) - for (let i = 0; i < 3; i++) { - const count = await dock.count() - if (count === 0) return - await dock.getByRole("button", { name: label }).click() - await page.waitForTimeout(150) - } + await expect(dock).toBeVisible() + await dock.getByRole("button", { name: label }).click() } async function setAutoAccept(page: any, enabled: boolean) { @@ -59,6 +59,120 @@ async function setAutoAccept(page: any, enabled: boolean) { await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false") } +async function expectQuestionBlocked(page: any) { + await expect(page.locator(questionDockSelector)).toBeVisible() + await expect(page.locator(promptSelector)).toHaveCount(0) +} + +async function expectQuestionOpen(page: any) { + await expect(page.locator(questionDockSelector)).toHaveCount(0) + await expect(page.locator(promptSelector)).toBeVisible() +} + +async function expectPermissionBlocked(page: any) { + await expect(page.locator(permissionDockSelector)).toBeVisible() + await expect(page.locator(promptSelector)).toHaveCount(0) +} + +async function expectPermissionOpen(page: any) { + await expect(page.locator(permissionDockSelector)).toHaveCount(0) + await expect(page.locator(promptSelector)).toBeVisible() +} + +async function todoDock(page: any, sessionID: string) { + await page.addInitScript(() => { + const win = window as ComposerWindow + win.__opencode_e2e = { + ...win.__opencode_e2e, + composer: { + enabled: true, + sessions: {}, + }, + } + }) + + const write = async (driver: ComposerDriverState | undefined) => { + await page.evaluate( + (input) => { + const win = window as ComposerWindow + const composer = win.__opencode_e2e?.composer + if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled") + composer.sessions ??= {} + const prev = composer.sessions[input.sessionID] ?? {} + if (!input.driver) { + if (!prev.probe) { + delete composer.sessions[input.sessionID] + } else { + composer.sessions[input.sessionID] = { probe: prev.probe } + } + } else { + composer.sessions[input.sessionID] = { + ...prev, + driver: input.driver, + } + } + window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } })) + }, + { event: composerEvent, sessionID, driver }, + ) + } + + const read = () => + page.evaluate((sessionID) => { + const win = window as ComposerWindow + return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null + }, sessionID) as Promise + + const api = { + async clear() { + await write(undefined) + return api + }, + async open(todos: NonNullable) { + await write({ live: true, todos }) + return api + }, + async finish(todos: NonNullable) { + await write({ live: false, todos }) + return api + }, + async expectOpen(states: ComposerProbeState["states"]) { + await expect.poll(read, { timeout: 10_000 }).toMatchObject({ + mounted: true, + collapsed: false, + hidden: false, + count: states.length, + states, + }) + return api + }, + async expectCollapsed(states: ComposerProbeState["states"]) { + await expect.poll(read, { timeout: 10_000 }).toMatchObject({ + mounted: true, + collapsed: true, + hidden: true, + count: states.length, + states, + }) + return api + }, + async expectClosed() { + await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false }) + return api + }, + async collapse() { + await page.locator(sessionTodoToggleButtonSelector).click() + return api + }, + async expand() { + await page.locator(sessionTodoToggleButtonSelector).click() + return api + }, + } + + return api +} + async function withMockPermission( page: any, request: { @@ -70,7 +184,7 @@ async function withMockPermission( always?: string[] }, opts: { child?: any } | undefined, - fn: () => Promise, + fn: (state: { resolved: () => Promise }) => Promise, ) { let pending = [ { @@ -119,8 +233,14 @@ async function withMockPermission( if (sessionList) await page.route("**/session?*", sessionList) + const state = { + async resolved() { + await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0) + }, + } + try { - return await fn() + return await fn(state) } finally { await page.unroute("**/permission", list) await page.unroute("**/session/*/permissions/*", reply) @@ -175,14 +295,12 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess }) const dock = page.locator(questionDockSelector) - await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectQuestionBlocked(page) await dock.locator('[data-slot="question-option"]').first().click() await dock.getByRole("button", { name: /submit/i }).click() - await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectQuestionOpen(page) }) }) }) @@ -201,15 +319,14 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess metadata: { description: "Need permission for command" }, }, undefined, - async () => { + async (state) => { await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectPermissionBlocked(page) await clearPermissionDock(page, /allow once/i) + await state.resolved() await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectPermissionOpen(page) }, ) }) @@ -228,15 +345,14 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession patterns: ["/tmp/opencode-e2e-perm-reject"], }, undefined, - async () => { + async (state) => { await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectPermissionBlocked(page) await clearPermissionDock(page, /deny/i) + await state.resolved() await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectPermissionOpen(page) }, ) }) @@ -256,15 +372,14 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe metadata: { description: "Need permission for command" }, }, undefined, - async () => { + async (state) => { await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectPermissionBlocked(page) await clearPermissionDock(page, /allow always/i) + await state.resolved() await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectPermissionOpen(page) }, ) }) @@ -305,14 +420,12 @@ test("child session question request blocks parent dock and unblocks after submi }) const dock = page.locator(questionDockSelector) - await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectQuestionBlocked(page) await dock.locator('[data-slot="question-option"]').first().click() await dock.getByRole("button", { name: /submit/i }).click() - await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectQuestionOpen(page) }) } finally { await cleanupSession({ sdk, sessionID: child.id }) @@ -348,17 +461,15 @@ test("child session permission request blocks parent dock and supports allow onc metadata: { description: "Need child permission" }, }, { child }, - async () => { + async (state) => { await page.goto(page.url()) - const dock = page.locator(permissionDockSelector) - await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectPermissionBlocked(page) await clearPermissionDock(page, /allow once/i) + await state.resolved() await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectPermissionOpen(page) }, ) } finally { @@ -369,36 +480,31 @@ test("child session permission request blocks parent dock and supports allow onc test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => { await withDockSession(sdk, "e2e composer dock todo", async (session) => { - await withDockSeed(sdk, session.id, async () => { - await gotoSession(session.id) + const dock = await todoDock(page, session.id) + await gotoSession(session.id) + await expect(page.locator(sessionComposerDockSelector)).toBeVisible() - await seedSessionTodos(sdk, { - sessionID: session.id, - todos: [ - { content: "first task", status: "pending", priority: "high" }, - { content: "second task", status: "in_progress", priority: "medium" }, - ], - }) + try { + await dock.open([ + { content: "first task", status: "pending", priority: "high" }, + { content: "second task", status: "in_progress", priority: "medium" }, + ]) + await dock.expectOpen(["pending", "in_progress"]) - await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(sessionTodoListSelector)).toBeVisible() + await dock.collapse() + await dock.expectCollapsed(["pending", "in_progress"]) - await page.locator(sessionTodoToggleButtonSelector).click() - await expect(page.locator(sessionTodoListSelector)).toBeHidden() + await dock.expand() + await dock.expectOpen(["pending", "in_progress"]) - await page.locator(sessionTodoToggleButtonSelector).click() - await expect(page.locator(sessionTodoListSelector)).toBeVisible() - - await seedSessionTodos(sdk, { - sessionID: session.id, - todos: [ - { content: "first task", status: "completed", priority: "high" }, - { content: "second task", status: "cancelled", priority: "medium" }, - ], - }) - - await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0) - }) + await dock.finish([ + { content: "first task", status: "completed", priority: "high" }, + { content: "second task", status: "cancelled", priority: "medium" }, + ]) + await dock.expectClosed() + } finally { + await dock.clear() + } }) }) @@ -419,8 +525,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe ], }) - await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectQuestionBlocked(page) await page.locator("main").click({ position: { x: 5, y: 5 } }) await page.keyboard.type("abc") diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts new file mode 100644 index 00000000000..4b09a528734 --- /dev/null +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -0,0 +1,351 @@ +import { base64Decode } from "@opencode-ai/util/encode" +import type { Locator, Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" +import { + promptAgentSelector, + promptModelSelector, + promptSelector, + promptVariantSelector, + workspaceItemSelector, + workspaceNewSessionSelector, +} from "../selectors" +import { createSdk, sessionPath } from "../utils" + +type Footer = { + agent: string + model: string + variant: string +} + +type Probe = { + dir?: string + sessionID?: string + model?: { providerID: string; modelID: string } +} + +const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim() + +const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null) + +const dirKey = (state: Probe | null) => state?.dir ?? "" + +async function probe(page: Page): Promise { + return page.evaluate(() => { + const win = window as Window & { + __opencode_e2e?: { + model?: { + current?: Probe + } + } + } + return win.__opencode_e2e?.model?.current ?? null + }) +} + +async function currentDir(page: Page) { + let hit = "" + await expect + .poll( + async () => { + const next = dirKey(await probe(page)) + if (next) hit = next + return next + }, + { timeout: 30_000 }, + ) + .not.toBe("") + return hit +} + +async function read(page: Page): Promise