@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.101
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +42 -34
- package/src/annotations/annotation-palette.ts +62 -0
- package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
- package/src/annotations/components/AnnotationCards.test.tsx +42 -0
- package/src/annotations/components/AnnotationCards.tsx +369 -0
- package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/annotations/components/DiscussionMarkerRail.tsx +186 -0
- package/src/annotations/discussions.ts +77 -0
- package/src/annotations/thread-selection.test.ts +210 -0
- package/src/annotations/thread-selection.ts +242 -0
- package/src/annotations/use-annotation-surface.tsx +1243 -0
- package/src/app/App.appearance.test.tsx +53 -0
- package/src/{App.autoclose.test.tsx → app/App.autoclose.test.tsx} +77 -51
- package/src/app/App.deleted-scroll.test.tsx +187 -0
- package/src/app/App.diff.test.tsx +313 -0
- package/src/app/App.export.test.tsx +105 -0
- package/src/app/App.focus.test.ts +89 -0
- package/src/app/App.identity.test.tsx +84 -0
- package/src/app/App.plan-render.test.tsx +209 -0
- package/src/app/App.plan-review.test.tsx +428 -0
- package/src/app/App.prototype-markdown.test.tsx +128 -0
- package/src/app/App.prototype.test.tsx +176 -0
- package/src/app/App.readonly.test.tsx +210 -0
- package/src/app/App.regression.test.tsx +99 -0
- package/src/app/App.rendering.test.tsx +134 -0
- package/src/app/App.share-dialog.test.tsx +177 -0
- package/src/app/App.submit-confirm.test.tsx +267 -0
- package/src/app/App.test.tsx +496 -0
- package/src/app/App.thread-agent.test.ts +20 -0
- package/src/app/App.toast.test.tsx +124 -0
- package/src/app/App.tsx +1667 -0
- package/src/app/App.visual.test.tsx +59 -0
- package/src/app/App.walk.test.tsx +299 -0
- package/src/app/App.workbench.test.tsx +1149 -0
- package/src/app/app-key-state.ts +61 -0
- package/src/app/app-screens.tsx +606 -0
- package/src/app/app-view-model.ts +168 -0
- package/src/app/components/AppHeader.stories.tsx +50 -0
- package/src/app/components/AppHeader.tsx +81 -0
- package/src/app/components/AppShell.brand.test.tsx +58 -0
- package/src/app/components/AppShell.extensions.test.tsx +191 -0
- package/src/app/components/AppShell.stories.tsx +89 -0
- package/src/app/components/AppShell.tsx +358 -0
- package/src/app/components/AppShell.zoom.test.tsx +149 -0
- package/src/app/components/Breadcrumb.stories.tsx +19 -0
- package/src/app/components/Breadcrumb.tsx +45 -0
- package/src/app/components/MenuBar.stories.tsx +28 -0
- package/src/app/components/MenuBar.tsx +44 -0
- package/src/app/components/PaneGrid.stories.tsx +84 -0
- package/src/app/components/PaneGrid.tsx +128 -0
- package/src/app/components/PanelColumn.stories.tsx +25 -0
- package/src/app/components/PanelColumn.tsx +93 -0
- package/src/app/components/ShellHeader.stories.tsx +50 -0
- package/src/app/components/ShellHeader.tsx +137 -0
- package/src/app/components/WorkspacePanels.stories.tsx +36 -0
- package/src/app/components/WorkspacePanels.tsx +120 -0
- package/src/app/components/pages/Workspace.stories.tsx +181 -0
- package/src/app/intent-dispatch.test.ts +728 -0
- package/src/app/intent-dispatch.ts +687 -0
- package/src/app/launch-layout.test.ts +78 -0
- package/src/app/launch-layout.ts +57 -0
- package/src/app/ready-signal.test.ts +85 -0
- package/src/app/ready-signal.ts +52 -0
- package/src/app/run.ts +109 -0
- package/src/{serve.test.ts → app/serve.test.ts} +24 -12
- package/src/app/serve.ts +111 -0
- package/src/app/version.ts +5 -0
- package/src/appearance/components/theme-context.stories.tsx +46 -0
- package/src/appearance/components/theme-context.tsx +37 -0
- package/src/appearance/theme-presets.test.ts +51 -0
- package/src/appearance/theme-presets.ts +219 -0
- package/src/appearance/theme.ts +112 -0
- package/src/appearance/visual/contrast.test.ts +35 -0
- package/src/appearance/visual/contrast.ts +33 -0
- package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/diff/components/AnnotatableFileView.test.tsx +157 -0
- package/src/diff/components/AnnotatableFileView.tsx +141 -0
- package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
- package/src/diff/components/DiffContentView.scroll.test.tsx +434 -0
- package/src/diff/components/DiffContentView.split.test.tsx +140 -0
- package/src/diff/components/DiffContentView.stories.tsx +80 -0
- package/src/diff/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/diff/components/DiffContentView.tsx +1055 -0
- package/src/diff/components/WalkWizard.stories.tsx +100 -0
- package/src/diff/components/WalkWizard.tsx +146 -0
- package/src/diff/components/diff-content-view-layout.test.ts +198 -0
- package/src/diff/components/diff-content-view-layout.ts +84 -0
- package/src/diff/diff-fold.test.ts +78 -0
- package/src/diff/diff-fold.ts +126 -0
- package/src/diff/diff-hunk-curate.test.ts +315 -0
- package/src/diff/diff-hunk-curate.ts +152 -0
- package/src/diff/diff-intraline.test.ts +239 -0
- package/src/diff/diff-intraline.ts +238 -0
- package/src/diff/diff-syntax.test.ts +125 -0
- package/src/diff/diff-syntax.ts +186 -0
- package/src/diff/split-diff.test.ts +110 -0
- package/src/diff/split-diff.ts +144 -0
- package/src/diff/view-diff.test.ts +325 -0
- package/src/diff/view-diff.ts +522 -0
- package/src/diff/walk.test.ts +121 -0
- package/src/diff/walk.ts +73 -0
- package/src/index.ts +31 -2
- package/src/integrations/attribution.test.ts +31 -0
- package/src/integrations/attribution.ts +42 -0
- package/src/integrations/client-extension-registry.test.tsx +61 -0
- package/src/integrations/client-extension-registry.ts +159 -0
- package/src/integrations/clipboard.test.ts +23 -0
- package/src/integrations/clipboard.ts +33 -0
- package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
- package/src/integrations/components/ExtensionZone.tsx +145 -0
- package/src/integrations/components/ShareDialog.agent.test.tsx +50 -0
- package/src/integrations/components/ShareDialog.stories.tsx +121 -0
- package/src/integrations/components/ShareDialog.tsx +574 -0
- package/src/integrations/components/share-dialog-store.ts +59 -0
- package/src/integrations/editor.test.ts +149 -0
- package/src/integrations/editor.ts +160 -0
- package/src/integrations/github-identity.test.ts +58 -0
- package/src/integrations/github-identity.ts +41 -0
- package/src/integrations/herdr.test.ts +43 -0
- package/src/integrations/herdr.ts +38 -0
- package/src/integrations/integrations.test.ts +41 -0
- package/src/integrations/integrations.ts +32 -0
- package/src/integrations/pasted-image.test.ts +67 -0
- package/src/integrations/pasted-image.ts +60 -0
- package/src/integrations/share.ts +238 -0
- package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
- package/src/keyboard/components/CompletionOverlay.tsx +75 -0
- package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
- package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
- package/src/keyboard/components/HandleChipsInput.tsx +143 -0
- package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
- package/src/keyboard/components/KeybindsDialog.tsx +103 -0
- package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
- package/src/keyboard/components/NavModeHint.tsx +31 -0
- package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
- package/src/keyboard/components/SlashComposer.tsx +87 -0
- package/src/keyboard/key-bindings.test.ts +156 -0
- package/src/keyboard/key-bindings.ts +459 -0
- package/src/keyboard/keymap.test.ts +611 -0
- package/src/keyboard/keymap.ts +395 -0
- package/src/keyboard/skills.test.ts +74 -0
- package/src/keyboard/skills.ts +75 -0
- package/src/keyboard/slash-palette.test.ts +103 -0
- package/src/keyboard/slash-palette.ts +129 -0
- package/src/keyboard/thread-chords.test.ts +79 -0
- package/src/keyboard/thread-chords.ts +163 -0
- package/src/keyboard/use-shared-keyboard.ts +55 -0
- package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
- package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
- package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
- package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
- package/src/markdown/components/ThreadView.stories.tsx +26 -0
- package/src/markdown/components/ThreadView.tsx +560 -0
- package/src/markdown/mark-runs.ts +121 -0
- package/src/markdown/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown/markdown-grid.test.ts +101 -0
- package/src/markdown/markdown-grid.ts +203 -0
- package/src/markdown/markdown-highlight.test.ts +61 -0
- package/src/markdown/markdown-highlight.ts +124 -0
- package/src/markdown/thread-code-syntax.ts +45 -0
- package/src/markdown/use-thread-body-editing.tsx +54 -0
- package/src/markdown/view-plan.regression.test.ts +60 -0
- package/src/markdown/view-plan.test.ts +545 -0
- package/src/markdown/view-plan.ts +1009 -0
- package/src/performance/otlp-trace.test.ts +119 -0
- package/src/performance/otlp-trace.ts +134 -0
- package/src/performance/perf-report.ts +18 -0
- package/src/performance/perf-timings.test.ts +18 -0
- package/src/performance/perf-timings.ts +39 -0
- package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
- package/src/prototype/components/PrototypeContentView.tsx +442 -0
- package/src/prototype/prototype-browser.test.ts +81 -0
- package/src/prototype/prototype-browser.ts +321 -0
- package/src/prototype/prototype-pixels.tsx +35 -0
- package/src/settings/components/SettingsDialog.stories.tsx +117 -0
- package/src/settings/components/SettingsDialog.tsx +183 -0
- package/src/settings/components/SettingsRows.stories.tsx +18 -0
- package/src/settings/components/SettingsRows.tsx +112 -0
- package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
- package/src/settings/components/quick-actions-editor.test.tsx +120 -0
- package/src/settings/components/quick-actions-editor.tsx +222 -0
- package/src/settings/config-identity.test.ts +78 -0
- package/src/settings/config.test.ts +676 -0
- package/src/settings/config.ts +628 -0
- package/src/settings/use-settings-dialog.tsx +351 -0
- package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
- package/src/stories/stories-app.tsx +196 -0
- package/src/stories/stories.test.tsx +74 -0
- package/src/stories/story-fixtures.ts +102 -0
- package/src/stories/story-tree.test.ts +108 -0
- package/src/stories/story.ts +154 -0
- package/src/terminal/ansi-screen.test.ts +28 -0
- package/src/terminal/ansi-screen.ts +72 -0
- package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
- package/src/terminal/components/AnsiScreen.tsx +28 -0
- package/src/terminal/components/terminal-pane.test.tsx +40 -0
- package/src/terminal/components/terminal-pane.ts +196 -0
- package/src/terminal/ghostty-terminal.test.ts +85 -0
- package/src/terminal/ghostty-terminal.ts +197 -0
- package/src/terminal/kitty-image.test.ts +66 -0
- package/src/terminal/kitty-image.ts +137 -0
- package/src/terminal/pty.test.ts +81 -0
- package/src/terminal/pty.ts +246 -0
- package/src/testing/test-support.test.ts +264 -0
- package/src/testing/test-support.ts +367 -0
- package/src/thread/agent-continuation.test.ts +32 -0
- package/src/thread/agent-continuation.ts +13 -0
- package/src/thread/agent-transcript.test.ts +161 -0
- package/src/thread/agent-transcript.ts +336 -0
- package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
- package/src/thread/components/AgentConfigControls.test.tsx +112 -0
- package/src/thread/components/AgentConfigControls.tsx +114 -0
- package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
- package/src/thread/components/AgentThreadPane.test.tsx +935 -0
- package/src/thread/components/AgentThreadPane.tsx +377 -0
- package/src/thread/components/ThreadFooter.stories.tsx +30 -0
- package/src/thread/components/ThreadFooter.test.tsx +49 -0
- package/src/thread/components/ThreadFooter.tsx +77 -0
- package/src/thread/components/ThreadTree.stories.tsx +46 -0
- package/src/thread/components/ThreadTree.test.tsx +41 -0
- package/src/thread/components/ThreadTree.tsx +267 -0
- package/src/thread/components/thread-tree.test.ts +137 -0
- package/src/thread/components/thread-tree.ts +112 -0
- package/src/thread/thread-controller.curate.test.ts +502 -0
- package/src/thread/thread-controller.derivation.test.ts +62 -0
- package/src/thread/thread-controller.live-diff.test.ts +357 -0
- package/src/thread/thread-controller.optimistic.test.ts +69 -0
- package/src/thread/thread-controller.share.test.ts +573 -0
- package/src/thread/thread-controller.switch-cache.test.ts +174 -0
- package/src/thread/thread-controller.tree.test.ts +263 -0
- package/src/thread/thread-controller.ts +2026 -0
- package/src/thread/tree-view.test.ts +128 -0
- package/src/thread/tree-view.ts +146 -0
- package/src/thread/use-thread-agent.test.tsx +208 -0
- package/src/thread/use-thread-agent.ts +117 -0
- package/src/ui/components/CodeBlock.stories.tsx +42 -0
- package/src/ui/components/CodeBlock.tsx +80 -0
- package/src/ui/components/ConfirmCard.stories.tsx +37 -0
- package/src/ui/components/ConfirmCard.tsx +151 -0
- package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
- package/src/ui/components/ConfirmDialog.tsx +72 -0
- package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
- package/src/ui/components/OverlayScrollbar.tsx +129 -0
- package/src/ui/components/PromptDialog.stories.tsx +36 -0
- package/src/ui/components/PromptDialog.test.tsx +65 -0
- package/src/ui/components/PromptDialog.tsx +99 -0
- package/src/ui/components/RootOverlay.stories.tsx +45 -0
- package/src/ui/components/RootOverlay.tsx +49 -0
- package/src/ui/components/ScrollArea.stories.tsx +20 -0
- package/src/ui/components/ScrollArea.tsx +61 -0
- package/src/ui/components/Toast.stories.tsx +19 -0
- package/src/ui/components/Toast.tsx +54 -0
- package/src/ui/components/Tooltip.stories.tsx +26 -0
- package/src/ui/components/Tooltip.tsx +72 -0
- package/src/ui/components/menu-control.stories.tsx +35 -0
- package/src/ui/components/menu-control.tsx +43 -0
- package/src/ui/components/primitives/Button.stories.tsx +32 -0
- package/src/ui/components/primitives/Button.tsx +57 -0
- package/src/ui/components/primitives/Card.stories.tsx +25 -0
- package/src/ui/components/primitives/Card.tsx +62 -0
- package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
- package/src/ui/components/primitives/Dialog.test.tsx +38 -0
- package/src/ui/components/primitives/Dialog.tsx +69 -0
- package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/ui/components/primitives/DialogActions.tsx +39 -0
- package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
- package/src/ui/components/primitives/IconButton.tsx +58 -0
- package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/ui/components/primitives/StatusBar.tsx +23 -0
- package/src/ui/components/primitives/Switch.stories.tsx +17 -0
- package/src/ui/components/primitives/Switch.tsx +32 -0
- package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
- package/src/ui/components/primitives/Tabs.tsx +101 -0
- package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/ui/components/primitives/Toolbar.tsx +14 -0
- package/src/ui/components/primitives/Tree.stories.tsx +124 -0
- package/src/ui/components/primitives/Tree.tsx +133 -0
- package/src/ui/components/primitives/frame.test.ts +12 -0
- package/src/ui/components/primitives/frame.ts +17 -0
- package/src/ui/components/primitives/icons.ts +61 -0
- package/src/ui/components/primitives/tree-model.test.ts +99 -0
- package/src/ui/components/primitives/tree-model.ts +137 -0
- package/src/ui/components/syntax-highlight.ts +133 -0
- package/src/ui/components/truncate-text.ts +7 -0
- package/src/ui/components/truncate-title.test.ts +31 -0
- package/src/ui/components/truncate-title.ts +15 -0
- package/src/ui/use-frame-measure.ts +74 -0
- package/src/ui/use-terminal-virtualizer.ts +259 -0
- package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
- package/src/workbench/components/ChangesColumn.tsx +203 -0
- package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
- package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
- package/src/workbench/components/EditorGrid.stories.tsx +86 -0
- package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/workbench/components/EditorGrid.tsx +544 -0
- package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
- package/src/workbench/components/FileContentsView.stories.tsx +16 -0
- package/src/workbench/components/FileContentsView.test.tsx +29 -0
- package/src/workbench/components/FileContentsView.tsx +100 -0
- package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
- package/src/workbench/components/GridTabContent.stories.tsx +59 -0
- package/src/workbench/components/GridTabContent.tsx +193 -0
- package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
- package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
- package/src/workbench/components/ProjectTreeView.tsx +151 -0
- package/src/workbench/components/TreePane.stories.tsx +106 -0
- package/src/workbench/components/TreePane.tsx +155 -0
- package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
- package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
- package/src/workbench/components/WelcomePlayground.tsx +138 -0
- package/src/workbench/components/editor-grid.test.ts +184 -0
- package/src/workbench/components/editor-grid.ts +281 -0
- package/src/workbench/components/file-tree.test.ts +60 -0
- package/src/workbench/components/file-tree.ts +112 -0
- package/src/workbench/use-changes-workbench.ts +205 -0
- package/src/workbench/use-remember-layout.ts +31 -0
- package/src/workbench/use-repo-changes.ts +91 -0
- package/src/workbench/workbench-snapshot.test.ts +99 -0
- package/src/workbench/workbench-snapshot.ts +59 -0
- package/src/App.diff.test.tsx +0 -99
- package/src/App.export.test.tsx +0 -88
- package/src/App.plan-review.test.tsx +0 -259
- package/src/App.readonly.test.tsx +0 -148
- package/src/App.rendering.test.tsx +0 -108
- package/src/App.submit-confirm.test.tsx +0 -183
- package/src/App.test.tsx +0 -218
- package/src/App.tsx +0 -1249
- package/src/config.test.ts +0 -116
- package/src/config.ts +0 -150
- package/src/editor.ts +0 -34
- package/src/herdr.test.ts +0 -68
- package/src/herdr.ts +0 -34
- package/src/keymap.test.ts +0 -270
- package/src/keymap.ts +0 -173
- package/src/run.ts +0 -30
- package/src/serve.ts +0 -75
- package/src/session-controller.ts +0 -398
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/theme.ts +0 -46
- package/src/view-diff.test.ts +0 -55
- package/src/view-diff.ts +0 -86
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -364
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createElement, type ReactElement } from "react";
|
|
6
|
+
import type { KeyEvent, MouseEvent } from "@opentui/core";
|
|
7
|
+
import type { TestRendererSetup } from "@opentui/core/testing";
|
|
8
|
+
import { useKeyboard } from "@opentui/react";
|
|
9
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
10
|
+
import { loadConfig } from "../settings/config";
|
|
11
|
+
import {
|
|
12
|
+
clickText,
|
|
13
|
+
dragText,
|
|
14
|
+
frameRow,
|
|
15
|
+
isolateUserConfig,
|
|
16
|
+
locateText,
|
|
17
|
+
locateTextInFrame,
|
|
18
|
+
pressKey,
|
|
19
|
+
waitForText,
|
|
20
|
+
type FrameLocation,
|
|
21
|
+
} from "./test-support";
|
|
22
|
+
|
|
23
|
+
describe("isolateUserConfig", () => {
|
|
24
|
+
test("loadConfig sees defaults while isolated and the env is restored after", () => {
|
|
25
|
+
// Arrange
|
|
26
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-isolate-"));
|
|
27
|
+
const userConfigPath = join(home, "config.toml");
|
|
28
|
+
|
|
29
|
+
writeFileSync(userConfigPath, "[ui]\nauto_close = 5\n");
|
|
30
|
+
const priorEnv = process.env.CUELOOP_CONFIG;
|
|
31
|
+
|
|
32
|
+
process.env.CUELOOP_CONFIG = userConfigPath;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
// Act
|
|
36
|
+
const restoreUserConfig = isolateUserConfig(home);
|
|
37
|
+
const isolated = loadConfig({ repoRoot: home });
|
|
38
|
+
|
|
39
|
+
restoreUserConfig();
|
|
40
|
+
const restored = loadConfig({ repoRoot: home });
|
|
41
|
+
|
|
42
|
+
// Assert
|
|
43
|
+
expect(isolated.ui.autoClose).toBe("off");
|
|
44
|
+
expect(process.env.CUELOOP_CONFIG).toBe(userConfigPath);
|
|
45
|
+
expect(restored.ui.autoClose).toBe(5);
|
|
46
|
+
} finally {
|
|
47
|
+
if (priorEnv === undefined) delete process.env.CUELOOP_CONFIG;
|
|
48
|
+
else process.env.CUELOOP_CONFIG = priorEnv;
|
|
49
|
+
rmSync(home, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("restore removes the variable when nothing was set before", () => {
|
|
54
|
+
// Arrange
|
|
55
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-isolate-"));
|
|
56
|
+
const priorEnv = process.env.CUELOOP_CONFIG;
|
|
57
|
+
|
|
58
|
+
delete process.env.CUELOOP_CONFIG;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Act
|
|
62
|
+
const restoreUserConfig = isolateUserConfig(home);
|
|
63
|
+
const pointedAt = process.env.CUELOOP_CONFIG ?? "";
|
|
64
|
+
|
|
65
|
+
restoreUserConfig();
|
|
66
|
+
|
|
67
|
+
// Assert
|
|
68
|
+
expect(pointedAt).toBe(join(home, "no-config.toml"));
|
|
69
|
+
expect(process.env.CUELOOP_CONFIG).toBeUndefined();
|
|
70
|
+
} finally {
|
|
71
|
+
if (priorEnv !== undefined) process.env.CUELOOP_CONFIG = priorEnv;
|
|
72
|
+
rmSync(home, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
interface RecordedKey {
|
|
78
|
+
name: string;
|
|
79
|
+
ctrl: boolean;
|
|
80
|
+
shift: boolean;
|
|
81
|
+
meta: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface RecordedMouseEvent {
|
|
85
|
+
type: "down" | "up";
|
|
86
|
+
x: number;
|
|
87
|
+
y: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface FixtureLog {
|
|
91
|
+
keys: RecordedKey[];
|
|
92
|
+
mouseEvents: RecordedMouseEvent[];
|
|
93
|
+
pressZoneClicks: number[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A minimal interaction surface: one keyboard recorder, a root box recording
|
|
98
|
+
* down/up coordinates, and stacked text rows at known positions.
|
|
99
|
+
*/
|
|
100
|
+
function InteractionFixture(properties: { log: FixtureLog }): ReactElement {
|
|
101
|
+
const { log } = properties;
|
|
102
|
+
|
|
103
|
+
useKeyboard((key: KeyEvent) => {
|
|
104
|
+
log.keys.push({ name: key.name, ctrl: key.ctrl, shift: key.shift, meta: key.meta });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
return createElement(
|
|
108
|
+
"box",
|
|
109
|
+
{
|
|
110
|
+
width: "100%",
|
|
111
|
+
height: "100%",
|
|
112
|
+
flexDirection: "column",
|
|
113
|
+
onMouseDown: (event: MouseEvent) => {
|
|
114
|
+
log.mouseEvents.push({ type: "down", x: event.x, y: event.y });
|
|
115
|
+
},
|
|
116
|
+
onMouseUp: (event: MouseEvent) => {
|
|
117
|
+
log.mouseEvents.push({ type: "up", x: event.x, y: event.y });
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
createElement("text", { content: "header line" }),
|
|
121
|
+
createElement("text", { content: " shifted needle" }),
|
|
122
|
+
createElement("text", { content: " drag from here" }),
|
|
123
|
+
createElement("text", { content: " drop to here" }),
|
|
124
|
+
createElement("text", {
|
|
125
|
+
content: "press zone",
|
|
126
|
+
onMouseDown: (event: MouseEvent) => {
|
|
127
|
+
log.pressZoneClicks.push(event.x);
|
|
128
|
+
},
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function renderFixture(): Promise<{ setup: TestRendererSetup; log: FixtureLog }> {
|
|
134
|
+
const log: FixtureLog = { keys: [], mouseEvents: [], pressZoneClicks: [] };
|
|
135
|
+
const setup = await testRender(createElement(InteractionFixture, { log }), {
|
|
136
|
+
width: 40,
|
|
137
|
+
height: 8,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await waitForText(setup, "press zone");
|
|
141
|
+
|
|
142
|
+
return { setup, log };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe("pressKey", () => {
|
|
146
|
+
test("a ctrl+letter chord reaches the keyboard handler with ctrl set", async () => {
|
|
147
|
+
// Arrange
|
|
148
|
+
const { setup, log } = await renderFixture();
|
|
149
|
+
|
|
150
|
+
// Act
|
|
151
|
+
await pressKey(setup, "o", { ctrl: true });
|
|
152
|
+
|
|
153
|
+
// Assert
|
|
154
|
+
expect(log.keys).toEqual([{ name: "o", ctrl: true, shift: false, meta: false }]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a named key with a modifier arrives as the modified key", async () => {
|
|
158
|
+
// Arrange
|
|
159
|
+
const { setup, log } = await renderFixture();
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
await pressKey(setup, "ARROW_RIGHT", { shift: true });
|
|
163
|
+
|
|
164
|
+
// Assert
|
|
165
|
+
expect(log.keys).toEqual([{ name: "right", ctrl: false, shift: true, meta: false }]);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("locateText", () => {
|
|
170
|
+
test("returns the 0-based row and start column of the first occurrence", async () => {
|
|
171
|
+
// Arrange
|
|
172
|
+
const { setup } = await renderFixture();
|
|
173
|
+
|
|
174
|
+
// Act
|
|
175
|
+
const located = locateText(setup, "shifted needle");
|
|
176
|
+
|
|
177
|
+
// Assert
|
|
178
|
+
expect(located).toEqual({ row: 1, column: 3 });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("a miss throws with the needle and the full frame in the message", async () => {
|
|
182
|
+
// Arrange
|
|
183
|
+
const { setup } = await renderFixture();
|
|
184
|
+
|
|
185
|
+
// Act
|
|
186
|
+
const locateMissing = (): FrameLocation => locateText(setup, "absent needle");
|
|
187
|
+
|
|
188
|
+
// Assert
|
|
189
|
+
expect(locateMissing).toThrow('"absent needle"');
|
|
190
|
+
expect(locateMissing).toThrow("header line");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("frameRow", () => {
|
|
195
|
+
test("returns rows that reflect the vertical layout", async () => {
|
|
196
|
+
// Arrange
|
|
197
|
+
const { setup } = await renderFixture();
|
|
198
|
+
|
|
199
|
+
// Act / Assert
|
|
200
|
+
expect(frameRow(setup, "header line")).toBe(0);
|
|
201
|
+
expect(frameRow(setup, "drop to here")).toBe(3);
|
|
202
|
+
expect(frameRow(setup, "press zone")).toBe(4);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("clickText", () => {
|
|
207
|
+
test("lands the click on the element rendering the needle", async () => {
|
|
208
|
+
// Arrange
|
|
209
|
+
const { setup, log } = await renderFixture();
|
|
210
|
+
|
|
211
|
+
// Act
|
|
212
|
+
await clickText(setup, "press zone");
|
|
213
|
+
|
|
214
|
+
// Assert
|
|
215
|
+
expect(log.pressZoneClicks).toEqual([0]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("charOffset shifts the click within the needle", async () => {
|
|
219
|
+
// Arrange
|
|
220
|
+
const { setup, log } = await renderFixture();
|
|
221
|
+
|
|
222
|
+
// Act
|
|
223
|
+
await clickText(setup, "press zone", 6);
|
|
224
|
+
|
|
225
|
+
// Assert
|
|
226
|
+
expect(log.pressZoneClicks).toEqual([6]);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("dragText", () => {
|
|
231
|
+
test("delivers the down at the from-text and the up at the to-text", async () => {
|
|
232
|
+
// Arrange
|
|
233
|
+
const { setup, log } = await renderFixture();
|
|
234
|
+
|
|
235
|
+
// Act
|
|
236
|
+
await dragText(setup, "drag from here", "drop to here");
|
|
237
|
+
|
|
238
|
+
// Assert
|
|
239
|
+
expect(log.mouseEvents).toContainEqual({ type: "down", x: 2, y: 2 });
|
|
240
|
+
expect(log.mouseEvents).toContainEqual({ type: "up", x: 5, y: 3 });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("an end offset lands the up that many characters past the to-text", async () => {
|
|
244
|
+
// Arrange
|
|
245
|
+
const { setup, log } = await renderFixture();
|
|
246
|
+
|
|
247
|
+
// Act
|
|
248
|
+
await dragText(setup, "drag from here", "drop to here", 4);
|
|
249
|
+
|
|
250
|
+
// Assert
|
|
251
|
+
expect(log.mouseEvents).toContainEqual({ type: "up", x: 9, y: 3 });
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("locateTextInFrame", () => {
|
|
256
|
+
test("finds the 0-based row and column of the first match, null when absent", () => {
|
|
257
|
+
// Arrange
|
|
258
|
+
const frame = "header\n ● draft here\nfooter";
|
|
259
|
+
|
|
260
|
+
// Act + Assert
|
|
261
|
+
expect(locateTextInFrame(frame, "draft")).toEqual({ row: 1, column: 4 });
|
|
262
|
+
expect(locateTextInFrame(frame, "missing")).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared driving helpers for the virtual-terminal App suites: an input settles
|
|
3
|
+
* when its painted frame stops changing across consecutive event-loop turns
|
|
4
|
+
* (macrotask yield, render pass, visual-idle wait per turn) - no fixed-duration
|
|
5
|
+
* sleeps. Waits on daemon round-trips go through waitForFrame/waitFor with
|
|
6
|
+
* generous pass budgets.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { cloneElement, isValidElement, type ReactNode } from "react";
|
|
11
|
+
import type { TestRendererOptions, TestRendererSetup } from "@opentui/core/testing";
|
|
12
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
13
|
+
import type { AppProps } from "../app/App";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A probe for the App's ready signal in an in-process suite: pass `onReady`
|
|
17
|
+
* to the App and await `ready` before the first interaction, instead of
|
|
18
|
+
* waiting for a piece of text and hoping the keyboard is bound by then.
|
|
19
|
+
*/
|
|
20
|
+
export interface AppReadyProbe {
|
|
21
|
+
onReady: () => void;
|
|
22
|
+
ready: Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function appReadyProbe(): AppReadyProbe {
|
|
26
|
+
let resolve: () => void = () => {};
|
|
27
|
+
const ready = new Promise<void>((done) => {
|
|
28
|
+
resolve = done;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return { onReady: () => resolve(), ready };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Boot an App element and resolve once it has fired its ready signal: the
|
|
36
|
+
* first usable screen is painted and its keyboard handlers are subscribed.
|
|
37
|
+
* Every App suite starts here so no boot waits on a piece of text and hopes.
|
|
38
|
+
*/
|
|
39
|
+
export async function renderReadyApp(
|
|
40
|
+
element: ReactNode,
|
|
41
|
+
options: TestRendererOptions,
|
|
42
|
+
): Promise<TestRendererSetup> {
|
|
43
|
+
if (!isValidElement<AppProps>(element)) throw new Error("renderReadyApp needs an App element");
|
|
44
|
+
const probe = appReadyProbe();
|
|
45
|
+
const setup = await testRender(cloneElement(element, { onReady: probe.onReady }), options);
|
|
46
|
+
|
|
47
|
+
await probe.ready;
|
|
48
|
+
|
|
49
|
+
return setup;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The CUELOOP_CONFIG path that isolates a test from the developer's real user
|
|
54
|
+
* config: a file inside the test home that does not exist unless the test
|
|
55
|
+
* writes it. Subprocess harnesses set this in the child env; in-process suites
|
|
56
|
+
* use `isolateUserConfig`.
|
|
57
|
+
*/
|
|
58
|
+
export function isolatedUserConfigPath(home: string, fileName = "no-config.toml"): string {
|
|
59
|
+
return join(home, fileName);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Isolate the user config: point CUELOOP_CONFIG into the test home so
|
|
64
|
+
* loadConfig never reads the developer's real ~/.config/cueloop (a persisted
|
|
65
|
+
* review_state would change what char frames render locally while CI stays
|
|
66
|
+
* clean). The default file name does not exist, so loadConfig returns
|
|
67
|
+
* defaults; pass a file name for suites that write and assert a config of
|
|
68
|
+
* their own. Call in beforeEach; invoke the returned restore in afterEach.
|
|
69
|
+
*/
|
|
70
|
+
export function isolateUserConfig(home: string, fileName?: string): () => void {
|
|
71
|
+
const priorUserConfig = process.env.CUELOOP_CONFIG;
|
|
72
|
+
|
|
73
|
+
process.env.CUELOOP_CONFIG = isolatedUserConfigPath(home, fileName);
|
|
74
|
+
|
|
75
|
+
return () => {
|
|
76
|
+
if (priorUserConfig === undefined) delete process.env.CUELOOP_CONFIG;
|
|
77
|
+
else process.env.CUELOOP_CONFIG = priorUserConfig;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
declare global {
|
|
82
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The harness drives the real event loop by design; keep React's act warning off. */
|
|
86
|
+
export function allowEventLoopUpdates(): void {
|
|
87
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Turns of the event loop a frame must survive unchanged before an input counts as settled. */
|
|
91
|
+
const SETTLE_QUIET_TURNS = 2;
|
|
92
|
+
|
|
93
|
+
/** Upper bound on settle turns, so a surface that keeps changing never stalls a test. */
|
|
94
|
+
const SETTLE_MAX_TURNS = 40;
|
|
95
|
+
|
|
96
|
+
/** The painted frame with its colors, so a caret cell (background only) counts as a change. */
|
|
97
|
+
function paintedFrame(setup: TestRendererSetup): string {
|
|
98
|
+
return JSON.stringify(
|
|
99
|
+
setup
|
|
100
|
+
.captureSpans()
|
|
101
|
+
.lines.map((line) =>
|
|
102
|
+
line.spans.map((span) => [span.text, span.fg?.toInts(), span.bg?.toInts()]),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Wait for an input to land: React commits its state on its own scheduler, which the renderer's
|
|
109
|
+
* idle check cannot see, so on a slow machine a single idle wait returns between the key and its
|
|
110
|
+
* paint. Instead, run turns of the loop until the painted frame is unchanged across
|
|
111
|
+
* `SETTLE_QUIET_TURNS` consecutive turns.
|
|
112
|
+
*/
|
|
113
|
+
export async function settle(setup: TestRendererSetup): Promise<void> {
|
|
114
|
+
allowEventLoopUpdates();
|
|
115
|
+
let previous = "";
|
|
116
|
+
let quietTurns = 0;
|
|
117
|
+
|
|
118
|
+
for (let turn = 0; turn < SETTLE_MAX_TURNS; turn++) {
|
|
119
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
120
|
+
await setup.renderOnce();
|
|
121
|
+
await setup.waitForVisualIdle();
|
|
122
|
+
const frame = paintedFrame(setup);
|
|
123
|
+
|
|
124
|
+
quietTurns = frame === previous ? quietTurns + 1 : 0;
|
|
125
|
+
if (quietTurns >= SETTLE_QUIET_TURNS) return;
|
|
126
|
+
previous = frame;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Drive one key press: letters type as text; named keys use KeyCodes ids. */
|
|
131
|
+
export async function press(setup: TestRendererSetup, key: string): Promise<void> {
|
|
132
|
+
if (key === "enter") setup.mockInput.pressKey("RETURN");
|
|
133
|
+
else if (key === "escape") setup.mockInput.pressKey("ESCAPE");
|
|
134
|
+
else if (key === "backspace") setup.mockInput.pressKey("BACKSPACE");
|
|
135
|
+
else if (key === "left") setup.mockInput.pressKey("ARROW_LEFT");
|
|
136
|
+
else if (key === "right") setup.mockInput.pressKey("ARROW_RIGHT");
|
|
137
|
+
else if (key === "up") setup.mockInput.pressKey("ARROW_UP");
|
|
138
|
+
else if (key === "down") setup.mockInput.pressKey("ARROW_DOWN");
|
|
139
|
+
else return typeText(setup, key);
|
|
140
|
+
await settle(setup);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function typeText(setup: TestRendererSetup, text: string): Promise<void> {
|
|
144
|
+
await setup.mockInput.typeText(text);
|
|
145
|
+
await settle(setup);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Drive one key press with modifiers: KeyCodes names ("ARROW_RIGHT") or single letters (ctrl+o). */
|
|
149
|
+
export async function pressKey(
|
|
150
|
+
setup: TestRendererSetup,
|
|
151
|
+
key: string,
|
|
152
|
+
modifiers?: { shift?: boolean; ctrl?: boolean; meta?: boolean },
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
setup.mockInput.pressKey(key, modifiers);
|
|
155
|
+
await settle(setup);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Run a thread nav-mode command: esc drops the surface into nav mode, then a bare
|
|
160
|
+
* key acts (a letter, or a named key like "enter"/"backspace"). The caret and any
|
|
161
|
+
* held selection carry into nav, so the command lands where the caret is.
|
|
162
|
+
*/
|
|
163
|
+
export async function navCommand(setup: TestRendererSetup, key: string): Promise<void> {
|
|
164
|
+
await press(setup, "escape");
|
|
165
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
166
|
+
await settle(setup);
|
|
167
|
+
// a single letter is a discrete keypress (nav command); named keys route through press
|
|
168
|
+
if (key.length === 1) await pressKey(setup, key);
|
|
169
|
+
else await press(setup, key);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A 0-based cell position within a captured char frame. */
|
|
173
|
+
export interface FrameLocation {
|
|
174
|
+
row: number;
|
|
175
|
+
column: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Locate the first line of a text frame containing the needle; null when absent. */
|
|
179
|
+
export function locateTextInFrame(frame: string, needle: string): FrameLocation | null {
|
|
180
|
+
for (const [row, line] of frame.split("\n").entries()) {
|
|
181
|
+
const column = line.indexOf(needle);
|
|
182
|
+
|
|
183
|
+
if (column !== -1) return { row, column };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Locate the first visual line containing the needle: 0-based row and start column. */
|
|
190
|
+
export function locateText(setup: TestRendererSetup, needle: string): FrameLocation {
|
|
191
|
+
const frame = setup.captureCharFrame();
|
|
192
|
+
const location = locateTextInFrame(frame, needle);
|
|
193
|
+
|
|
194
|
+
if (location === null) {
|
|
195
|
+
throw new Error(`locateText ${JSON.stringify(needle)} not found.\nframe:\n${frame}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return location;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The 0-based visual row of the needle - for relative vertical-layout assertions. */
|
|
202
|
+
export function frameRow(setup: TestRendererSetup, needle: string): number {
|
|
203
|
+
return locateText(setup, needle).row;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Click the first on-screen occurrence of the needle, offset in characters from its start. */
|
|
207
|
+
export async function clickText(
|
|
208
|
+
setup: TestRendererSetup,
|
|
209
|
+
needle: string,
|
|
210
|
+
charOffset = 0,
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
const { row, column } = locateText(setup, needle);
|
|
213
|
+
|
|
214
|
+
await setup.mockMouse.click(column + charOffset, row);
|
|
215
|
+
await settle(setup);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Drag from the first occurrence of one needle to the first occurrence of
|
|
220
|
+
* another, `toCharOffset` characters past the second needle's start - so a
|
|
221
|
+
* character-precise selection can end inside or right after a word.
|
|
222
|
+
*/
|
|
223
|
+
export async function dragText(
|
|
224
|
+
setup: TestRendererSetup,
|
|
225
|
+
fromNeedle: string,
|
|
226
|
+
toNeedle: string,
|
|
227
|
+
toCharOffset = 0,
|
|
228
|
+
): Promise<void> {
|
|
229
|
+
const from = locateText(setup, fromNeedle);
|
|
230
|
+
const to = locateText(setup, toNeedle);
|
|
231
|
+
|
|
232
|
+
await setup.mockMouse.drag(from.column, from.row, to.column + toCharOffset, to.row);
|
|
233
|
+
await settle(setup);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Below the 60s per-test budget so a genuinely stuck wait throws its frame
|
|
237
|
+
// error (useful) before bun's bare "timed out" fires. Generous because a
|
|
238
|
+
// contended CI runner starves the render loop and daemon round-trips.
|
|
239
|
+
const WAIT_DEADLINE_MS = 45_000;
|
|
240
|
+
|
|
241
|
+
/** One macrotask turn: hands the event loop back so the in-process daemon can
|
|
242
|
+
* read its socket and apply the write before the next poll. Tight render-pass
|
|
243
|
+
* bursts starve that read on a contended CI runner - the round-trip then never
|
|
244
|
+
* lands and the wait hangs the whole budget. */
|
|
245
|
+
function yieldEventLoop(): Promise<void> {
|
|
246
|
+
return new Promise((resolve) => setTimeout(resolve, 2));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Log a still-waiting line at most this often, so only genuinely slow (i.e.
|
|
250
|
+
* flaking) waits are noisy; fast waits stay silent. */
|
|
251
|
+
const PROGRESS_LOG_MS = 4000;
|
|
252
|
+
|
|
253
|
+
/** The last render frame's tail, so a progress line shows what is on screen. */
|
|
254
|
+
function frameTail(frame: string, rows = 6): string {
|
|
255
|
+
return frame.split("\n").slice(-rows).join("\n");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Poll gently: one render pass, one frame check, one macrotask yield. No
|
|
260
|
+
* multi-pass bursts, so the daemon (same event loop) always gets a turn to
|
|
261
|
+
* process IO between checks. The deadline bounds the whole wait. Slow waits log
|
|
262
|
+
* progress (poll count + frame-change count) so a CI stall shows whether the
|
|
263
|
+
* render froze or the content simply never arrived.
|
|
264
|
+
*/
|
|
265
|
+
async function waitForFramePredicate(
|
|
266
|
+
setup: TestRendererSetup,
|
|
267
|
+
predicate: (frame: string) => boolean,
|
|
268
|
+
label: string,
|
|
269
|
+
): Promise<string> {
|
|
270
|
+
allowEventLoopUpdates();
|
|
271
|
+
const start = Date.now();
|
|
272
|
+
const deadline = start + WAIT_DEADLINE_MS;
|
|
273
|
+
let polls = 0;
|
|
274
|
+
let frameChanges = 0;
|
|
275
|
+
let lastFrame = "";
|
|
276
|
+
let lastLog = start;
|
|
277
|
+
|
|
278
|
+
for (;;) {
|
|
279
|
+
await setup.renderOnce();
|
|
280
|
+
const frame = setup.captureCharFrame();
|
|
281
|
+
|
|
282
|
+
polls++;
|
|
283
|
+
if (frame !== lastFrame) {
|
|
284
|
+
frameChanges++;
|
|
285
|
+
lastFrame = frame;
|
|
286
|
+
}
|
|
287
|
+
if (predicate(frame)) return frame;
|
|
288
|
+
const now = Date.now();
|
|
289
|
+
|
|
290
|
+
if (now > deadline) {
|
|
291
|
+
const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
|
|
292
|
+
|
|
293
|
+
throw new Error(
|
|
294
|
+
`waitFor ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
298
|
+
lastLog = now;
|
|
299
|
+
console.error(
|
|
300
|
+
`[waitFor] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes\n${frameTail(frame)}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
await yieldEventLoop();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Wait until a state predicate holds (daemon round-trips included). */
|
|
308
|
+
export async function waitForState(
|
|
309
|
+
setup: TestRendererSetup,
|
|
310
|
+
predicate: () => boolean,
|
|
311
|
+
label = "state",
|
|
312
|
+
): Promise<void> {
|
|
313
|
+
allowEventLoopUpdates();
|
|
314
|
+
const start = Date.now();
|
|
315
|
+
const deadline = start + WAIT_DEADLINE_MS;
|
|
316
|
+
let polls = 0;
|
|
317
|
+
let frameChanges = 0;
|
|
318
|
+
let lastFrame = "";
|
|
319
|
+
let lastLog = start;
|
|
320
|
+
|
|
321
|
+
for (;;) {
|
|
322
|
+
if (predicate()) return;
|
|
323
|
+
await setup.renderOnce();
|
|
324
|
+
const frame = setup.captureCharFrame();
|
|
325
|
+
|
|
326
|
+
polls++;
|
|
327
|
+
if (frame !== lastFrame) {
|
|
328
|
+
frameChanges++;
|
|
329
|
+
lastFrame = frame;
|
|
330
|
+
}
|
|
331
|
+
if (predicate()) return;
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
|
|
334
|
+
if (now > deadline) {
|
|
335
|
+
const frozen = frameChanges <= 1 ? " - render never advanced (frozen)" : "";
|
|
336
|
+
|
|
337
|
+
throw new Error(
|
|
338
|
+
`waitForState ${label} timed out after ${now - start}ms (${polls} polls, ${frameChanges} frame changes${frozen}).\nlast frame:\n${frame}`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (now - lastLog >= PROGRESS_LOG_MS) {
|
|
342
|
+
lastLog = now;
|
|
343
|
+
console.error(
|
|
344
|
+
`[waitForState] still waiting for ${label} - ${now - start}ms, ${polls} polls, ${frameChanges} frame changes`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
await yieldEventLoop();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Wait until the frame contains the needle (daemon round-trips included). */
|
|
352
|
+
export async function waitForText(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
353
|
+
return waitForFramePredicate(
|
|
354
|
+
setup,
|
|
355
|
+
(frame) => frame.includes(needle),
|
|
356
|
+
`text ${JSON.stringify(needle)}`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Wait until the frame no longer contains the needle. */
|
|
361
|
+
export async function waitForTextGone(setup: TestRendererSetup, needle: string): Promise<string> {
|
|
362
|
+
return waitForFramePredicate(
|
|
363
|
+
setup,
|
|
364
|
+
(frame) => !frame.includes(needle),
|
|
365
|
+
`text-gone ${JSON.stringify(needle)}`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { agentContinuation, type AgentContinuation } from "./agent-continuation";
|
|
3
|
+
import type { ThreadAgentState } from "@cueloop/schema";
|
|
4
|
+
|
|
5
|
+
const state: ThreadAgentState = {
|
|
6
|
+
threadId: "thread",
|
|
7
|
+
phase: { kind: "idle" },
|
|
8
|
+
messages: [],
|
|
9
|
+
comments: [],
|
|
10
|
+
tools: [],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
test("only a completed reply in an idle conversation offers continuation", () => {
|
|
14
|
+
expect(agentContinuation(state)).toEqual({ kind: "waiting" });
|
|
15
|
+
const message = {
|
|
16
|
+
id: "answer",
|
|
17
|
+
role: "agent" as const,
|
|
18
|
+
text: "Done",
|
|
19
|
+
complete: true,
|
|
20
|
+
revision: 1,
|
|
21
|
+
};
|
|
22
|
+
const completed: AgentContinuation = agentContinuation({ ...state, messages: [message] });
|
|
23
|
+
|
|
24
|
+
expect(completed.kind).toBe("ready");
|
|
25
|
+
if (completed.kind === "ready") expect(completed.replyId).toBe("answer");
|
|
26
|
+
expect(agentContinuation({ ...state, phase: { kind: "running" }, messages: [message] })).toEqual({
|
|
27
|
+
kind: "waiting",
|
|
28
|
+
});
|
|
29
|
+
expect(agentContinuation({ ...state, messages: [{ ...message, complete: false }] })).toEqual({
|
|
30
|
+
kind: "waiting",
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ThreadAgentState } from "@cueloop/schema";
|
|
2
|
+
|
|
3
|
+
/** Chat continuation names a completed reply only when the harness has returned to idle. */
|
|
4
|
+
export type AgentContinuation = { kind: "waiting" } | { kind: "ready"; replyId: string };
|
|
5
|
+
|
|
6
|
+
/** Streaming replies cannot request the next prompt's focus. */
|
|
7
|
+
export function agentContinuation(state: ThreadAgentState): AgentContinuation {
|
|
8
|
+
const reply = state.messages.findLast((message) => message.role === "agent");
|
|
9
|
+
|
|
10
|
+
if (state.phase.kind !== "idle" || !reply?.complete) return { kind: "waiting" };
|
|
11
|
+
|
|
12
|
+
return { kind: "ready", replyId: reply.id };
|
|
13
|
+
}
|