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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
disposeIfDisposable,
|
|
4
|
+
getHoveredLinkText,
|
|
5
|
+
getSpeechRecognitionCtor,
|
|
6
|
+
hasSetOption,
|
|
7
|
+
isDisposable,
|
|
8
|
+
setOptionIfSupported,
|
|
9
|
+
} from "./runtime-adapters"
|
|
10
|
+
|
|
11
|
+
describe("runtime adapters", () => {
|
|
12
|
+
test("detects and disposes disposable values", () => {
|
|
13
|
+
let count = 0
|
|
14
|
+
const value = {
|
|
15
|
+
dispose: () => {
|
|
16
|
+
count += 1
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
expect(isDisposable(value)).toBe(true)
|
|
20
|
+
disposeIfDisposable(value)
|
|
21
|
+
expect(count).toBe(1)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("ignores non-disposable values", () => {
|
|
25
|
+
expect(isDisposable({ dispose: "nope" })).toBe(false)
|
|
26
|
+
expect(() => disposeIfDisposable({ dispose: "nope" })).not.toThrow()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("sets options only when setter exists", () => {
|
|
30
|
+
const calls: Array<[string, unknown]> = []
|
|
31
|
+
const value = {
|
|
32
|
+
setOption: (key: string, next: unknown) => {
|
|
33
|
+
calls.push([key, next])
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
expect(hasSetOption(value)).toBe(true)
|
|
37
|
+
setOptionIfSupported(value, "fontFamily", "Berkeley Mono")
|
|
38
|
+
expect(calls).toEqual([["fontFamily", "Berkeley Mono"]])
|
|
39
|
+
expect(() => setOptionIfSupported({}, "fontFamily", "Berkeley Mono")).not.toThrow()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("reads hovered link text safely", () => {
|
|
43
|
+
expect(getHoveredLinkText({ currentHoveredLink: { text: "https://example.com" } })).toBe("https://example.com")
|
|
44
|
+
expect(getHoveredLinkText({ currentHoveredLink: { text: 1 } })).toBeUndefined()
|
|
45
|
+
expect(getHoveredLinkText(null)).toBeUndefined()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("resolves speech recognition constructor with webkit precedence", () => {
|
|
49
|
+
class SpeechCtor {}
|
|
50
|
+
class WebkitCtor {}
|
|
51
|
+
const ctor = getSpeechRecognitionCtor({
|
|
52
|
+
SpeechRecognition: SpeechCtor,
|
|
53
|
+
webkitSpeechRecognition: WebkitCtor,
|
|
54
|
+
})
|
|
55
|
+
expect(ctor).toBe(WebkitCtor)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("returns undefined when no valid speech constructor exists", () => {
|
|
59
|
+
expect(getSpeechRecognitionCtor({ SpeechRecognition: "nope" })).toBeUndefined()
|
|
60
|
+
expect(getSpeechRecognitionCtor(undefined)).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
type RecordValue = Record<string, unknown>
|
|
2
|
+
|
|
3
|
+
const isRecord = (value: unknown): value is RecordValue => {
|
|
4
|
+
return typeof value === "object" && value !== null
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const isDisposable = (value: unknown): value is { dispose: () => void } => {
|
|
8
|
+
return isRecord(value) && typeof value.dispose === "function"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const disposeIfDisposable = (value: unknown) => {
|
|
12
|
+
if (!isDisposable(value)) return
|
|
13
|
+
value.dispose()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const hasSetOption = (value: unknown): value is { setOption: (key: string, next: unknown) => void } => {
|
|
17
|
+
return isRecord(value) && typeof value.setOption === "function"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const setOptionIfSupported = (value: unknown, key: string, next: unknown) => {
|
|
21
|
+
if (!hasSetOption(value)) return
|
|
22
|
+
value.setOption(key, next)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const getHoveredLinkText = (value: unknown) => {
|
|
26
|
+
if (!isRecord(value)) return
|
|
27
|
+
const link = value.currentHoveredLink
|
|
28
|
+
if (!isRecord(link)) return
|
|
29
|
+
if (typeof link.text !== "string") return
|
|
30
|
+
return link.text
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const getSpeechRecognitionCtor = <T>(value: unknown): (new () => T) | undefined => {
|
|
34
|
+
if (!isRecord(value)) return
|
|
35
|
+
const ctor =
|
|
36
|
+
typeof value.webkitSpeechRecognition === "function" ? value.webkitSpeechRecognition : value.SpeechRecognition
|
|
37
|
+
if (typeof ctor !== "function") return
|
|
38
|
+
return ctor as new () => T
|
|
39
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { createScopedCache } from "./scoped-cache"
|
|
3
|
+
|
|
4
|
+
describe("createScopedCache", () => {
|
|
5
|
+
test("evicts least-recently-used entry when max is reached", () => {
|
|
6
|
+
const disposed: string[] = []
|
|
7
|
+
const cache = createScopedCache((key) => ({ key }), {
|
|
8
|
+
maxEntries: 2,
|
|
9
|
+
dispose: (value) => disposed.push(value.key),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const a = cache.get("a")
|
|
13
|
+
const b = cache.get("b")
|
|
14
|
+
expect(a.key).toBe("a")
|
|
15
|
+
expect(b.key).toBe("b")
|
|
16
|
+
|
|
17
|
+
cache.get("a")
|
|
18
|
+
const c = cache.get("c")
|
|
19
|
+
|
|
20
|
+
expect(c.key).toBe("c")
|
|
21
|
+
expect(cache.peek("a")?.key).toBe("a")
|
|
22
|
+
expect(cache.peek("b")).toBeUndefined()
|
|
23
|
+
expect(cache.peek("c")?.key).toBe("c")
|
|
24
|
+
expect(disposed).toEqual(["b"])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("disposes entries on delete and clear", () => {
|
|
28
|
+
const disposed: string[] = []
|
|
29
|
+
const cache = createScopedCache((key) => ({ key }), {
|
|
30
|
+
dispose: (value) => disposed.push(value.key),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
cache.get("a")
|
|
34
|
+
cache.get("b")
|
|
35
|
+
|
|
36
|
+
const removed = cache.delete("a")
|
|
37
|
+
expect(removed?.key).toBe("a")
|
|
38
|
+
expect(cache.peek("a")).toBeUndefined()
|
|
39
|
+
|
|
40
|
+
cache.clear()
|
|
41
|
+
expect(cache.peek("b")).toBeUndefined()
|
|
42
|
+
expect(disposed).toEqual(["a", "b"])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("expires stale entries with ttl and recreates on get", () => {
|
|
46
|
+
let clock = 0
|
|
47
|
+
let count = 0
|
|
48
|
+
const disposed: string[] = []
|
|
49
|
+
const cache = createScopedCache((key) => ({ key, count: ++count }), {
|
|
50
|
+
ttlMs: 10,
|
|
51
|
+
now: () => clock,
|
|
52
|
+
dispose: (value) => disposed.push(`${value.key}:${value.count}`),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const first = cache.get("a")
|
|
56
|
+
expect(first.count).toBe(1)
|
|
57
|
+
|
|
58
|
+
clock = 9
|
|
59
|
+
expect(cache.peek("a")?.count).toBe(1)
|
|
60
|
+
|
|
61
|
+
clock = 11
|
|
62
|
+
expect(cache.peek("a")).toBeUndefined()
|
|
63
|
+
expect(disposed).toEqual(["a:1"])
|
|
64
|
+
|
|
65
|
+
const second = cache.get("a")
|
|
66
|
+
expect(second.count).toBe(2)
|
|
67
|
+
expect(disposed).toEqual(["a:1"])
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
type ScopedCacheOptions<T> = {
|
|
2
|
+
maxEntries?: number
|
|
3
|
+
ttlMs?: number
|
|
4
|
+
dispose?: (value: T, key: string) => void
|
|
5
|
+
now?: () => number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
type Entry<T> = {
|
|
9
|
+
value: T
|
|
10
|
+
touchedAt: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createScopedCache<T>(createValue: (key: string) => T, options: ScopedCacheOptions<T> = {}) {
|
|
14
|
+
const store = new Map<string, Entry<T>>()
|
|
15
|
+
const now = options.now ?? Date.now
|
|
16
|
+
|
|
17
|
+
const dispose = (key: string, entry: Entry<T>) => {
|
|
18
|
+
options.dispose?.(entry.value, key)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const expired = (entry: Entry<T>) => {
|
|
22
|
+
if (options.ttlMs === undefined) return false
|
|
23
|
+
return now() - entry.touchedAt >= options.ttlMs
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sweep = () => {
|
|
27
|
+
if (options.ttlMs === undefined) return
|
|
28
|
+
for (const [key, entry] of store) {
|
|
29
|
+
if (!expired(entry)) continue
|
|
30
|
+
store.delete(key)
|
|
31
|
+
dispose(key, entry)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const touch = (key: string, entry: Entry<T>) => {
|
|
36
|
+
entry.touchedAt = now()
|
|
37
|
+
store.delete(key)
|
|
38
|
+
store.set(key, entry)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const prune = () => {
|
|
42
|
+
if (options.maxEntries === undefined) return
|
|
43
|
+
while (store.size > options.maxEntries) {
|
|
44
|
+
const key = store.keys().next().value
|
|
45
|
+
if (!key) return
|
|
46
|
+
const entry = store.get(key)
|
|
47
|
+
store.delete(key)
|
|
48
|
+
if (!entry) continue
|
|
49
|
+
dispose(key, entry)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const remove = (key: string) => {
|
|
54
|
+
const entry = store.get(key)
|
|
55
|
+
if (!entry) return
|
|
56
|
+
store.delete(key)
|
|
57
|
+
dispose(key, entry)
|
|
58
|
+
return entry.value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const peek = (key: string) => {
|
|
62
|
+
sweep()
|
|
63
|
+
const entry = store.get(key)
|
|
64
|
+
if (!entry) return
|
|
65
|
+
if (!expired(entry)) return entry.value
|
|
66
|
+
store.delete(key)
|
|
67
|
+
dispose(key, entry)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const get = (key: string) => {
|
|
71
|
+
sweep()
|
|
72
|
+
const entry = store.get(key)
|
|
73
|
+
if (entry && !expired(entry)) {
|
|
74
|
+
touch(key, entry)
|
|
75
|
+
return entry.value
|
|
76
|
+
}
|
|
77
|
+
if (entry) {
|
|
78
|
+
store.delete(key)
|
|
79
|
+
dispose(key, entry)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const created = {
|
|
83
|
+
value: createValue(key),
|
|
84
|
+
touchedAt: now(),
|
|
85
|
+
}
|
|
86
|
+
store.set(key, created)
|
|
87
|
+
prune()
|
|
88
|
+
return created.value
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const clear = () => {
|
|
92
|
+
for (const [key, entry] of store) {
|
|
93
|
+
dispose(key, entry)
|
|
94
|
+
}
|
|
95
|
+
store.clear()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
get,
|
|
100
|
+
peek,
|
|
101
|
+
delete: remove,
|
|
102
|
+
clear,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors"
|
|
3
|
+
import { formatServerError, parseReadableConfigInvalidError } from "./server-errors"
|
|
4
|
+
|
|
5
|
+
function fill(text: string, vars?: Record<string, string | number>) {
|
|
6
|
+
if (!vars) return text
|
|
7
|
+
return text.replace(/{{\s*(\w+)\s*}}/g, (_, key: string) => {
|
|
8
|
+
const value = vars[key]
|
|
9
|
+
if (value === undefined) return ""
|
|
10
|
+
return String(value)
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function useLanguageMock() {
|
|
15
|
+
const dict: Record<string, string> = {
|
|
16
|
+
"error.chain.unknown": "Erro desconhecido",
|
|
17
|
+
"error.chain.configInvalid": "Arquivo de config em {{path}} invalido",
|
|
18
|
+
"error.chain.configInvalidWithMessage": "Arquivo de config em {{path}} invalido: {{message}}",
|
|
19
|
+
"error.chain.modelNotFound": "Modelo nao encontrado: {{provider}}/{{model}}",
|
|
20
|
+
"error.chain.didYouMean": "Voce quis dizer: {{suggestions}}",
|
|
21
|
+
"error.chain.checkConfig": "Revise provider/model no config",
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
t(key: string, vars?: Record<string, string | number>) {
|
|
25
|
+
const text = dict[key]
|
|
26
|
+
if (!text) return key
|
|
27
|
+
return fill(text, vars)
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const language = useLanguageMock()
|
|
33
|
+
|
|
34
|
+
describe("parseReadableConfigInvalidError", () => {
|
|
35
|
+
test("formats issues with file path", () => {
|
|
36
|
+
const error = {
|
|
37
|
+
name: "ConfigInvalidError",
|
|
38
|
+
data: {
|
|
39
|
+
path: "opencode.config.ts",
|
|
40
|
+
issues: [
|
|
41
|
+
{ path: ["settings", "host"], message: "Required" },
|
|
42
|
+
{ path: ["mode"], message: "Invalid" },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
} satisfies ConfigInvalidError
|
|
46
|
+
|
|
47
|
+
const result = parseReadableConfigInvalidError(error, language.t)
|
|
48
|
+
|
|
49
|
+
expect(result).toBe(
|
|
50
|
+
["Arquivo de config em opencode.config.ts invalido: settings.host: Required", "mode: Invalid"].join("\n"),
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("uses trimmed message when issues are missing", () => {
|
|
55
|
+
const error = {
|
|
56
|
+
name: "ConfigInvalidError",
|
|
57
|
+
data: {
|
|
58
|
+
path: "config",
|
|
59
|
+
message: " Bad value ",
|
|
60
|
+
},
|
|
61
|
+
} satisfies ConfigInvalidError
|
|
62
|
+
|
|
63
|
+
const result = parseReadableConfigInvalidError(error, language.t)
|
|
64
|
+
|
|
65
|
+
expect(result).toBe("Arquivo de config em config invalido: Bad value")
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe("formatServerError", () => {
|
|
70
|
+
test("formats config invalid errors", () => {
|
|
71
|
+
const error = {
|
|
72
|
+
name: "ConfigInvalidError",
|
|
73
|
+
data: {
|
|
74
|
+
message: "Missing host",
|
|
75
|
+
},
|
|
76
|
+
} satisfies ConfigInvalidError
|
|
77
|
+
|
|
78
|
+
const result = formatServerError(error, language.t)
|
|
79
|
+
|
|
80
|
+
expect(result).toBe("Arquivo de config em config invalido: Missing host")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("returns error messages", () => {
|
|
84
|
+
expect(formatServerError(new Error("Request failed with status 503"), language.t)).toBe(
|
|
85
|
+
"Request failed with status 503",
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("returns provided string errors", () => {
|
|
90
|
+
expect(formatServerError("Failed to connect to server", language.t)).toBe("Failed to connect to server")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("uses translated unknown fallback", () => {
|
|
94
|
+
expect(formatServerError(0, language.t)).toBe("Erro desconhecido")
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("falls back for unknown error objects and names", () => {
|
|
98
|
+
expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } }, language.t)).toBe(
|
|
99
|
+
"Erro desconhecido",
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test("formats provider model errors using provider/model", () => {
|
|
104
|
+
const error = {
|
|
105
|
+
name: "ProviderModelNotFoundError",
|
|
106
|
+
data: {
|
|
107
|
+
providerID: "openai",
|
|
108
|
+
modelID: "gpt-4.1",
|
|
109
|
+
},
|
|
110
|
+
} satisfies ProviderModelNotFoundError
|
|
111
|
+
|
|
112
|
+
expect(formatServerError(error, language.t)).toBe(
|
|
113
|
+
["Modelo nao encontrado: openai/gpt-4.1", "Revise provider/model no config"].join("\n"),
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("formats provider model suggestions", () => {
|
|
118
|
+
const error = {
|
|
119
|
+
name: "ProviderModelNotFoundError",
|
|
120
|
+
data: {
|
|
121
|
+
providerID: "x",
|
|
122
|
+
modelID: "y",
|
|
123
|
+
suggestions: ["x/y2", "x/y3"],
|
|
124
|
+
},
|
|
125
|
+
} satisfies ProviderModelNotFoundError
|
|
126
|
+
|
|
127
|
+
expect(formatServerError(error, language.t)).toBe(
|
|
128
|
+
["Modelo nao encontrado: x/y", "Voce quis dizer: x/y2, x/y3", "Revise provider/model no config"].join("\n"),
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export type ConfigInvalidError = {
|
|
2
|
+
name: "ConfigInvalidError"
|
|
3
|
+
data: {
|
|
4
|
+
path?: string
|
|
5
|
+
message?: string
|
|
6
|
+
issues?: Array<{ message: string; path: string[] }>
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ProviderModelNotFoundError = {
|
|
11
|
+
name: "ProviderModelNotFoundError"
|
|
12
|
+
data: {
|
|
13
|
+
providerID: string
|
|
14
|
+
modelID: string
|
|
15
|
+
suggestions?: string[]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Translator = (key: string, vars?: Record<string, string | number>) => string
|
|
20
|
+
|
|
21
|
+
function tr(translator: Translator | undefined, key: string, text: string, vars?: Record<string, string | number>) {
|
|
22
|
+
if (!translator) return text
|
|
23
|
+
const out = translator(key, vars)
|
|
24
|
+
if (!out || out === key) return text
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatServerError(error: unknown, translate?: Translator, fallback?: string) {
|
|
29
|
+
if (isConfigInvalidErrorLike(error)) return parseReadableConfigInvalidError(error, translate)
|
|
30
|
+
if (isProviderModelNotFoundErrorLike(error)) return parseReadableProviderModelNotFoundError(error, translate)
|
|
31
|
+
if (error instanceof Error && error.message) return error.message
|
|
32
|
+
if (typeof error === "string" && error) return error
|
|
33
|
+
if (fallback) return fallback
|
|
34
|
+
return tr(translate, "error.chain.unknown", "Unknown error")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
|
|
38
|
+
if (typeof error !== "object" || error === null) return false
|
|
39
|
+
const o = error as Record<string, unknown>
|
|
40
|
+
return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isProviderModelNotFoundErrorLike(error: unknown): error is ProviderModelNotFoundError {
|
|
44
|
+
if (typeof error !== "object" || error === null) return false
|
|
45
|
+
const o = error as Record<string, unknown>
|
|
46
|
+
return o.name === "ProviderModelNotFoundError" && typeof o.data === "object" && o.data !== null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseReadableConfigInvalidError(errorInput: ConfigInvalidError, translator?: Translator) {
|
|
50
|
+
const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "config"
|
|
51
|
+
const detail = errorInput.data.message?.trim() ?? ""
|
|
52
|
+
const issues = (errorInput.data.issues ?? [])
|
|
53
|
+
.map((issue) => {
|
|
54
|
+
const msg = issue.message.trim()
|
|
55
|
+
if (!issue.path.length) return msg
|
|
56
|
+
return `${issue.path.join(".")}: ${msg}`
|
|
57
|
+
})
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
const msg = issues.length ? issues.join("\n") : detail
|
|
60
|
+
if (!msg) return tr(translator, "error.chain.configInvalid", `Config file at ${file} is invalid`, { path: file })
|
|
61
|
+
return tr(translator, "error.chain.configInvalidWithMessage", `Config file at ${file} is invalid: ${msg}`, {
|
|
62
|
+
path: file,
|
|
63
|
+
message: msg,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseReadableProviderModelNotFoundError(errorInput: ProviderModelNotFoundError, translator?: Translator) {
|
|
68
|
+
const p = errorInput.data.providerID.trim()
|
|
69
|
+
const m = errorInput.data.modelID.trim()
|
|
70
|
+
const list = (errorInput.data.suggestions ?? []).map((v) => v.trim()).filter(Boolean)
|
|
71
|
+
const body = tr(translator, "error.chain.modelNotFound", `Model not found: ${p}/${m}`, { provider: p, model: m })
|
|
72
|
+
const tail = tr(translator, "error.chain.checkConfig", "Check your config (reigncode.json) provider/model names")
|
|
73
|
+
if (list.length) {
|
|
74
|
+
const suggestions = list.slice(0, 5).join(", ")
|
|
75
|
+
return [body, tr(translator, "error.chain.didYouMean", `Did you mean: ${suggestions}`, { suggestions }), tail].join(
|
|
76
|
+
"\n",
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
return [body, tail].join("\n")
|
|
80
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { ServerConnection } from "@/context/server"
|
|
3
|
+
import { checkServerHealth } from "./server-health"
|
|
4
|
+
|
|
5
|
+
const server: ServerConnection.HttpBase = {
|
|
6
|
+
url: "http://localhost:4096",
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
|
|
10
|
+
if (init?.signal) return init.signal
|
|
11
|
+
if (input instanceof Request) return input.signal
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("checkServerHealth", () => {
|
|
16
|
+
test("returns healthy response with version", async () => {
|
|
17
|
+
const fetch = (async () =>
|
|
18
|
+
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
|
19
|
+
status: 200,
|
|
20
|
+
headers: { "content-type": "application/json" },
|
|
21
|
+
})) as unknown as typeof globalThis.fetch
|
|
22
|
+
|
|
23
|
+
const result = await checkServerHealth(server, fetch)
|
|
24
|
+
|
|
25
|
+
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("returns unhealthy when request fails", async () => {
|
|
29
|
+
const fetch = (async () => {
|
|
30
|
+
throw new Error("network")
|
|
31
|
+
}) as unknown as typeof globalThis.fetch
|
|
32
|
+
|
|
33
|
+
const result = await checkServerHealth(server, fetch)
|
|
34
|
+
|
|
35
|
+
expect(result).toEqual({ healthy: false })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("uses timeout fallback when AbortSignal.timeout is unavailable", async () => {
|
|
39
|
+
const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout")
|
|
40
|
+
Object.defineProperty(AbortSignal, "timeout", {
|
|
41
|
+
configurable: true,
|
|
42
|
+
value: undefined,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
let aborted = false
|
|
46
|
+
const fetch = ((input: RequestInfo | URL, init?: RequestInit) =>
|
|
47
|
+
new Promise<Response>((_resolve, reject) => {
|
|
48
|
+
const signal = abortFromInput(input, init)
|
|
49
|
+
signal?.addEventListener(
|
|
50
|
+
"abort",
|
|
51
|
+
() => {
|
|
52
|
+
aborted = true
|
|
53
|
+
reject(new DOMException("Aborted", "AbortError"))
|
|
54
|
+
},
|
|
55
|
+
{ once: true },
|
|
56
|
+
)
|
|
57
|
+
})) as unknown as typeof globalThis.fetch
|
|
58
|
+
|
|
59
|
+
const result = await checkServerHealth(server, fetch, {
|
|
60
|
+
timeoutMs: 10,
|
|
61
|
+
}).finally(() => {
|
|
62
|
+
if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
|
|
63
|
+
if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
expect(aborted).toBe(true)
|
|
67
|
+
expect(result).toEqual({ healthy: false })
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("uses provided abort signal", async () => {
|
|
71
|
+
let signal: AbortSignal | undefined
|
|
72
|
+
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
73
|
+
signal = abortFromInput(input, init)
|
|
74
|
+
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
|
75
|
+
status: 200,
|
|
76
|
+
headers: { "content-type": "application/json" },
|
|
77
|
+
})
|
|
78
|
+
}) as unknown as typeof globalThis.fetch
|
|
79
|
+
|
|
80
|
+
const abort = new AbortController()
|
|
81
|
+
await checkServerHealth(server, fetch, {
|
|
82
|
+
signal: abort.signal,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
expect(signal).toBe(abort.signal)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("retries transient failures and eventually succeeds", async () => {
|
|
89
|
+
let count = 0
|
|
90
|
+
const fetch = (async () => {
|
|
91
|
+
count += 1
|
|
92
|
+
if (count < 3) throw new TypeError("network")
|
|
93
|
+
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
|
94
|
+
status: 200,
|
|
95
|
+
headers: { "content-type": "application/json" },
|
|
96
|
+
})
|
|
97
|
+
}) as unknown as typeof globalThis.fetch
|
|
98
|
+
|
|
99
|
+
const result = await checkServerHealth(server, fetch, {
|
|
100
|
+
retryCount: 2,
|
|
101
|
+
retryDelayMs: 1,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
expect(count).toBe(3)
|
|
105
|
+
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("returns unhealthy when retries are exhausted", async () => {
|
|
109
|
+
let count = 0
|
|
110
|
+
const fetch = (async () => {
|
|
111
|
+
count += 1
|
|
112
|
+
throw new TypeError("network")
|
|
113
|
+
}) as unknown as typeof globalThis.fetch
|
|
114
|
+
|
|
115
|
+
const result = await checkServerHealth(server, fetch, {
|
|
116
|
+
retryCount: 2,
|
|
117
|
+
retryDelayMs: 1,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(count).toBe(3)
|
|
121
|
+
expect(result).toEqual({ healthy: false })
|
|
122
|
+
})
|
|
123
|
+
})
|