reigncode-app 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +30 -0
- package/Dockerfile +21 -0
- package/README.md +51 -0
- package/bunfig.toml +3 -0
- package/create-effect-simplification-spec.md +515 -0
- package/e2e/AGENTS.md +226 -0
- package/e2e/actions.ts +1018 -0
- package/e2e/app/home.spec.ts +24 -0
- package/e2e/app/navigation.spec.ts +10 -0
- package/e2e/app/palette.spec.ts +20 -0
- package/e2e/app/server-default.spec.ts +58 -0
- package/e2e/app/session.spec.ts +16 -0
- package/e2e/app/titlebar-history.spec.ts +120 -0
- package/e2e/commands/input-focus.spec.ts +15 -0
- package/e2e/commands/panels.spec.ts +33 -0
- package/e2e/commands/tab-close.spec.ts +32 -0
- package/e2e/files/file-open.spec.ts +31 -0
- package/e2e/files/file-tree.spec.ts +56 -0
- package/e2e/files/file-viewer.spec.ts +156 -0
- package/e2e/fixtures.ts +154 -0
- package/e2e/models/model-picker.spec.ts +48 -0
- package/e2e/models/models-visibility.spec.ts +61 -0
- package/e2e/projects/project-edit.spec.ts +43 -0
- package/e2e/projects/projects-close.spec.ts +54 -0
- package/e2e/projects/projects-switch.spec.ts +116 -0
- package/e2e/projects/workspace-new-session.spec.ts +94 -0
- package/e2e/projects/workspaces.spec.ts +375 -0
- package/e2e/prompt/context.spec.ts +95 -0
- package/e2e/prompt/prompt-async.spec.ts +76 -0
- package/e2e/prompt/prompt-drop-file-uri.spec.ts +22 -0
- package/e2e/prompt/prompt-drop-file.spec.ts +30 -0
- package/e2e/prompt/prompt-history.spec.ts +184 -0
- package/e2e/prompt/prompt-mention.spec.ts +26 -0
- package/e2e/prompt/prompt-multiline.spec.ts +24 -0
- package/e2e/prompt/prompt-shell.spec.ts +62 -0
- package/e2e/prompt/prompt-slash-open.spec.ts +22 -0
- package/e2e/prompt/prompt-slash-share.spec.ts +64 -0
- package/e2e/prompt/prompt-slash-terminal.spec.ts +18 -0
- package/e2e/prompt/prompt.spec.ts +55 -0
- package/e2e/selectors.ts +75 -0
- package/e2e/session/session-child-navigation.spec.ts +37 -0
- package/e2e/session/session-composer-dock.spec.ts +530 -0
- package/e2e/session/session-model-persistence.spec.ts +359 -0
- package/e2e/session/session-review.spec.ts +426 -0
- package/e2e/session/session-undo-redo.spec.ts +233 -0
- package/e2e/session/session.spec.ts +174 -0
- package/e2e/settings/settings-keybinds.spec.ts +389 -0
- package/e2e/settings/settings-models.spec.ts +122 -0
- package/e2e/settings/settings-providers.spec.ts +136 -0
- package/e2e/settings/settings.spec.ts +519 -0
- package/e2e/sidebar/sidebar-popover-actions.spec.ts +118 -0
- package/e2e/sidebar/sidebar-session-links.spec.ts +30 -0
- package/e2e/sidebar/sidebar.spec.ts +40 -0
- package/e2e/status/status-popover.spec.ts +94 -0
- package/e2e/terminal/terminal-init.spec.ts +28 -0
- package/e2e/terminal/terminal-reconnect.spec.ts +46 -0
- package/e2e/terminal/terminal-tabs.spec.ts +168 -0
- package/e2e/terminal/terminal.spec.ts +18 -0
- package/e2e/thinking-level.spec.ts +25 -0
- package/e2e/tsconfig.json +9 -0
- package/e2e/utils.ts +63 -0
- package/happydom.ts +75 -0
- package/index.html +23 -0
- package/package.json +77 -0
- package/playwright.config.ts +45 -0
- package/public/_headers +17 -0
- package/public/oc-theme-preload.js +35 -0
- package/script/e2e-local.ts +180 -0
- package/src/addons/serialize.test.ts +319 -0
- package/src/addons/serialize.ts +634 -0
- package/src/app.tsx +308 -0
- package/src/components/debug-bar.tsx +443 -0
- package/src/components/dialog-connect-provider.tsx +617 -0
- package/src/components/dialog-custom-provider-form.ts +158 -0
- package/src/components/dialog-custom-provider.test.ts +80 -0
- package/src/components/dialog-custom-provider.tsx +329 -0
- package/src/components/dialog-edit-project.tsx +255 -0
- package/src/components/dialog-fork.tsx +108 -0
- package/src/components/dialog-manage-models.tsx +101 -0
- package/src/components/dialog-release-notes.tsx +144 -0
- package/src/components/dialog-select-directory.tsx +392 -0
- package/src/components/dialog-select-file.tsx +466 -0
- package/src/components/dialog-select-mcp.tsx +107 -0
- package/src/components/dialog-select-model-unpaid.tsx +137 -0
- package/src/components/dialog-select-model.tsx +220 -0
- package/src/components/dialog-select-provider.tsx +86 -0
- package/src/components/dialog-select-server.tsx +649 -0
- package/src/components/dialog-settings.tsx +73 -0
- package/src/components/file-tree.test.ts +78 -0
- package/src/components/file-tree.tsx +507 -0
- package/src/components/link.tsx +26 -0
- package/src/components/model-tooltip.tsx +91 -0
- package/src/components/prompt-input/attachments.test.ts +44 -0
- package/src/components/prompt-input/attachments.ts +201 -0
- package/src/components/prompt-input/build-request-parts.test.ts +312 -0
- package/src/components/prompt-input/build-request-parts.ts +175 -0
- package/src/components/prompt-input/context-items.tsx +88 -0
- package/src/components/prompt-input/drag-overlay.tsx +25 -0
- package/src/components/prompt-input/editor-dom.test.ts +99 -0
- package/src/components/prompt-input/editor-dom.ts +148 -0
- package/src/components/prompt-input/files.ts +66 -0
- package/src/components/prompt-input/history.test.ts +153 -0
- package/src/components/prompt-input/history.ts +256 -0
- package/src/components/prompt-input/image-attachments.tsx +58 -0
- package/src/components/prompt-input/paste.ts +24 -0
- package/src/components/prompt-input/placeholder.test.ts +48 -0
- package/src/components/prompt-input/placeholder.ts +15 -0
- package/src/components/prompt-input/slash-popover.tsx +141 -0
- package/src/components/prompt-input/submit.test.ts +346 -0
- package/src/components/prompt-input/submit.ts +579 -0
- package/src/components/prompt-input.tsx +1595 -0
- package/src/components/server/server-row.tsx +130 -0
- package/src/components/session/index.ts +5 -0
- package/src/components/session/session-context-breakdown.test.ts +61 -0
- package/src/components/session/session-context-breakdown.ts +132 -0
- package/src/components/session/session-context-format.ts +20 -0
- package/src/components/session/session-context-metrics.test.ts +101 -0
- package/src/components/session/session-context-metrics.ts +82 -0
- package/src/components/session/session-context-tab.tsx +339 -0
- package/src/components/session/session-header.tsx +486 -0
- package/src/components/session/session-new-view.tsx +91 -0
- package/src/components/session/session-sortable-tab.tsx +70 -0
- package/src/components/session/session-sortable-terminal-tab.tsx +193 -0
- package/src/components/session-context-usage.tsx +122 -0
- package/src/components/settings-general.tsx +585 -0
- package/src/components/settings-keybinds.tsx +453 -0
- package/src/components/settings-list.tsx +5 -0
- package/src/components/settings-models.tsx +137 -0
- package/src/components/settings-providers.tsx +251 -0
- package/src/components/status-popover.tsx +419 -0
- package/src/components/terminal.tsx +653 -0
- package/src/components/titlebar-history.test.ts +63 -0
- package/src/components/titlebar-history.ts +57 -0
- package/src/components/titlebar.tsx +312 -0
- package/src/constants/file-picker.ts +89 -0
- package/src/context/command-keybind.test.ts +69 -0
- package/src/context/command.test.ts +25 -0
- package/src/context/command.tsx +437 -0
- package/src/context/comments.test.ts +186 -0
- package/src/context/comments.tsx +243 -0
- package/src/context/file/content-cache.ts +88 -0
- package/src/context/file/path.test.ts +360 -0
- package/src/context/file/path.ts +151 -0
- package/src/context/file/tree-store.ts +170 -0
- package/src/context/file/types.ts +41 -0
- package/src/context/file/view-cache.ts +146 -0
- package/src/context/file/watcher.test.ts +149 -0
- package/src/context/file/watcher.ts +53 -0
- package/src/context/file-content-eviction-accounting.test.ts +65 -0
- package/src/context/file.tsx +280 -0
- package/src/context/global-sdk.tsx +232 -0
- package/src/context/global-sync/bootstrap.ts +206 -0
- package/src/context/global-sync/child-store.test.ts +38 -0
- package/src/context/global-sync/child-store.ts +281 -0
- package/src/context/global-sync/event-reducer.test.ts +552 -0
- package/src/context/global-sync/event-reducer.ts +359 -0
- package/src/context/global-sync/eviction.ts +28 -0
- package/src/context/global-sync/queue.ts +83 -0
- package/src/context/global-sync/session-cache.test.ts +102 -0
- package/src/context/global-sync/session-cache.ts +62 -0
- package/src/context/global-sync/session-load.ts +25 -0
- package/src/context/global-sync/session-prefetch.test.ts +96 -0
- package/src/context/global-sync/session-prefetch.ts +100 -0
- package/src/context/global-sync/session-trim.test.ts +59 -0
- package/src/context/global-sync/session-trim.ts +56 -0
- package/src/context/global-sync/types.ts +133 -0
- package/src/context/global-sync/utils.ts +25 -0
- package/src/context/global-sync.test.ts +122 -0
- package/src/context/global-sync.tsx +408 -0
- package/src/context/highlights.tsx +233 -0
- package/src/context/language.tsx +248 -0
- package/src/context/layout-scroll.test.ts +64 -0
- package/src/context/layout-scroll.ts +126 -0
- package/src/context/layout.test.ts +69 -0
- package/src/context/layout.tsx +937 -0
- package/src/context/local.tsx +422 -0
- package/src/context/model-variant.test.ts +86 -0
- package/src/context/model-variant.ts +52 -0
- package/src/context/models.tsx +163 -0
- package/src/context/notification.tsx +373 -0
- package/src/context/permission-auto-respond.test.ts +102 -0
- package/src/context/permission-auto-respond.ts +51 -0
- package/src/context/permission.tsx +277 -0
- package/src/context/platform.tsx +99 -0
- package/src/context/prompt.tsx +297 -0
- package/src/context/sdk.tsx +49 -0
- package/src/context/server.tsx +295 -0
- package/src/context/settings.tsx +241 -0
- package/src/context/sync-optimistic.test.ts +123 -0
- package/src/context/sync.tsx +618 -0
- package/src/context/terminal-title.ts +51 -0
- package/src/context/terminal.test.ts +82 -0
- package/src/context/terminal.tsx +437 -0
- package/src/entry.tsx +144 -0
- package/src/env.d.ts +18 -0
- package/src/hooks/use-providers.ts +44 -0
- package/src/i18n/ar.ts +855 -0
- package/src/i18n/br.ts +867 -0
- package/src/i18n/bs.ts +943 -0
- package/src/i18n/da.ts +937 -0
- package/src/i18n/de.ts +879 -0
- package/src/i18n/en.ts +948 -0
- package/src/i18n/es.ts +950 -0
- package/src/i18n/fr.ts +878 -0
- package/src/i18n/ja.ts +861 -0
- package/src/i18n/ko.ts +860 -0
- package/src/i18n/no.ts +944 -0
- package/src/i18n/parity.test.ts +32 -0
- package/src/i18n/pl.ts +865 -0
- package/src/i18n/ru.ts +946 -0
- package/src/i18n/th.ts +933 -0
- package/src/i18n/tr.ts +952 -0
- package/src/i18n/zh.ts +930 -0
- package/src/i18n/zht.ts +925 -0
- package/src/index.css +29 -0
- package/src/index.ts +6 -0
- package/src/pages/directory-layout.tsx +88 -0
- package/src/pages/error.tsx +327 -0
- package/src/pages/home.tsx +131 -0
- package/src/pages/layout/deep-links.ts +50 -0
- package/src/pages/layout/helpers.test.ts +211 -0
- package/src/pages/layout/helpers.ts +98 -0
- package/src/pages/layout/inline-editor.tsx +126 -0
- package/src/pages/layout/sidebar-items.tsx +437 -0
- package/src/pages/layout/sidebar-project.tsx +384 -0
- package/src/pages/layout/sidebar-shell.tsx +125 -0
- package/src/pages/layout/sidebar-workspace.tsx +504 -0
- package/src/pages/layout.tsx +2509 -0
- package/src/pages/session/composer/index.ts +2 -0
- package/src/pages/session/composer/session-composer-region.tsx +255 -0
- package/src/pages/session/composer/session-composer-state.test.ts +128 -0
- package/src/pages/session/composer/session-composer-state.ts +249 -0
- package/src/pages/session/composer/session-followup-dock.tsx +109 -0
- package/src/pages/session/composer/session-permission-dock.tsx +74 -0
- package/src/pages/session/composer/session-question-dock.tsx +449 -0
- package/src/pages/session/composer/session-request-tree.ts +52 -0
- package/src/pages/session/composer/session-revert-dock.tsx +99 -0
- package/src/pages/session/composer/session-todo-dock.tsx +330 -0
- package/src/pages/session/file-tab-scroll.test.ts +40 -0
- package/src/pages/session/file-tab-scroll.ts +67 -0
- package/src/pages/session/file-tabs.tsx +456 -0
- package/src/pages/session/handoff.ts +36 -0
- package/src/pages/session/helpers.test.ts +181 -0
- package/src/pages/session/helpers.ts +198 -0
- package/src/pages/session/message-gesture.test.ts +62 -0
- package/src/pages/session/message-gesture.ts +21 -0
- package/src/pages/session/message-id-from-hash.ts +6 -0
- package/src/pages/session/message-timeline.tsx +1013 -0
- package/src/pages/session/review-tab.tsx +170 -0
- package/src/pages/session/session-layout.ts +20 -0
- package/src/pages/session/session-model-helpers.test.ts +51 -0
- package/src/pages/session/session-model-helpers.ts +16 -0
- package/src/pages/session/session-side-panel.tsx +453 -0
- package/src/pages/session/terminal-label.ts +16 -0
- package/src/pages/session/terminal-panel.test.ts +25 -0
- package/src/pages/session/terminal-panel.tsx +326 -0
- package/src/pages/session/use-session-commands.tsx +495 -0
- package/src/pages/session/use-session-hash-scroll.test.ts +16 -0
- package/src/pages/session/use-session-hash-scroll.ts +197 -0
- package/src/pages/session.tsx +1841 -0
- package/src/sst-env.d.ts +12 -0
- package/src/testing/model-selection.ts +80 -0
- package/src/testing/prompt.ts +56 -0
- package/src/testing/session-composer.ts +84 -0
- package/src/testing/terminal.ts +118 -0
- package/src/theme-preload.test.ts +46 -0
- package/src/utils/agent.ts +23 -0
- package/src/utils/aim.ts +138 -0
- package/src/utils/base64.ts +10 -0
- package/src/utils/comment-note.ts +88 -0
- package/src/utils/id.ts +99 -0
- package/src/utils/notification-click.test.ts +27 -0
- package/src/utils/notification-click.ts +13 -0
- package/src/utils/persist.test.ts +115 -0
- package/src/utils/persist.ts +476 -0
- package/src/utils/prompt.test.ts +44 -0
- package/src/utils/prompt.ts +203 -0
- package/src/utils/runtime-adapters.test.ts +62 -0
- package/src/utils/runtime-adapters.ts +39 -0
- package/src/utils/same.ts +6 -0
- package/src/utils/scoped-cache.test.ts +69 -0
- package/src/utils/scoped-cache.ts +104 -0
- package/src/utils/server-errors.test.ts +131 -0
- package/src/utils/server-errors.ts +80 -0
- package/src/utils/server-health.test.ts +123 -0
- package/src/utils/server-health.ts +91 -0
- package/src/utils/server.ts +22 -0
- package/src/utils/solid-dnd.tsx +49 -0
- package/src/utils/sound.ts +117 -0
- package/src/utils/terminal-writer.test.ts +64 -0
- package/src/utils/terminal-writer.ts +65 -0
- package/src/utils/time.ts +22 -0
- package/src/utils/uuid.test.ts +78 -0
- package/src/utils/uuid.ts +12 -0
- package/src/utils/worktree.test.ts +46 -0
- package/src/utils/worktree.ts +73 -0
- package/sst-env.d.ts +10 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +15 -0
- package/vite.js +26 -0
package/e2e/actions.ts
ADDED
|
@@ -0,0 +1,1018 @@
|
|
|
1
|
+
import { base64Decode, base64Encode } from "@reign-labs/util/encode"
|
|
2
|
+
import { expect, type Locator, type Page } from "@playwright/test"
|
|
3
|
+
import fs from "node:fs/promises"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
6
|
+
import { execSync } from "node:child_process"
|
|
7
|
+
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
|
8
|
+
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
|
9
|
+
import {
|
|
10
|
+
dropdownMenuTriggerSelector,
|
|
11
|
+
dropdownMenuContentSelector,
|
|
12
|
+
projectSwitchSelector,
|
|
13
|
+
projectMenuTriggerSelector,
|
|
14
|
+
projectCloseMenuSelector,
|
|
15
|
+
projectWorkspacesToggleSelector,
|
|
16
|
+
titlebarRightSelector,
|
|
17
|
+
popoverBodySelector,
|
|
18
|
+
listItemSelector,
|
|
19
|
+
listItemKeySelector,
|
|
20
|
+
listItemKeyStartsWithSelector,
|
|
21
|
+
promptSelector,
|
|
22
|
+
terminalSelector,
|
|
23
|
+
workspaceItemSelector,
|
|
24
|
+
workspaceMenuTriggerSelector,
|
|
25
|
+
} from "./selectors"
|
|
26
|
+
|
|
27
|
+
const phase = new WeakMap<Page, "test" | "cleanup">()
|
|
28
|
+
|
|
29
|
+
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
|
|
30
|
+
phase.set(page, value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function healthPhase(page: Page) {
|
|
34
|
+
return phase.get(page) ?? "test"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function defocus(page: Page) {
|
|
38
|
+
await page
|
|
39
|
+
.evaluate(() => {
|
|
40
|
+
const el = document.activeElement
|
|
41
|
+
if (el instanceof HTMLElement) el.blur()
|
|
42
|
+
})
|
|
43
|
+
.catch(() => undefined)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function terminalID(term: Locator) {
|
|
47
|
+
const id = await term.getAttribute(terminalAttr)
|
|
48
|
+
if (id) return id
|
|
49
|
+
throw new Error(`Active terminal missing ${terminalAttr}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
|
|
53
|
+
const term = input?.term ?? page.locator(terminalSelector).first()
|
|
54
|
+
const id = await terminalID(term)
|
|
55
|
+
return page.evaluate((id) => {
|
|
56
|
+
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
|
|
57
|
+
}, id)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
|
|
61
|
+
const term = input?.term ?? page.locator(terminalSelector).first()
|
|
62
|
+
const id = await terminalID(term)
|
|
63
|
+
await page.evaluate((id) => {
|
|
64
|
+
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
|
|
65
|
+
}, id)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function terminalReady(page: Page, term?: Locator) {
|
|
69
|
+
const next = term ?? page.locator(terminalSelector).first()
|
|
70
|
+
const id = await terminalID(next)
|
|
71
|
+
return page.evaluate((id) => {
|
|
72
|
+
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
|
73
|
+
return !!state?.connected && (state.settled ?? 0) > 0
|
|
74
|
+
}, id)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function terminalFocusIdle(page: Page, term?: Locator) {
|
|
78
|
+
const next = term ?? page.locator(terminalSelector).first()
|
|
79
|
+
const id = await terminalID(next)
|
|
80
|
+
return page.evaluate((id) => {
|
|
81
|
+
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
|
82
|
+
return (state?.focusing ?? 0) === 0
|
|
83
|
+
}, id)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
|
87
|
+
const next = input.term ?? page.locator(terminalSelector).first()
|
|
88
|
+
const id = await terminalID(next)
|
|
89
|
+
return page.evaluate(
|
|
90
|
+
(input) => {
|
|
91
|
+
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
|
|
92
|
+
return state?.rendered.includes(input.token) ?? false
|
|
93
|
+
},
|
|
94
|
+
{ id, token: input.token },
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function promptSlashActive(page: Page, id: string) {
|
|
99
|
+
return page.evaluate((id) => {
|
|
100
|
+
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
|
101
|
+
if (state?.popover !== "slash") return false
|
|
102
|
+
if (!state.slash.ids.includes(id)) return false
|
|
103
|
+
return state.slash.active === id
|
|
104
|
+
}, id)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function promptSlashSelects(page: Page) {
|
|
108
|
+
return page.evaluate(() => {
|
|
109
|
+
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
|
|
114
|
+
return page.evaluate((input) => {
|
|
115
|
+
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
|
116
|
+
if (!state) return false
|
|
117
|
+
return state.selected === input.id && state.selects >= input.count
|
|
118
|
+
}, input)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
|
122
|
+
const term = input?.term ?? page.locator(terminalSelector).first()
|
|
123
|
+
const timeout = input?.timeout ?? 10_000
|
|
124
|
+
await expect(term).toBeVisible()
|
|
125
|
+
await expect(term.locator("textarea")).toHaveCount(1)
|
|
126
|
+
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
|
|
130
|
+
const term = input?.term ?? page.locator(terminalSelector).first()
|
|
131
|
+
const timeout = input?.timeout ?? 10_000
|
|
132
|
+
await waitTerminalReady(page, { term, timeout })
|
|
133
|
+
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function showPromptSlash(
|
|
137
|
+
page: Page,
|
|
138
|
+
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
|
139
|
+
) {
|
|
140
|
+
const prompt = input.prompt ?? page.locator(promptSelector)
|
|
141
|
+
const timeout = input.timeout ?? 10_000
|
|
142
|
+
await expect
|
|
143
|
+
.poll(
|
|
144
|
+
async () => {
|
|
145
|
+
await prompt.click().catch(() => false)
|
|
146
|
+
await prompt.fill(input.text).catch(() => false)
|
|
147
|
+
return promptSlashActive(page, input.id).catch(() => false)
|
|
148
|
+
},
|
|
149
|
+
{ timeout },
|
|
150
|
+
)
|
|
151
|
+
.toBe(true)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function runPromptSlash(
|
|
155
|
+
page: Page,
|
|
156
|
+
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
|
157
|
+
) {
|
|
158
|
+
const prompt = input.prompt ?? page.locator(promptSelector)
|
|
159
|
+
const timeout = input.timeout ?? 10_000
|
|
160
|
+
const count = await promptSlashSelects(page)
|
|
161
|
+
await showPromptSlash(page, input)
|
|
162
|
+
await prompt.press("Enter")
|
|
163
|
+
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
|
167
|
+
const term = input.term ?? page.locator(terminalSelector).first()
|
|
168
|
+
const timeout = input.timeout ?? 10_000
|
|
169
|
+
await waitTerminalReady(page, { term, timeout })
|
|
170
|
+
const textarea = term.locator("textarea")
|
|
171
|
+
await term.click()
|
|
172
|
+
await expect(textarea).toBeFocused()
|
|
173
|
+
await page.keyboard.type(input.cmd)
|
|
174
|
+
await page.keyboard.press("Enter")
|
|
175
|
+
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function openPalette(page: Page, key = "K") {
|
|
179
|
+
await defocus(page)
|
|
180
|
+
await page.keyboard.press(`${modKey}+${key}`)
|
|
181
|
+
|
|
182
|
+
const dialog = page.getByRole("dialog")
|
|
183
|
+
await expect(dialog).toBeVisible()
|
|
184
|
+
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
|
185
|
+
return dialog
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function closeDialog(page: Page, dialog: Locator) {
|
|
189
|
+
await page.keyboard.press("Escape")
|
|
190
|
+
const closed = await dialog
|
|
191
|
+
.waitFor({ state: "detached", timeout: 1500 })
|
|
192
|
+
.then(() => true)
|
|
193
|
+
.catch(() => false)
|
|
194
|
+
|
|
195
|
+
if (closed) return
|
|
196
|
+
|
|
197
|
+
await page.keyboard.press("Escape")
|
|
198
|
+
const closedSecond = await dialog
|
|
199
|
+
.waitFor({ state: "detached", timeout: 1500 })
|
|
200
|
+
.then(() => true)
|
|
201
|
+
.catch(() => false)
|
|
202
|
+
|
|
203
|
+
if (closedSecond) return
|
|
204
|
+
|
|
205
|
+
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
|
206
|
+
await expect(dialog).toHaveCount(0)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function isSidebarClosed(page: Page) {
|
|
210
|
+
const button = await waitSidebarButton(page, "isSidebarClosed")
|
|
211
|
+
return (await button.getAttribute("aria-expanded")) !== "true"
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function errorBoundaryText(page: Page) {
|
|
215
|
+
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
|
|
216
|
+
if (!(await title.isVisible().catch(() => false))) return
|
|
217
|
+
|
|
218
|
+
const description = await page
|
|
219
|
+
.getByText(/an error occurred while loading the application\./i)
|
|
220
|
+
.first()
|
|
221
|
+
.textContent()
|
|
222
|
+
.catch(() => "")
|
|
223
|
+
const detail = await page
|
|
224
|
+
.getByRole("textbox", { name: /error details/i })
|
|
225
|
+
.first()
|
|
226
|
+
.inputValue()
|
|
227
|
+
.catch(async () =>
|
|
228
|
+
(
|
|
229
|
+
(await page
|
|
230
|
+
.getByRole("textbox", { name: /error details/i })
|
|
231
|
+
.first()
|
|
232
|
+
.textContent()
|
|
233
|
+
.catch(() => "")) ?? ""
|
|
234
|
+
).trim(),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function assertHealthy(page: Page, context: string) {
|
|
241
|
+
const text = await errorBoundaryText(page)
|
|
242
|
+
if (!text) return
|
|
243
|
+
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
|
244
|
+
throw new Error(`Error boundary during ${context}\n${text}`)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function waitSidebarButton(page: Page, context: string) {
|
|
248
|
+
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
|
249
|
+
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
|
|
250
|
+
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
|
|
251
|
+
await assertHealthy(page, context)
|
|
252
|
+
return button
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function toggleSidebar(page: Page) {
|
|
256
|
+
await defocus(page)
|
|
257
|
+
await page.keyboard.press(`${modKey}+B`)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function openSidebar(page: Page) {
|
|
261
|
+
if (!(await isSidebarClosed(page))) return
|
|
262
|
+
|
|
263
|
+
const button = await waitSidebarButton(page, "openSidebar")
|
|
264
|
+
await button.click()
|
|
265
|
+
|
|
266
|
+
const opened = await expect(button)
|
|
267
|
+
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
|
|
268
|
+
.then(() => true)
|
|
269
|
+
.catch(() => false)
|
|
270
|
+
|
|
271
|
+
if (opened) return
|
|
272
|
+
|
|
273
|
+
await toggleSidebar(page)
|
|
274
|
+
await expect(button).toHaveAttribute("aria-expanded", "true")
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function closeSidebar(page: Page) {
|
|
278
|
+
if (await isSidebarClosed(page)) return
|
|
279
|
+
|
|
280
|
+
const button = await waitSidebarButton(page, "closeSidebar")
|
|
281
|
+
await button.click()
|
|
282
|
+
|
|
283
|
+
const closed = await expect(button)
|
|
284
|
+
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
|
|
285
|
+
.then(() => true)
|
|
286
|
+
.catch(() => false)
|
|
287
|
+
|
|
288
|
+
if (closed) return
|
|
289
|
+
|
|
290
|
+
await toggleSidebar(page)
|
|
291
|
+
await expect(button).toHaveAttribute("aria-expanded", "false")
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function openSettings(page: Page) {
|
|
295
|
+
await assertHealthy(page, "openSettings")
|
|
296
|
+
await defocus(page)
|
|
297
|
+
|
|
298
|
+
const dialog = page.getByRole("dialog")
|
|
299
|
+
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
|
300
|
+
|
|
301
|
+
const opened = await dialog
|
|
302
|
+
.waitFor({ state: "visible", timeout: 3000 })
|
|
303
|
+
.then(() => true)
|
|
304
|
+
.catch(() => false)
|
|
305
|
+
|
|
306
|
+
if (opened) return dialog
|
|
307
|
+
|
|
308
|
+
await assertHealthy(page, "openSettings")
|
|
309
|
+
|
|
310
|
+
await page.getByRole("button", { name: "Settings" }).first().click()
|
|
311
|
+
await expect(dialog).toBeVisible()
|
|
312
|
+
return dialog
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
|
316
|
+
await page.addInitScript(
|
|
317
|
+
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
|
318
|
+
const key = "opencode.global.dat:server"
|
|
319
|
+
const raw = localStorage.getItem(key)
|
|
320
|
+
const parsed = (() => {
|
|
321
|
+
if (!raw) return undefined
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(raw) as unknown
|
|
324
|
+
} catch {
|
|
325
|
+
return undefined
|
|
326
|
+
}
|
|
327
|
+
})()
|
|
328
|
+
|
|
329
|
+
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
|
330
|
+
const list = Array.isArray(store.list) ? store.list : []
|
|
331
|
+
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
|
332
|
+
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
|
333
|
+
const nextProjects = { ...(projects as Record<string, unknown>) }
|
|
334
|
+
|
|
335
|
+
const add = (origin: string, directory: string) => {
|
|
336
|
+
const current = nextProjects[origin]
|
|
337
|
+
const items = Array.isArray(current) ? current : []
|
|
338
|
+
const existing = items.filter(
|
|
339
|
+
(p): p is { worktree: string; expanded?: boolean } =>
|
|
340
|
+
!!p &&
|
|
341
|
+
typeof p === "object" &&
|
|
342
|
+
"worktree" in p &&
|
|
343
|
+
typeof (p as { worktree?: unknown }).worktree === "string",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if (existing.some((p) => p.worktree === directory)) return
|
|
347
|
+
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const directories = [args.directory, ...args.extra]
|
|
351
|
+
for (const directory of directories) {
|
|
352
|
+
add("local", directory)
|
|
353
|
+
add(args.serverUrl, directory)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
localStorage.setItem(
|
|
357
|
+
key,
|
|
358
|
+
JSON.stringify({
|
|
359
|
+
list,
|
|
360
|
+
projects: nextProjects,
|
|
361
|
+
lastProject,
|
|
362
|
+
}),
|
|
363
|
+
)
|
|
364
|
+
},
|
|
365
|
+
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export async function createTestProject() {
|
|
370
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
|
371
|
+
const id = `e2e-${path.basename(root)}`
|
|
372
|
+
|
|
373
|
+
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
|
|
374
|
+
|
|
375
|
+
execSync("git init", { cwd: root, stdio: "ignore" })
|
|
376
|
+
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
|
377
|
+
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
|
378
|
+
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
|
379
|
+
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
|
380
|
+
cwd: root,
|
|
381
|
+
stdio: "ignore",
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
return resolveDirectory(root)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function cleanupTestProject(directory: string) {
|
|
388
|
+
try {
|
|
389
|
+
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
|
|
390
|
+
} catch {}
|
|
391
|
+
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function slugFromUrl(url: string) {
|
|
395
|
+
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function probeSession(page: Page) {
|
|
399
|
+
return page
|
|
400
|
+
.evaluate(() => {
|
|
401
|
+
const win = window as E2EWindow
|
|
402
|
+
const current = win.__opencode_e2e?.model?.current
|
|
403
|
+
if (!current) return null
|
|
404
|
+
return { dir: current.dir, sessionID: current.sessionID }
|
|
405
|
+
})
|
|
406
|
+
.catch(() => null as { dir?: string; sessionID?: string } | null)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function waitSlug(page: Page, skip: string[] = []) {
|
|
410
|
+
let prev = ""
|
|
411
|
+
let next = ""
|
|
412
|
+
await expect
|
|
413
|
+
.poll(
|
|
414
|
+
async () => {
|
|
415
|
+
await assertHealthy(page, "waitSlug")
|
|
416
|
+
const slug = slugFromUrl(page.url())
|
|
417
|
+
if (!slug) return ""
|
|
418
|
+
if (skip.includes(slug)) return ""
|
|
419
|
+
if (slug !== prev) {
|
|
420
|
+
prev = slug
|
|
421
|
+
next = ""
|
|
422
|
+
return ""
|
|
423
|
+
}
|
|
424
|
+
next = slug
|
|
425
|
+
return slug
|
|
426
|
+
},
|
|
427
|
+
{ timeout: 45_000 },
|
|
428
|
+
)
|
|
429
|
+
.not.toBe("")
|
|
430
|
+
return next
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function resolveSlug(slug: string) {
|
|
434
|
+
const directory = base64Decode(slug)
|
|
435
|
+
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
|
436
|
+
const resolved = await resolveDirectory(directory)
|
|
437
|
+
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function waitDir(page: Page, directory: string) {
|
|
441
|
+
const target = await resolveDirectory(directory)
|
|
442
|
+
await expect
|
|
443
|
+
.poll(
|
|
444
|
+
async () => {
|
|
445
|
+
await assertHealthy(page, "waitDir")
|
|
446
|
+
const slug = slugFromUrl(page.url())
|
|
447
|
+
if (!slug) return ""
|
|
448
|
+
return resolveSlug(slug)
|
|
449
|
+
.then((item) => item.directory)
|
|
450
|
+
.catch(() => "")
|
|
451
|
+
},
|
|
452
|
+
{ timeout: 45_000 },
|
|
453
|
+
)
|
|
454
|
+
.toBe(target)
|
|
455
|
+
return { directory: target, slug: base64Encode(target) }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
|
459
|
+
const target = await resolveDirectory(input.directory)
|
|
460
|
+
await expect
|
|
461
|
+
.poll(
|
|
462
|
+
async () => {
|
|
463
|
+
await assertHealthy(page, "waitSession")
|
|
464
|
+
const slug = slugFromUrl(page.url())
|
|
465
|
+
if (!slug) return false
|
|
466
|
+
const resolved = await resolveSlug(slug).catch(() => undefined)
|
|
467
|
+
if (!resolved || resolved.directory !== target) return false
|
|
468
|
+
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
|
|
469
|
+
|
|
470
|
+
const state = await probeSession(page)
|
|
471
|
+
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
|
472
|
+
if (state?.dir) {
|
|
473
|
+
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
|
474
|
+
if (dir !== target) return false
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return page
|
|
478
|
+
.locator(promptSelector)
|
|
479
|
+
.first()
|
|
480
|
+
.isVisible()
|
|
481
|
+
.catch(() => false)
|
|
482
|
+
},
|
|
483
|
+
{ timeout: 45_000 },
|
|
484
|
+
)
|
|
485
|
+
.toBe(true)
|
|
486
|
+
return { directory: target, slug: base64Encode(target) }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
|
490
|
+
const sdk = createSdk(directory)
|
|
491
|
+
const target = await resolveDirectory(directory)
|
|
492
|
+
|
|
493
|
+
await expect
|
|
494
|
+
.poll(
|
|
495
|
+
async () => {
|
|
496
|
+
const data = await sdk.session
|
|
497
|
+
.get({ sessionID })
|
|
498
|
+
.then((x) => x.data)
|
|
499
|
+
.catch(() => undefined)
|
|
500
|
+
if (!data?.directory) return ""
|
|
501
|
+
return resolveDirectory(data.directory).catch(() => data.directory)
|
|
502
|
+
},
|
|
503
|
+
{ timeout },
|
|
504
|
+
)
|
|
505
|
+
.toBe(target)
|
|
506
|
+
|
|
507
|
+
await expect
|
|
508
|
+
.poll(
|
|
509
|
+
async () => {
|
|
510
|
+
const items = await sdk.session
|
|
511
|
+
.messages({ sessionID, limit: 20 })
|
|
512
|
+
.then((x) => x.data ?? [])
|
|
513
|
+
.catch(() => [])
|
|
514
|
+
return items.some((item) => item.info.role === "user")
|
|
515
|
+
},
|
|
516
|
+
{ timeout },
|
|
517
|
+
)
|
|
518
|
+
.toBe(true)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function sessionIDFromUrl(url: string) {
|
|
522
|
+
const match = /\/session\/([^/?#]+)/.exec(url)
|
|
523
|
+
return match?.[1]
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export async function hoverSessionItem(page: Page, sessionID: string) {
|
|
527
|
+
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
|
|
528
|
+
await expect(sessionEl).toBeVisible()
|
|
529
|
+
await sessionEl.hover()
|
|
530
|
+
return sessionEl
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
|
534
|
+
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
|
535
|
+
|
|
536
|
+
const scroller = page.locator(".scroll-view__viewport").first()
|
|
537
|
+
await expect(scroller).toBeVisible()
|
|
538
|
+
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
|
539
|
+
|
|
540
|
+
const menu = page
|
|
541
|
+
.locator(dropdownMenuContentSelector)
|
|
542
|
+
.filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
|
|
543
|
+
.filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
|
|
544
|
+
.filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
|
|
545
|
+
.first()
|
|
546
|
+
|
|
547
|
+
const opened = await menu
|
|
548
|
+
.isVisible()
|
|
549
|
+
.then((x) => x)
|
|
550
|
+
.catch(() => false)
|
|
551
|
+
|
|
552
|
+
if (opened) return menu
|
|
553
|
+
|
|
554
|
+
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
|
555
|
+
await expect(menuTrigger).toBeVisible()
|
|
556
|
+
await menuTrigger.click()
|
|
557
|
+
|
|
558
|
+
await expect(menu).toBeVisible()
|
|
559
|
+
return menu
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
|
|
563
|
+
const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
|
|
564
|
+
await expect(item).toBeVisible()
|
|
565
|
+
await item.click({ force: options?.force })
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
|
569
|
+
const dialog = page.getByRole("dialog").first()
|
|
570
|
+
await expect(dialog).toBeVisible()
|
|
571
|
+
|
|
572
|
+
const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
|
|
573
|
+
await expect(button).toBeVisible()
|
|
574
|
+
await button.click()
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export async function openSharePopover(page: Page) {
|
|
578
|
+
const rightSection = page.locator(titlebarRightSelector)
|
|
579
|
+
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
|
580
|
+
await expect(shareButton).toBeVisible()
|
|
581
|
+
|
|
582
|
+
const popoverBody = page
|
|
583
|
+
.locator(popoverBodySelector)
|
|
584
|
+
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
|
585
|
+
.first()
|
|
586
|
+
|
|
587
|
+
const opened = await popoverBody
|
|
588
|
+
.isVisible()
|
|
589
|
+
.then((x) => x)
|
|
590
|
+
.catch(() => false)
|
|
591
|
+
|
|
592
|
+
if (!opened) {
|
|
593
|
+
await shareButton.click()
|
|
594
|
+
await expect(popoverBody).toBeVisible()
|
|
595
|
+
}
|
|
596
|
+
return { rightSection, popoverBody }
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
|
|
600
|
+
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
|
|
601
|
+
await expect(button).toBeVisible()
|
|
602
|
+
await button.click()
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export async function clickListItem(
|
|
606
|
+
container: Locator | Page,
|
|
607
|
+
filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
|
|
608
|
+
): Promise<Locator> {
|
|
609
|
+
let item: Locator
|
|
610
|
+
|
|
611
|
+
if (typeof filter === "string" || filter instanceof RegExp) {
|
|
612
|
+
item = container.locator(listItemSelector).filter({ hasText: filter }).first()
|
|
613
|
+
} else if (filter.keyStartsWith) {
|
|
614
|
+
item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
|
|
615
|
+
} else if (filter.key) {
|
|
616
|
+
item = container.locator(listItemKeySelector(filter.key)).first()
|
|
617
|
+
} else if (filter.text) {
|
|
618
|
+
item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
|
|
619
|
+
} else {
|
|
620
|
+
throw new Error("Invalid filter provided to clickListItem")
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
await expect(item).toBeVisible()
|
|
624
|
+
await item.click()
|
|
625
|
+
return item
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
|
629
|
+
const data = await sdk.session
|
|
630
|
+
.status()
|
|
631
|
+
.then((x) => x.data ?? {})
|
|
632
|
+
.catch(() => undefined)
|
|
633
|
+
return data?.[sessionID]
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
|
|
637
|
+
let prev = ""
|
|
638
|
+
await expect
|
|
639
|
+
.poll(
|
|
640
|
+
async () => {
|
|
641
|
+
const info = await sdk.session
|
|
642
|
+
.get({ sessionID })
|
|
643
|
+
.then((x) => x.data)
|
|
644
|
+
.catch(() => undefined)
|
|
645
|
+
if (!info) return true
|
|
646
|
+
const next = `${info.title}:${info.time.updated ?? info.time.created}`
|
|
647
|
+
if (next !== prev) {
|
|
648
|
+
prev = next
|
|
649
|
+
return false
|
|
650
|
+
}
|
|
651
|
+
return true
|
|
652
|
+
},
|
|
653
|
+
{ timeout },
|
|
654
|
+
)
|
|
655
|
+
.toBe(true)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
|
|
659
|
+
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export async function cleanupSession(input: {
|
|
663
|
+
sessionID: string
|
|
664
|
+
directory?: string
|
|
665
|
+
sdk?: ReturnType<typeof createSdk>
|
|
666
|
+
}) {
|
|
667
|
+
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
|
668
|
+
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
|
669
|
+
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
|
670
|
+
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
|
671
|
+
if (current && current.type !== "idle") {
|
|
672
|
+
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
|
|
673
|
+
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
|
|
674
|
+
}
|
|
675
|
+
await stable(sdk, input.sessionID).catch(() => undefined)
|
|
676
|
+
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export async function withSession<T>(
|
|
680
|
+
sdk: ReturnType<typeof createSdk>,
|
|
681
|
+
title: string,
|
|
682
|
+
callback: (session: { id: string; title: string }) => Promise<T>,
|
|
683
|
+
): Promise<T> {
|
|
684
|
+
const session = await sdk.session.create({ title }).then((r) => r.data)
|
|
685
|
+
if (!session?.id) throw new Error("Session create did not return an id")
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
return await callback(session)
|
|
689
|
+
} finally {
|
|
690
|
+
await cleanupSession({ sdk, sessionID: session.id })
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const seedSystem = [
|
|
695
|
+
"You are seeding deterministic e2e UI state.",
|
|
696
|
+
"Follow the user's instruction exactly.",
|
|
697
|
+
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
|
|
698
|
+
"Do not call any extra tools.",
|
|
699
|
+
].join(" ")
|
|
700
|
+
|
|
701
|
+
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
|
|
702
|
+
const timeout = input.timeout ?? 30_000
|
|
703
|
+
const end = Date.now() + timeout
|
|
704
|
+
while (Date.now() < end) {
|
|
705
|
+
const value = await input.probe()
|
|
706
|
+
if (value !== undefined) return value
|
|
707
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const seed = async <T>(input: {
|
|
712
|
+
sessionID: string
|
|
713
|
+
prompt: string
|
|
714
|
+
sdk: ReturnType<typeof createSdk>
|
|
715
|
+
probe: () => Promise<T | undefined>
|
|
716
|
+
timeout?: number
|
|
717
|
+
attempts?: number
|
|
718
|
+
}) => {
|
|
719
|
+
for (let i = 0; i < (input.attempts ?? 2); i++) {
|
|
720
|
+
await input.sdk.session.promptAsync({
|
|
721
|
+
sessionID: input.sessionID,
|
|
722
|
+
agent: "build",
|
|
723
|
+
system: seedSystem,
|
|
724
|
+
parts: [{ type: "text", text: input.prompt }],
|
|
725
|
+
})
|
|
726
|
+
const value = await wait({ probe: input.probe, timeout: input.timeout })
|
|
727
|
+
if (value !== undefined) return value
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
export async function seedSessionQuestion(
|
|
732
|
+
sdk: ReturnType<typeof createSdk>,
|
|
733
|
+
input: {
|
|
734
|
+
sessionID: string
|
|
735
|
+
questions: Array<{
|
|
736
|
+
header: string
|
|
737
|
+
question: string
|
|
738
|
+
options: Array<{ label: string; description: string }>
|
|
739
|
+
multiple?: boolean
|
|
740
|
+
custom?: boolean
|
|
741
|
+
}>
|
|
742
|
+
},
|
|
743
|
+
) {
|
|
744
|
+
const first = input.questions[0]
|
|
745
|
+
if (!first) throw new Error("Question seed requires at least one question")
|
|
746
|
+
|
|
747
|
+
const text = [
|
|
748
|
+
"Your only valid response is one question tool call.",
|
|
749
|
+
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
|
|
750
|
+
"Do not output plain text.",
|
|
751
|
+
"After calling the tool, wait for the user response.",
|
|
752
|
+
].join("\n")
|
|
753
|
+
|
|
754
|
+
const result = await seed({
|
|
755
|
+
sdk,
|
|
756
|
+
sessionID: input.sessionID,
|
|
757
|
+
prompt: text,
|
|
758
|
+
timeout: 30_000,
|
|
759
|
+
probe: async () => {
|
|
760
|
+
const list = await sdk.question.list().then((x) => x.data ?? [])
|
|
761
|
+
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
|
|
762
|
+
},
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
if (!result) throw new Error("Timed out seeding question request")
|
|
766
|
+
return { id: result.id }
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
export async function seedSessionPermission(
|
|
770
|
+
sdk: ReturnType<typeof createSdk>,
|
|
771
|
+
input: {
|
|
772
|
+
sessionID: string
|
|
773
|
+
permission: string
|
|
774
|
+
patterns: string[]
|
|
775
|
+
description?: string
|
|
776
|
+
},
|
|
777
|
+
) {
|
|
778
|
+
const text = [
|
|
779
|
+
"Your only valid response is one bash tool call.",
|
|
780
|
+
`Use this JSON input: ${JSON.stringify({
|
|
781
|
+
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
|
|
782
|
+
workdir: "/",
|
|
783
|
+
description: input.description ?? `seed ${input.permission} permission request`,
|
|
784
|
+
})}`,
|
|
785
|
+
"Do not output plain text.",
|
|
786
|
+
].join("\n")
|
|
787
|
+
|
|
788
|
+
const result = await seed({
|
|
789
|
+
sdk,
|
|
790
|
+
sessionID: input.sessionID,
|
|
791
|
+
prompt: text,
|
|
792
|
+
timeout: 30_000,
|
|
793
|
+
probe: async () => {
|
|
794
|
+
const list = await sdk.permission.list().then((x) => x.data ?? [])
|
|
795
|
+
return list.find((item) => item.sessionID === input.sessionID)
|
|
796
|
+
},
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
if (!result) throw new Error("Timed out seeding permission request")
|
|
800
|
+
return { id: result.id }
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export async function seedSessionTask(
|
|
804
|
+
sdk: ReturnType<typeof createSdk>,
|
|
805
|
+
input: {
|
|
806
|
+
sessionID: string
|
|
807
|
+
description: string
|
|
808
|
+
prompt: string
|
|
809
|
+
subagentType?: string
|
|
810
|
+
},
|
|
811
|
+
) {
|
|
812
|
+
const text = [
|
|
813
|
+
"Your only valid response is one task tool call.",
|
|
814
|
+
`Use this JSON input: ${JSON.stringify({
|
|
815
|
+
description: input.description,
|
|
816
|
+
prompt: input.prompt,
|
|
817
|
+
subagent_type: input.subagentType ?? "general",
|
|
818
|
+
})}`,
|
|
819
|
+
"Do not output plain text.",
|
|
820
|
+
"Wait for the task to start and return the child session id.",
|
|
821
|
+
].join("\n")
|
|
822
|
+
|
|
823
|
+
const result = await seed({
|
|
824
|
+
sdk,
|
|
825
|
+
sessionID: input.sessionID,
|
|
826
|
+
prompt: text,
|
|
827
|
+
timeout: 90_000,
|
|
828
|
+
probe: async () => {
|
|
829
|
+
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
|
|
830
|
+
const part = messages
|
|
831
|
+
.flatMap((message) => message.parts)
|
|
832
|
+
.find((part) => {
|
|
833
|
+
if (part.type !== "tool" || part.tool !== "task") return false
|
|
834
|
+
if (!("state" in part) || !part.state || typeof part.state !== "object") return false
|
|
835
|
+
if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
|
|
836
|
+
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
|
|
837
|
+
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
|
|
838
|
+
return false
|
|
839
|
+
if (!("sessionId" in part.state.metadata)) return false
|
|
840
|
+
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
|
|
844
|
+
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
|
845
|
+
if (!("sessionId" in part.state.metadata)) return
|
|
846
|
+
const id = part.state.metadata.sessionId
|
|
847
|
+
if (typeof id !== "string" || !id) return
|
|
848
|
+
const child = await sdk.session
|
|
849
|
+
.get({ sessionID: id })
|
|
850
|
+
.then((x) => x.data)
|
|
851
|
+
.catch(() => undefined)
|
|
852
|
+
if (!child?.id) return
|
|
853
|
+
return { sessionID: id }
|
|
854
|
+
},
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
if (!result) throw new Error("Timed out seeding task tool")
|
|
858
|
+
return result
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
export async function seedSessionTodos(
|
|
862
|
+
sdk: ReturnType<typeof createSdk>,
|
|
863
|
+
input: {
|
|
864
|
+
sessionID: string
|
|
865
|
+
todos: Array<{ content: string; status: string; priority: string }>
|
|
866
|
+
},
|
|
867
|
+
) {
|
|
868
|
+
const text = [
|
|
869
|
+
"Your only valid response is one todowrite tool call.",
|
|
870
|
+
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
|
|
871
|
+
"Do not output plain text.",
|
|
872
|
+
].join("\n")
|
|
873
|
+
const target = JSON.stringify(input.todos)
|
|
874
|
+
|
|
875
|
+
const result = await seed({
|
|
876
|
+
sdk,
|
|
877
|
+
sessionID: input.sessionID,
|
|
878
|
+
prompt: text,
|
|
879
|
+
timeout: 30_000,
|
|
880
|
+
probe: async () => {
|
|
881
|
+
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
|
|
882
|
+
if (JSON.stringify(todos) !== target) return
|
|
883
|
+
return true
|
|
884
|
+
},
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
if (!result) throw new Error("Timed out seeding todos")
|
|
888
|
+
return true
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
|
892
|
+
const [questions, permissions] = await Promise.all([
|
|
893
|
+
sdk.question.list().then((x) => x.data ?? []),
|
|
894
|
+
sdk.permission.list().then((x) => x.data ?? []),
|
|
895
|
+
])
|
|
896
|
+
|
|
897
|
+
await Promise.all([
|
|
898
|
+
...questions
|
|
899
|
+
.filter((item) => item.sessionID === sessionID)
|
|
900
|
+
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
|
|
901
|
+
...permissions
|
|
902
|
+
.filter((item) => item.sessionID === sessionID)
|
|
903
|
+
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
|
|
904
|
+
])
|
|
905
|
+
|
|
906
|
+
return true
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export async function openStatusPopover(page: Page) {
|
|
910
|
+
await defocus(page)
|
|
911
|
+
|
|
912
|
+
const rightSection = page.locator(titlebarRightSelector)
|
|
913
|
+
const trigger = rightSection.getByRole("button", { name: /status/i }).first()
|
|
914
|
+
|
|
915
|
+
const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
|
|
916
|
+
|
|
917
|
+
const opened = await popoverBody
|
|
918
|
+
.isVisible()
|
|
919
|
+
.then((x) => x)
|
|
920
|
+
.catch(() => false)
|
|
921
|
+
|
|
922
|
+
if (!opened) {
|
|
923
|
+
await expect(trigger).toBeVisible()
|
|
924
|
+
await trigger.click()
|
|
925
|
+
await expect(popoverBody).toBeVisible()
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return { rightSection, popoverBody }
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
export async function openProjectMenu(page: Page, projectSlug: string) {
|
|
932
|
+
await openSidebar(page)
|
|
933
|
+
const item = page.locator(projectSwitchSelector(projectSlug)).first()
|
|
934
|
+
await expect(item).toBeVisible()
|
|
935
|
+
await item.hover()
|
|
936
|
+
|
|
937
|
+
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
|
938
|
+
await expect(trigger).toHaveCount(1)
|
|
939
|
+
await expect(trigger).toBeVisible()
|
|
940
|
+
|
|
941
|
+
const menu = page
|
|
942
|
+
.locator(dropdownMenuContentSelector)
|
|
943
|
+
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
|
|
944
|
+
.first()
|
|
945
|
+
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
|
946
|
+
|
|
947
|
+
const clicked = await trigger
|
|
948
|
+
.click({ force: true, timeout: 1500 })
|
|
949
|
+
.then(() => true)
|
|
950
|
+
.catch(() => false)
|
|
951
|
+
|
|
952
|
+
if (clicked) {
|
|
953
|
+
const opened = await menu
|
|
954
|
+
.waitFor({ state: "visible", timeout: 1500 })
|
|
955
|
+
.then(() => true)
|
|
956
|
+
.catch(() => false)
|
|
957
|
+
if (opened) {
|
|
958
|
+
await expect(close).toBeVisible()
|
|
959
|
+
return menu
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
await trigger.focus()
|
|
964
|
+
await page.keyboard.press("Enter")
|
|
965
|
+
|
|
966
|
+
const opened = await menu
|
|
967
|
+
.waitFor({ state: "visible", timeout: 1500 })
|
|
968
|
+
.then(() => true)
|
|
969
|
+
.catch(() => false)
|
|
970
|
+
|
|
971
|
+
if (opened) {
|
|
972
|
+
await expect(close).toBeVisible()
|
|
973
|
+
return menu
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
throw new Error(`Failed to open project menu: ${projectSlug}`)
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
|
980
|
+
const current = await page
|
|
981
|
+
.getByRole("button", { name: "New workspace" })
|
|
982
|
+
.first()
|
|
983
|
+
.isVisible()
|
|
984
|
+
.then((x) => x)
|
|
985
|
+
.catch(() => false)
|
|
986
|
+
|
|
987
|
+
if (current === enabled) return
|
|
988
|
+
|
|
989
|
+
const flip = async (timeout?: number) => {
|
|
990
|
+
const menu = await openProjectMenu(page, projectSlug)
|
|
991
|
+
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
|
992
|
+
await expect(toggle).toBeVisible()
|
|
993
|
+
return toggle.click({ force: true, timeout })
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const flipped = await flip(1500)
|
|
997
|
+
.then(() => true)
|
|
998
|
+
.catch(() => false)
|
|
999
|
+
|
|
1000
|
+
if (!flipped) await flip()
|
|
1001
|
+
|
|
1002
|
+
const expected = enabled ? "New workspace" : "New session"
|
|
1003
|
+
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
|
1007
|
+
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
|
1008
|
+
await expect(item).toBeVisible()
|
|
1009
|
+
await item.hover()
|
|
1010
|
+
|
|
1011
|
+
const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
|
|
1012
|
+
await expect(trigger).toBeVisible()
|
|
1013
|
+
await trigger.click({ force: true })
|
|
1014
|
+
|
|
1015
|
+
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
1016
|
+
await expect(menu).toBeVisible()
|
|
1017
|
+
return menu
|
|
1018
|
+
}
|