mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-20 15:44:44 +00:00
fix(app): stabilize todo dock e2e with composer probe (#17267)
This commit is contained in:
@@ -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.
|
- 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.
|
- 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
|
## Writing New Tests
|
||||||
|
|
||||||
1. Choose appropriate folder or create new one
|
1. Choose appropriate folder or create new one
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { test, expect } from "../fixtures"
|
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 {
|
import {
|
||||||
permissionDockSelector,
|
permissionDockSelector,
|
||||||
promptSelector,
|
promptSelector,
|
||||||
questionDockSelector,
|
questionDockSelector,
|
||||||
sessionComposerDockSelector,
|
sessionComposerDockSelector,
|
||||||
sessionTodoDockSelector,
|
|
||||||
sessionTodoListSelector,
|
|
||||||
sessionTodoToggleButtonSelector,
|
sessionTodoToggleButtonSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
|
|
||||||
@@ -42,12 +41,8 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
|
|||||||
|
|
||||||
async function clearPermissionDock(page: any, label: RegExp) {
|
async function clearPermissionDock(page: any, label: RegExp) {
|
||||||
const dock = page.locator(permissionDockSelector)
|
const dock = page.locator(permissionDockSelector)
|
||||||
for (let i = 0; i < 3; i++) {
|
await expect(dock).toBeVisible()
|
||||||
const count = await dock.count()
|
await dock.getByRole("button", { name: label }).click()
|
||||||
if (count === 0) return
|
|
||||||
await dock.getByRole("button", { name: label }).click()
|
|
||||||
await page.waitForTimeout(150)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setAutoAccept(page: any, enabled: boolean) {
|
async function setAutoAccept(page: any, enabled: boolean) {
|
||||||
@@ -59,6 +54,120 @@ async function setAutoAccept(page: any, enabled: boolean) {
|
|||||||
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
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<ComposerProbeState | null>
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
async clear() {
|
||||||
|
await write(undefined)
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
|
||||||
|
await write({ live: true, todos })
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
|
||||||
|
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<T>(
|
async function withMockPermission<T>(
|
||||||
page: any,
|
page: any,
|
||||||
request: {
|
request: {
|
||||||
@@ -70,7 +179,7 @@ async function withMockPermission<T>(
|
|||||||
always?: string[]
|
always?: string[]
|
||||||
},
|
},
|
||||||
opts: { child?: any } | undefined,
|
opts: { child?: any } | undefined,
|
||||||
fn: () => Promise<T>,
|
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
|
||||||
) {
|
) {
|
||||||
let pending = [
|
let pending = [
|
||||||
{
|
{
|
||||||
@@ -119,8 +228,14 @@ async function withMockPermission<T>(
|
|||||||
|
|
||||||
if (sessionList) await page.route("**/session?*", sessionList)
|
if (sessionList) await page.route("**/session?*", sessionList)
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
async resolved() {
|
||||||
|
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn()
|
return await fn(state)
|
||||||
} finally {
|
} finally {
|
||||||
await page.unroute("**/permission", list)
|
await page.unroute("**/permission", list)
|
||||||
await page.unroute("**/session/*/permissions/*", reply)
|
await page.unroute("**/session/*/permissions/*", reply)
|
||||||
@@ -173,14 +288,12 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
|
|||||||
})
|
})
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
const dock = page.locator(questionDockSelector)
|
||||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
await expectQuestionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectQuestionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -199,15 +312,14 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
|
|||||||
metadata: { description: "Need permission for command" },
|
metadata: { description: "Need permission for command" },
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
async () => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
await expectPermissionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
await clearPermissionDock(page, /allow once/i)
|
||||||
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectPermissionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -226,15 +338,14 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession
|
|||||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
async () => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
await expectPermissionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /deny/i)
|
await clearPermissionDock(page, /deny/i)
|
||||||
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectPermissionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -254,15 +365,14 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe
|
|||||||
metadata: { description: "Need permission for command" },
|
metadata: { description: "Need permission for command" },
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
async () => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
await expectPermissionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow always/i)
|
await clearPermissionDock(page, /allow always/i)
|
||||||
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectPermissionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -301,14 +411,12 @@ test("child session question request blocks parent dock and unblocks after submi
|
|||||||
})
|
})
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
const dock = page.locator(questionDockSelector)
|
||||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
await expectQuestionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectQuestionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupSession({ sdk, sessionID: child.id })
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
@@ -344,17 +452,15 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
metadata: { description: "Need child permission" },
|
metadata: { description: "Need child permission" },
|
||||||
},
|
},
|
||||||
{ child },
|
{ child },
|
||||||
async () => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
const dock = page.locator(permissionDockSelector)
|
await expectPermissionBlocked(page)
|
||||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
await clearPermissionDock(page, /allow once/i)
|
||||||
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
|
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectPermissionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -365,36 +471,31 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
|
|
||||||
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
const dock = await todoDock(page, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
|
|
||||||
await seedSessionTodos(sdk, {
|
try {
|
||||||
sessionID: session.id,
|
await dock.open([
|
||||||
todos: [
|
{ content: "first task", status: "pending", priority: "high" },
|
||||||
{ content: "first task", status: "pending", priority: "high" },
|
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||||
{ 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 dock.collapse()
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
await dock.expectCollapsed(["pending", "in_progress"])
|
||||||
|
|
||||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
await dock.expand()
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
|
await dock.expectOpen(["pending", "in_progress"])
|
||||||
|
|
||||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
await dock.finish([
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
{ content: "first task", status: "completed", priority: "high" },
|
||||||
|
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||||
await seedSessionTodos(sdk, {
|
])
|
||||||
sessionID: session.id,
|
await dock.expectClosed()
|
||||||
todos: [
|
} finally {
|
||||||
{ content: "first task", status: "completed", priority: "high" },
|
await dock.clear()
|
||||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
}
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -414,8 +515,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 expectQuestionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
await page.keyboard.type("abc")
|
await page.keyboard.type("abc")
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ export function SessionComposerRegion(props: {
|
|||||||
}) {
|
}) {
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const { sessionKey } = useSessionKey()
|
const route = useSessionKey()
|
||||||
|
|
||||||
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
|
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
||||||
|
|
||||||
const previewPrompt = () =>
|
const previewPrompt = () =>
|
||||||
prompt
|
prompt
|
||||||
@@ -62,7 +62,7 @@ export function SessionComposerRegion(props: {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!prompt.ready()) return
|
if (!prompt.ready()) return
|
||||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
setSessionHandoff(route.sessionKey(), { prompt: previewPrompt() })
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
@@ -85,7 +85,7 @@ export function SessionComposerRegion(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
sessionKey()
|
route.sessionKey()
|
||||||
const ready = props.ready
|
const ready = props.ready
|
||||||
const delay = 140
|
const delay = 140
|
||||||
|
|
||||||
@@ -194,6 +194,7 @@ export function SessionComposerRegion(props: {
|
|||||||
>
|
>
|
||||||
<div ref={(el) => setStore("body", el)}>
|
<div ref={(el) => setStore("body", el)}>
|
||||||
<SessionTodoDock
|
<SessionTodoDock
|
||||||
|
sessionID={route.params.id}
|
||||||
todos={props.state.todos()}
|
todos={props.state.todos()}
|
||||||
title={language.t("session.todo.title")}
|
title={language.t("session.todo.title")}
|
||||||
collapseLabel={language.t("session.todo.collapse")}
|
collapseLabel={language.t("session.todo.collapse")}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
@@ -8,6 +8,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer"
|
||||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||||
|
|
||||||
export const todoState = (input: {
|
export const todoState = (input: {
|
||||||
@@ -47,7 +48,50 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
return !!permissionRequest() || !!questionRequest()
|
return !!permissionRequest() || !!questionRequest()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [test, setTest] = createStore({
|
||||||
|
on: false,
|
||||||
|
live: undefined as boolean | undefined,
|
||||||
|
todos: undefined as Todo[] | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pull = () => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) {
|
||||||
|
setTest({ on: false, live: undefined, todos: undefined })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = composerDriver(id)
|
||||||
|
if (!next) {
|
||||||
|
setTest({ on: false, live: undefined, todos: undefined })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTest({
|
||||||
|
on: true,
|
||||||
|
live: next.live,
|
||||||
|
todos: next.todos?.map((todo) => ({ ...todo })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!composerEnabled()) return
|
||||||
|
|
||||||
|
pull()
|
||||||
|
createEffect(on(() => params.id, pull, { defer: true }))
|
||||||
|
|
||||||
|
const onEvent = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<{ sessionID?: string }>).detail
|
||||||
|
if (detail?.sessionID !== params.id) return
|
||||||
|
pull()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener(composerEvent, onEvent)
|
||||||
|
onCleanup(() => window.removeEventListener(composerEvent, onEvent))
|
||||||
|
})
|
||||||
|
|
||||||
const todos = createMemo((): Todo[] => {
|
const todos = createMemo((): Todo[] => {
|
||||||
|
if (test.on && test.todos !== undefined) return test.todos
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return []
|
if (!id) return []
|
||||||
return globalSync.data.session_todo[id] ?? []
|
return globalSync.data.session_todo[id] ?? []
|
||||||
@@ -64,7 +108,10 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
const busy = createMemo(() => status().type !== "idle")
|
const busy = createMemo(() => status().type !== "idle")
|
||||||
const live = createMemo(() => busy() || blocked())
|
const live = createMemo(() => {
|
||||||
|
if (test.on && test.live !== undefined) return test.live
|
||||||
|
return busy() || blocked()
|
||||||
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
responding: undefined as string | undefined,
|
responding: undefined as string | undefined,
|
||||||
@@ -116,6 +163,10 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
|
|
||||||
// Keep stale turn todos from reopening if the model never clears them.
|
// Keep stale turn todos from reopening if the model never clears them.
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
|
if (test.on && test.todos !== undefined) {
|
||||||
|
setTest("todos", [])
|
||||||
|
return
|
||||||
|
}
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
globalSync.todo.set(id, [])
|
globalSync.todo.set(id, [])
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
|||||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||||
import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
|
import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { composerEnabled, composerProbe } from "@/testing/session-composer"
|
||||||
|
|
||||||
function dot(status: Todo["status"]) {
|
function dot(status: Todo["status"]) {
|
||||||
if (status !== "in_progress") return undefined
|
if (status !== "in_progress") return undefined
|
||||||
@@ -35,6 +36,7 @@ function dot(status: Todo["status"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SessionTodoDock(props: {
|
export function SessionTodoDock(props: {
|
||||||
|
sessionID?: string
|
||||||
todos: Todo[]
|
todos: Todo[]
|
||||||
title: string
|
title: string
|
||||||
collapseLabel: string
|
collapseLabel: string
|
||||||
@@ -69,6 +71,8 @@ export function SessionTodoDock(props: {
|
|||||||
const off = createMemo(() => hide() > 0.98)
|
const off = createMemo(() => hide() > 0.98)
|
||||||
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
||||||
const full = createMemo(() => Math.max(78, store.height))
|
const full = createMemo(() => Math.max(78, store.height))
|
||||||
|
const e2e = composerEnabled()
|
||||||
|
const probe = composerProbe(props.sessionID)
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -83,6 +87,23 @@ export function SessionTodoDock(props: {
|
|||||||
onCleanup(() => observer.disconnect())
|
onCleanup(() => observer.disconnect())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!e2e) return
|
||||||
|
|
||||||
|
probe.set({
|
||||||
|
mounted: true,
|
||||||
|
collapsed: store.collapsed,
|
||||||
|
hidden: store.collapsed || off(),
|
||||||
|
count: props.todos.length,
|
||||||
|
states: props.todos.map((todo) => todo.status),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (!e2e) return
|
||||||
|
probe.drop()
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DockTray
|
<DockTray
|
||||||
data-component="session-todo-dock"
|
data-component="session-todo-dock"
|
||||||
|
|||||||
84
packages/app/src/testing/session-composer.ts
Normal file
84
packages/app/src/testing/session-composer.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { Todo } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
export const composerEvent = "opencode:e2e:composer"
|
||||||
|
|
||||||
|
export type ComposerDriverState = {
|
||||||
|
live?: boolean
|
||||||
|
todos?: Array<Pick<Todo, "content" | "status" | "priority">>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComposerProbeState = {
|
||||||
|
mounted: boolean
|
||||||
|
collapsed: boolean
|
||||||
|
hidden: boolean
|
||||||
|
count: number
|
||||||
|
states: Todo["status"][]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComposerState = {
|
||||||
|
driver?: ComposerDriverState
|
||||||
|
probe?: ComposerProbeState
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComposerWindow = Window & {
|
||||||
|
__opencode_e2e?: {
|
||||||
|
composer?: {
|
||||||
|
enabled?: boolean
|
||||||
|
sessions?: Record<string, ComposerState>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clone = (driver: ComposerDriverState) => ({
|
||||||
|
live: driver.live,
|
||||||
|
todos: driver.todos?.map((todo) => ({ ...todo })),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const composerEnabled = () => {
|
||||||
|
if (typeof window === "undefined") return false
|
||||||
|
return (window as ComposerWindow).__opencode_e2e?.composer?.enabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = () => {
|
||||||
|
if (!composerEnabled()) return
|
||||||
|
const state = (window as ComposerWindow).__opencode_e2e?.composer
|
||||||
|
if (!state) return
|
||||||
|
state.sessions ??= {}
|
||||||
|
return state.sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
export const composerDriver = (sessionID?: string) => {
|
||||||
|
if (!sessionID) return
|
||||||
|
const state = root()?.[sessionID]?.driver
|
||||||
|
if (!state) return
|
||||||
|
return clone(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const composerProbe = (sessionID?: string) => {
|
||||||
|
const set = (next: ComposerProbeState) => {
|
||||||
|
if (!sessionID) return
|
||||||
|
const sessions = root()
|
||||||
|
if (!sessions) return
|
||||||
|
const prev = sessions[sessionID] ?? {}
|
||||||
|
sessions[sessionID] = {
|
||||||
|
...prev,
|
||||||
|
probe: {
|
||||||
|
...next,
|
||||||
|
states: [...next.states],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
set,
|
||||||
|
drop() {
|
||||||
|
set({
|
||||||
|
mounted: false,
|
||||||
|
collapsed: false,
|
||||||
|
hidden: true,
|
||||||
|
count: 0,
|
||||||
|
states: [],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user