@jonsoc/app 1.1.34

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.
Files changed (139) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +51 -0
  3. package/bunfig.toml +2 -0
  4. package/e2e/context.spec.ts +45 -0
  5. package/e2e/file-open.spec.ts +23 -0
  6. package/e2e/file-viewer.spec.ts +35 -0
  7. package/e2e/fixtures.ts +40 -0
  8. package/e2e/home.spec.ts +21 -0
  9. package/e2e/model-picker.spec.ts +43 -0
  10. package/e2e/navigation.spec.ts +9 -0
  11. package/e2e/palette.spec.ts +15 -0
  12. package/e2e/prompt-mention.spec.ts +26 -0
  13. package/e2e/prompt-slash-open.spec.ts +22 -0
  14. package/e2e/prompt.spec.ts +62 -0
  15. package/e2e/session.spec.ts +21 -0
  16. package/e2e/settings.spec.ts +44 -0
  17. package/e2e/sidebar.spec.ts +21 -0
  18. package/e2e/terminal-init.spec.ts +25 -0
  19. package/e2e/terminal.spec.ts +16 -0
  20. package/e2e/tsconfig.json +8 -0
  21. package/e2e/utils.ts +38 -0
  22. package/happydom.ts +75 -0
  23. package/index.html +23 -0
  24. package/package.json +72 -0
  25. package/playwright.config.ts +43 -0
  26. package/public/_headers +17 -0
  27. package/public/apple-touch-icon-v3.png +1 -0
  28. package/public/apple-touch-icon.png +1 -0
  29. package/public/favicon-96x96-v3.png +1 -0
  30. package/public/favicon-96x96.png +1 -0
  31. package/public/favicon-v3.ico +1 -0
  32. package/public/favicon-v3.svg +1 -0
  33. package/public/favicon.ico +1 -0
  34. package/public/favicon.svg +1 -0
  35. package/public/oc-theme-preload.js +28 -0
  36. package/public/site.webmanifest +1 -0
  37. package/public/social-share-zen.png +1 -0
  38. package/public/social-share.png +1 -0
  39. package/public/web-app-manifest-192x192.png +1 -0
  40. package/public/web-app-manifest-512x512.png +1 -0
  41. package/script/e2e-local.ts +143 -0
  42. package/src/addons/serialize.test.ts +319 -0
  43. package/src/addons/serialize.ts +591 -0
  44. package/src/app.tsx +150 -0
  45. package/src/components/dialog-connect-provider.tsx +428 -0
  46. package/src/components/dialog-edit-project.tsx +259 -0
  47. package/src/components/dialog-fork.tsx +104 -0
  48. package/src/components/dialog-manage-models.tsx +59 -0
  49. package/src/components/dialog-select-directory.tsx +208 -0
  50. package/src/components/dialog-select-file.tsx +196 -0
  51. package/src/components/dialog-select-mcp.tsx +96 -0
  52. package/src/components/dialog-select-model-unpaid.tsx +130 -0
  53. package/src/components/dialog-select-model.tsx +162 -0
  54. package/src/components/dialog-select-provider.tsx +70 -0
  55. package/src/components/dialog-select-server.tsx +249 -0
  56. package/src/components/dialog-settings.tsx +112 -0
  57. package/src/components/file-tree.tsx +112 -0
  58. package/src/components/link.tsx +17 -0
  59. package/src/components/model-tooltip.tsx +91 -0
  60. package/src/components/prompt-input.tsx +2076 -0
  61. package/src/components/session/index.ts +5 -0
  62. package/src/components/session/session-context-tab.tsx +428 -0
  63. package/src/components/session/session-header.tsx +343 -0
  64. package/src/components/session/session-new-view.tsx +93 -0
  65. package/src/components/session/session-sortable-tab.tsx +56 -0
  66. package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
  67. package/src/components/session-context-usage.tsx +113 -0
  68. package/src/components/session-lsp-indicator.tsx +42 -0
  69. package/src/components/session-mcp-indicator.tsx +34 -0
  70. package/src/components/settings-agents.tsx +15 -0
  71. package/src/components/settings-commands.tsx +15 -0
  72. package/src/components/settings-general.tsx +306 -0
  73. package/src/components/settings-keybinds.tsx +437 -0
  74. package/src/components/settings-mcp.tsx +15 -0
  75. package/src/components/settings-models.tsx +15 -0
  76. package/src/components/settings-permissions.tsx +234 -0
  77. package/src/components/settings-providers.tsx +15 -0
  78. package/src/components/terminal.tsx +315 -0
  79. package/src/components/titlebar.tsx +156 -0
  80. package/src/context/command.tsx +308 -0
  81. package/src/context/comments.tsx +140 -0
  82. package/src/context/file.tsx +409 -0
  83. package/src/context/global-sdk.tsx +106 -0
  84. package/src/context/global-sync.tsx +898 -0
  85. package/src/context/language.tsx +161 -0
  86. package/src/context/layout-scroll.test.ts +73 -0
  87. package/src/context/layout-scroll.ts +118 -0
  88. package/src/context/layout.tsx +648 -0
  89. package/src/context/local.tsx +578 -0
  90. package/src/context/notification.tsx +173 -0
  91. package/src/context/permission.tsx +167 -0
  92. package/src/context/platform.tsx +59 -0
  93. package/src/context/prompt.tsx +245 -0
  94. package/src/context/sdk.tsx +48 -0
  95. package/src/context/server.tsx +214 -0
  96. package/src/context/settings.tsx +166 -0
  97. package/src/context/sync.tsx +320 -0
  98. package/src/context/terminal.tsx +267 -0
  99. package/src/custom-elements.d.ts +17 -0
  100. package/src/entry.tsx +76 -0
  101. package/src/env.d.ts +8 -0
  102. package/src/hooks/use-providers.ts +31 -0
  103. package/src/i18n/ar.ts +656 -0
  104. package/src/i18n/br.ts +667 -0
  105. package/src/i18n/da.ts +582 -0
  106. package/src/i18n/de.ts +591 -0
  107. package/src/i18n/en.ts +665 -0
  108. package/src/i18n/es.ts +585 -0
  109. package/src/i18n/fr.ts +592 -0
  110. package/src/i18n/ja.ts +579 -0
  111. package/src/i18n/ko.ts +580 -0
  112. package/src/i18n/no.ts +602 -0
  113. package/src/i18n/pl.ts +661 -0
  114. package/src/i18n/ru.ts +664 -0
  115. package/src/i18n/zh.ts +574 -0
  116. package/src/i18n/zht.ts +570 -0
  117. package/src/index.css +57 -0
  118. package/src/index.ts +2 -0
  119. package/src/pages/directory-layout.tsx +57 -0
  120. package/src/pages/error.tsx +290 -0
  121. package/src/pages/home.tsx +125 -0
  122. package/src/pages/layout.tsx +2599 -0
  123. package/src/pages/session.tsx +2505 -0
  124. package/src/sst-env.d.ts +10 -0
  125. package/src/utils/dom.ts +51 -0
  126. package/src/utils/id.ts +99 -0
  127. package/src/utils/index.ts +1 -0
  128. package/src/utils/perf.ts +135 -0
  129. package/src/utils/persist.ts +377 -0
  130. package/src/utils/prompt.ts +203 -0
  131. package/src/utils/same.ts +6 -0
  132. package/src/utils/solid-dnd.tsx +55 -0
  133. package/src/utils/sound.ts +110 -0
  134. package/src/utils/speech.ts +302 -0
  135. package/src/utils/worktree.ts +58 -0
  136. package/sst-env.d.ts +9 -0
  137. package/tsconfig.json +26 -0
  138. package/vite.config.ts +15 -0
  139. package/vite.js +26 -0
package/AGENTS.md ADDED
@@ -0,0 +1,30 @@
1
+ ## Debugging
2
+
3
+ - NEVER try to restart the app, or the server process, EVER.
4
+
5
+ ## Local Dev
6
+
7
+ - `jonsoc dev web` proxies `https://app.jonsoc.com`, so local UI/CSS changes will not show there.
8
+ - For local UI changes, run the backend and app dev servers separately.
9
+ - Backend (from `packages/jonsoc`): `bun run --conditions=browser ./src/index.ts serve --port 4096`
10
+ - App (from `packages/app`): `bun dev -- --port 4444`
11
+ - Open `http://localhost:4444` to verify UI changes (it targets the backend at `http://localhost:4096`).
12
+
13
+ ## SolidJS
14
+
15
+ - Always prefer `createStore` over multiple `createSignal` calls
16
+
17
+ ## Tool Calling
18
+
19
+ - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
20
+
21
+ ## Browser Automation
22
+
23
+ Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
24
+
25
+ Core workflow:
26
+
27
+ 1. `agent-browser open <url>` - Navigate to page
28
+ 2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2)
29
+ 3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs
30
+ 4. Re-snapshot after page changes
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ ## Usage
2
+
3
+ Dependencies for these templates are managed with [pnpm](https://pnpm.io) using `pnpm up -Lri`.
4
+
5
+ This is the reason you see a `pnpm-lock.yaml`. That said, any package manager will work. This file can safely be removed once you clone a template.
6
+
7
+ ```bash
8
+ $ npm install # or pnpm install or yarn install
9
+ ```
10
+
11
+ ### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
12
+
13
+ ## Available Scripts
14
+
15
+ In the project directory, you can run:
16
+
17
+ ### `npm run dev` or `npm start`
18
+
19
+ Runs the app in the development mode.<br>
20
+ Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
21
+
22
+ The page will reload if you make edits.<br>
23
+
24
+ ### `npm run build`
25
+
26
+ Builds the app for production to the `dist` folder.<br>
27
+ It correctly bundles Solid in production mode and optimizes the build for the best performance.
28
+
29
+ The build is minified and the filenames include the hashes.<br>
30
+ Your app is ready to be deployed!
31
+
32
+ ## E2E Testing
33
+
34
+ Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an jonsoc backend (defaults to `localhost:4096`).
35
+ Use the local runner to create a temp sandbox, seed data, and run the tests.
36
+
37
+ ```bash
38
+ bunx playwright install
39
+ bun run test:e2e:local
40
+ bun run test:e2e:local -- --grep "settings"
41
+ ```
42
+
43
+ Environment options:
44
+
45
+ - `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`)
46
+ - `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`)
47
+ - `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:<PLAYWRIGHT_PORT>`)
48
+
49
+ ## Deployment
50
+
51
+ You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
package/bunfig.toml ADDED
@@ -0,0 +1,2 @@
1
+ [test]
2
+ preload = ["./happydom.ts"]
@@ -0,0 +1,45 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { promptSelector } from "./utils"
3
+
4
+ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
5
+ const title = `e2e smoke context ${Date.now()}`
6
+ const created = await sdk.session.create({ title }).then((r) => r.data)
7
+
8
+ if (!created?.id) throw new Error("Session create did not return an id")
9
+ const sessionID = created.id
10
+
11
+ try {
12
+ await sdk.session.promptAsync({
13
+ sessionID,
14
+ noReply: true,
15
+ parts: [
16
+ {
17
+ type: "text",
18
+ text: "seed context",
19
+ },
20
+ ],
21
+ })
22
+
23
+ await expect
24
+ .poll(async () => {
25
+ const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
26
+ return messages.length
27
+ })
28
+ .toBeGreaterThan(0)
29
+
30
+ await gotoSession(sessionID)
31
+
32
+ const contextButton = page
33
+ .locator('[data-component="button"]')
34
+ .filter({ has: page.locator('[data-component="progress-circle"]').first() })
35
+ .first()
36
+
37
+ await expect(contextButton).toBeVisible()
38
+ await contextButton.click()
39
+
40
+ const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
41
+ await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
42
+ } finally {
43
+ await sdk.session.delete({ sessionID }).catch(() => undefined)
44
+ }
45
+ })
@@ -0,0 +1,23 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { modKey } from "./utils"
3
+
4
+ test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ await page.keyboard.press(`${modKey}+P`)
8
+
9
+ const dialog = page.getByRole("dialog")
10
+ await expect(dialog).toBeVisible()
11
+
12
+ const input = dialog.getByRole("textbox").first()
13
+ await input.fill("package.json")
14
+
15
+ const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
16
+ await expect(fileItem).toBeVisible()
17
+ await fileItem.click()
18
+
19
+ await expect(dialog).toHaveCount(0)
20
+
21
+ const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
22
+ await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible()
23
+ })
@@ -0,0 +1,35 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { modKey } from "./utils"
3
+
4
+ test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ const sep = process.platform === "win32" ? "\\" : "/"
8
+ const file = ["packages", "app", "package.json"].join(sep)
9
+
10
+ await page.keyboard.press(`${modKey}+P`)
11
+
12
+ const dialog = page.getByRole("dialog")
13
+ await expect(dialog).toBeVisible()
14
+
15
+ const input = dialog.getByRole("textbox").first()
16
+ await input.fill(file)
17
+
18
+ const fileItem = dialog
19
+ .locator(
20
+ '[data-slot="list-item"][data-key^="file:"][data-key*="packages"][data-key*="app"][data-key$="package.json"]',
21
+ )
22
+ .first()
23
+ await expect(fileItem).toBeVisible()
24
+ await fileItem.click()
25
+
26
+ await expect(dialog).toHaveCount(0)
27
+
28
+ const tab = page.getByRole("tab", { name: "package.json" })
29
+ await expect(tab).toBeVisible()
30
+ await tab.click()
31
+
32
+ const code = page.locator('[data-component="code"]').first()
33
+ await expect(code).toBeVisible()
34
+ await expect(code.getByText("@jonsoc/app")).toBeVisible()
35
+ })
@@ -0,0 +1,40 @@
1
+ import { test as base, expect } from "@playwright/test"
2
+ import { createSdk, dirSlug, getWorktree, promptSelector, sessionPath } from "./utils"
3
+
4
+ type TestFixtures = {
5
+ sdk: ReturnType<typeof createSdk>
6
+ gotoSession: (sessionID?: string) => Promise<void>
7
+ }
8
+
9
+ type WorkerFixtures = {
10
+ directory: string
11
+ slug: string
12
+ }
13
+
14
+ export const test = base.extend<TestFixtures, WorkerFixtures>({
15
+ directory: [
16
+ async ({}, use) => {
17
+ const directory = await getWorktree()
18
+ await use(directory)
19
+ },
20
+ { scope: "worker" },
21
+ ],
22
+ slug: [
23
+ async ({ directory }, use) => {
24
+ await use(dirSlug(directory))
25
+ },
26
+ { scope: "worker" },
27
+ ],
28
+ sdk: async ({ directory }, use) => {
29
+ await use(createSdk(directory))
30
+ },
31
+ gotoSession: async ({ page, directory }, use) => {
32
+ const gotoSession = async (sessionID?: string) => {
33
+ await page.goto(sessionPath(directory, sessionID))
34
+ await expect(page.locator(promptSelector)).toBeVisible()
35
+ }
36
+ await use(gotoSession)
37
+ },
38
+ })
39
+
40
+ export { expect }
@@ -0,0 +1,21 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { serverName } from "./utils"
3
+
4
+ test("home renders and shows core entrypoints", async ({ page }) => {
5
+ await page.goto("/")
6
+
7
+ await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
8
+ await expect(page.getByRole("button", { name: serverName })).toBeVisible()
9
+ })
10
+
11
+ test("server picker dialog opens from home", async ({ page }) => {
12
+ await page.goto("/")
13
+
14
+ const trigger = page.getByRole("button", { name: serverName })
15
+ await expect(trigger).toBeVisible()
16
+ await trigger.click()
17
+
18
+ const dialog = page.getByRole("dialog")
19
+ await expect(dialog).toBeVisible()
20
+ await expect(dialog.getByRole("textbox").first()).toBeVisible()
21
+ })
@@ -0,0 +1,43 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { promptSelector } from "./utils"
3
+
4
+ test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ await page.locator(promptSelector).click()
8
+ await page.keyboard.type("/model")
9
+
10
+ const command = page.locator('[data-slash-id="model.choose"]')
11
+ await expect(command).toBeVisible()
12
+ await command.hover()
13
+
14
+ await page.keyboard.press("Enter")
15
+
16
+ const dialog = page.getByRole("dialog")
17
+ await expect(dialog).toBeVisible()
18
+
19
+ const input = dialog.getByRole("textbox").first()
20
+
21
+ const selected = dialog.locator('[data-slot="list-item"][data-selected="true"]').first()
22
+ await expect(selected).toBeVisible()
23
+
24
+ const other = dialog.locator('[data-slot="list-item"]:not([data-selected="true"])').first()
25
+ const target = (await other.count()) > 0 ? other : selected
26
+
27
+ const key = await target.getAttribute("data-key")
28
+ if (!key) throw new Error("Failed to resolve model key from list item")
29
+
30
+ const name = (await target.locator("span").first().innerText()).trim()
31
+ const model = key.split(":").slice(1).join(":")
32
+
33
+ await input.fill(model)
34
+
35
+ const item = dialog.locator(`[data-slot="list-item"][data-key="${key}"]`)
36
+ await expect(item).toBeVisible()
37
+ await item.click()
38
+
39
+ await expect(dialog).toHaveCount(0)
40
+
41
+ const form = page.locator(promptSelector).locator("xpath=ancestor::form[1]")
42
+ await expect(form.locator('[data-component="button"]').filter({ hasText: name }).first()).toBeVisible()
43
+ })
@@ -0,0 +1,9 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { dirPath, promptSelector } from "./utils"
3
+
4
+ test("project route redirects to /session", async ({ page, directory, slug }) => {
5
+ await page.goto(dirPath(directory))
6
+
7
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
8
+ await expect(page.locator(promptSelector)).toBeVisible()
9
+ })
@@ -0,0 +1,15 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { modKey } from "./utils"
3
+
4
+ test("search palette opens and closes", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ await page.keyboard.press(`${modKey}+P`)
8
+
9
+ const dialog = page.getByRole("dialog")
10
+ await expect(dialog).toBeVisible()
11
+ await expect(dialog.getByRole("textbox").first()).toBeVisible()
12
+
13
+ await page.keyboard.press("Escape")
14
+ await expect(dialog).toHaveCount(0)
15
+ })
@@ -0,0 +1,26 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { promptSelector } from "./utils"
3
+
4
+ test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ await page.locator(promptSelector).click()
8
+ const sep = process.platform === "win32" ? "\\" : "/"
9
+ const file = ["packages", "app", "package.json"].join(sep)
10
+ const filePattern = /packages[\\/]+app[\\/]+\s*package\.json/
11
+
12
+ await page.keyboard.type(`@${file}`)
13
+
14
+ const suggestion = page.getByRole("button", { name: filePattern }).first()
15
+ await expect(suggestion).toBeVisible()
16
+ await suggestion.hover()
17
+
18
+ await page.keyboard.press("Tab")
19
+
20
+ const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
21
+ await expect(pill).toBeVisible()
22
+ await expect(pill).toHaveAttribute("data-path", filePattern)
23
+
24
+ await page.keyboard.type(" ok")
25
+ await expect(page.locator(promptSelector)).toContainText("ok")
26
+ })
@@ -0,0 +1,22 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { promptSelector } from "./utils"
3
+
4
+ test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ await page.locator(promptSelector).click()
8
+ await page.keyboard.type("/open")
9
+
10
+ const command = page.locator('[data-slash-id="file.open"]')
11
+ await expect(command).toBeVisible()
12
+ await command.hover()
13
+
14
+ await page.keyboard.press("Enter")
15
+
16
+ const dialog = page.getByRole("dialog")
17
+ await expect(dialog).toBeVisible()
18
+ await expect(dialog.getByRole("textbox").first()).toBeVisible()
19
+
20
+ await page.keyboard.press("Escape")
21
+ await expect(dialog).toHaveCount(0)
22
+ })
@@ -0,0 +1,62 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { promptSelector } from "./utils"
3
+
4
+ function sessionIDFromUrl(url: string) {
5
+ const match = /\/session\/([^/?#]+)/.exec(url)
6
+ return match?.[1]
7
+ }
8
+
9
+ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
10
+ test.setTimeout(120_000)
11
+
12
+ const pageErrors: string[] = []
13
+ const onPageError = (err: Error) => {
14
+ pageErrors.push(err.message)
15
+ }
16
+ page.on("pageerror", onPageError)
17
+
18
+ await gotoSession()
19
+
20
+ const token = `E2E_OK_${Date.now()}`
21
+
22
+ const prompt = page.locator(promptSelector)
23
+ await prompt.click()
24
+ await page.keyboard.type(`Reply with exactly: ${token}`)
25
+ await page.keyboard.press("Enter")
26
+
27
+ await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
28
+
29
+ const sessionID = (() => {
30
+ const id = sessionIDFromUrl(page.url())
31
+ if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
32
+ return id
33
+ })()
34
+
35
+ try {
36
+ await expect
37
+ .poll(
38
+ async () => {
39
+ const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
40
+ return messages
41
+ .filter((m) => m.info.role === "assistant")
42
+ .flatMap((m) => m.parts)
43
+ .filter((p) => p.type === "text")
44
+ .map((p) => p.text)
45
+ .join("\n")
46
+ },
47
+ { timeout: 90_000 },
48
+ )
49
+
50
+ .toContain(token)
51
+
52
+ const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first()
53
+ await expect(reply).toBeVisible({ timeout: 90_000 })
54
+ } finally {
55
+ page.off("pageerror", onPageError)
56
+ await sdk.session.delete({ sessionID }).catch(() => undefined)
57
+ }
58
+
59
+ if (pageErrors.length > 0) {
60
+ throw new Error(`Page error(s):\n${pageErrors.join("\n")}`)
61
+ }
62
+ })
@@ -0,0 +1,21 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { promptSelector } from "./utils"
3
+
4
+ test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
5
+ const title = `e2e smoke ${Date.now()}`
6
+ const created = await sdk.session.create({ title }).then((r) => r.data)
7
+
8
+ if (!created?.id) throw new Error("Session create did not return an id")
9
+ const sessionID = created.id
10
+
11
+ try {
12
+ await gotoSession(sessionID)
13
+
14
+ const prompt = page.locator(promptSelector)
15
+ await prompt.click()
16
+ await page.keyboard.type("hello from e2e")
17
+ await expect(prompt).toContainText("hello from e2e")
18
+ } finally {
19
+ await sdk.session.delete({ sessionID }).catch(() => undefined)
20
+ }
21
+ })
@@ -0,0 +1,44 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { modKey } from "./utils"
3
+
4
+ test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ const dialog = page.getByRole("dialog")
8
+
9
+ await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
10
+
11
+ const opened = await dialog
12
+ .waitFor({ state: "visible", timeout: 3000 })
13
+ .then(() => true)
14
+ .catch(() => false)
15
+
16
+ if (!opened) {
17
+ await page.getByRole("button", { name: "Settings" }).first().click()
18
+ await expect(dialog).toBeVisible()
19
+ }
20
+
21
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
22
+ await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
23
+ await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
24
+
25
+ await page.keyboard.press("Escape")
26
+
27
+ const closed = await dialog
28
+ .waitFor({ state: "detached", timeout: 1500 })
29
+ .then(() => true)
30
+ .catch(() => false)
31
+
32
+ if (closed) return
33
+
34
+ await page.keyboard.press("Escape")
35
+ const closedSecond = await dialog
36
+ .waitFor({ state: "detached", timeout: 1500 })
37
+ .then(() => true)
38
+ .catch(() => false)
39
+
40
+ if (closedSecond) return
41
+
42
+ await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
43
+ await expect(dialog).toHaveCount(0)
44
+ })
@@ -0,0 +1,21 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { modKey } from "./utils"
3
+
4
+ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ const main = page.locator("main")
8
+ const closedClass = /xl:border-l/
9
+ const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))
10
+
11
+ if (isClosed) {
12
+ await page.keyboard.press(`${modKey}+B`)
13
+ await expect(main).not.toHaveClass(closedClass)
14
+ }
15
+
16
+ await page.keyboard.press(`${modKey}+B`)
17
+ await expect(main).toHaveClass(closedClass)
18
+
19
+ await page.keyboard.press(`${modKey}+B`)
20
+ await expect(main).not.toHaveClass(closedClass)
21
+ })
@@ -0,0 +1,25 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { promptSelector, terminalSelector, terminalToggleKey } from "./utils"
3
+
4
+ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ const terminals = page.locator(terminalSelector)
8
+ const opened = await terminals.first().isVisible()
9
+
10
+ if (!opened) {
11
+ await page.keyboard.press(terminalToggleKey)
12
+ }
13
+
14
+ await expect(terminals.first()).toBeVisible()
15
+ await expect(terminals.first().locator("textarea")).toHaveCount(1)
16
+ await expect(terminals).toHaveCount(1)
17
+
18
+ // Ghostty captures a lot of keybinds when focused; move focus back
19
+ // to the app shell before triggering `terminal.new`.
20
+ await page.locator(promptSelector).click()
21
+ await page.keyboard.press("Control+Alt+T")
22
+
23
+ await expect(terminals).toHaveCount(2)
24
+ await expect(terminals.nth(1).locator("textarea")).toHaveCount(1)
25
+ })
@@ -0,0 +1,16 @@
1
+ import { test, expect } from "./fixtures"
2
+ import { terminalSelector, terminalToggleKey } from "./utils"
3
+
4
+ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
5
+ await gotoSession()
6
+
7
+ const terminal = page.locator(terminalSelector)
8
+ const initiallyOpen = await terminal.isVisible()
9
+ if (initiallyOpen) {
10
+ await page.keyboard.press(terminalToggleKey)
11
+ await expect(terminal).toHaveCount(0)
12
+ }
13
+
14
+ await page.keyboard.press(terminalToggleKey)
15
+ await expect(terminal).toBeVisible()
16
+ })
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["node"]
6
+ },
7
+ "include": ["./**/*.ts"]
8
+ }
package/e2e/utils.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { createOpencodeClient } from "@jonsoc/sdk/v2/client"
2
+ import { base64Encode } from "@jonsoc/util/encode"
3
+
4
+ export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
5
+ export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
6
+
7
+ export const serverUrl = `http://${serverHost}:${serverPort}`
8
+ export const serverName = `${serverHost}:${serverPort}`
9
+
10
+ export const modKey = process.platform === "darwin" ? "Meta" : "Control"
11
+ export const terminalToggleKey = "Control+Backquote"
12
+
13
+ export const promptSelector = '[data-component="prompt-input"]'
14
+ export const terminalSelector = '[data-component="terminal"]'
15
+
16
+ export function createSdk(directory?: string) {
17
+ return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
18
+ }
19
+
20
+ export async function getWorktree() {
21
+ const sdk = createSdk()
22
+ const result = await sdk.path.get()
23
+ const data = result.data
24
+ if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
25
+ return data.worktree
26
+ }
27
+
28
+ export function dirSlug(directory: string) {
29
+ return base64Encode(directory)
30
+ }
31
+
32
+ export function dirPath(directory: string) {
33
+ return `/${dirSlug(directory)}`
34
+ }
35
+
36
+ export function sessionPath(directory: string, sessionID?: string) {
37
+ return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
38
+ }