@cueloop/client 0.1.0-alpha.8 → 0.1.0-alpha.81
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 +40 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +176 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +428 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +222 -0
- package/src/App.test.tsx +333 -131
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1338 -608
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +701 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +574 -0
- package/src/app-view-model.ts +162 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +329 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +153 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +876 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +59 -0
- package/src/components/ProjectTreeView.tsx +111 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +899 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +472 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +142 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2238 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +56 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +517 -33
- package/src/config.ts +467 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +238 -0
- package/src/diff-intraline.ts +236 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +13 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +723 -0
- package/src/intent-dispatch.ts +692 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +461 -113
- package/src/keymap.ts +321 -79
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +52 -0
- package/src/launch-layout.ts +42 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +64 -0
- package/src/prototype-browser.ts +329 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +78 -13
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-controller.curate.test.ts +450 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +1893 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-annotation-surface.tsx +915 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-settings-dialog.tsx +320 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +137 -9
- package/src/view-diff.ts +269 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +456 -0
- package/src/view-plan.ts +719 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +81 -0
- package/src/workbench-snapshot.ts +29 -0
- package/src/session-controller.ts +0 -345
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -312
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid layout for the two block kinds the read-only thread renders as tables:
|
|
3
|
+
* a GFM table (bold header over a rule, columns aligned per the delimiter row,
|
|
4
|
+
* no vertical borders) and leading YAML
|
|
5
|
+
* frontmatter (a full-bordered key/value grid, bold nowrap keys, wrapped
|
|
6
|
+
* values). Both are pure: they turn a block's source text and a width into
|
|
7
|
+
* positioned, styled segments the renderer prints verbatim, so a char-frame
|
|
8
|
+
* test can assert the exact grid.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { wrapLines } from "./mark-runs";
|
|
12
|
+
|
|
13
|
+
/** One painted piece of a grid line: its text plus whether it reads bold or dim (a border). */
|
|
14
|
+
export interface GridSegment {
|
|
15
|
+
text: string;
|
|
16
|
+
bold: boolean;
|
|
17
|
+
dim: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A grid line is left-to-right segments; concatenating their text gives the row. */
|
|
21
|
+
export type GridLine = GridSegment[];
|
|
22
|
+
|
|
23
|
+
export type ColumnAlign = "left" | "right" | "center";
|
|
24
|
+
|
|
25
|
+
export interface MarkdownTable {
|
|
26
|
+
aligns: ColumnAlign[];
|
|
27
|
+
header: string[];
|
|
28
|
+
rows: string[][];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Split a table row into trimmed cells on unescaped pipes, unescaping `\|` into a
|
|
33
|
+
* literal pipe, and dropping the empty cells an optional leading or trailing
|
|
34
|
+
* border pipe produces (an intentional blank cell keeps its surrounding spaces).
|
|
35
|
+
*/
|
|
36
|
+
function splitTableRow(line: string): string[] {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
const parts: string[] = [];
|
|
39
|
+
let cell = "";
|
|
40
|
+
|
|
41
|
+
for (let index = 0; index < trimmed.length; index++) {
|
|
42
|
+
const char = trimmed[index]!;
|
|
43
|
+
|
|
44
|
+
if (char === "\\" && trimmed[index + 1] === "|") {
|
|
45
|
+
cell += "|";
|
|
46
|
+
index++;
|
|
47
|
+
} else if (char === "|") {
|
|
48
|
+
parts.push(cell);
|
|
49
|
+
cell = "";
|
|
50
|
+
} else {
|
|
51
|
+
cell += char;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
parts.push(cell);
|
|
55
|
+
if (parts.length > 1 && parts[0] === "") parts.shift();
|
|
56
|
+
if (parts.length > 1 && parts[parts.length - 1] === "") parts.pop();
|
|
57
|
+
|
|
58
|
+
return parts.map((part) => part.trim());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Parse a GFM table block's verbatim source into header cells, column alignments, and body rows. */
|
|
62
|
+
export function parseMarkdownTable(text: string): MarkdownTable {
|
|
63
|
+
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
|
64
|
+
const header = splitTableRow(lines[0] ?? "");
|
|
65
|
+
const aligns: ColumnAlign[] = splitTableRow(lines[1] ?? "").map((cell) => {
|
|
66
|
+
const left = cell.startsWith(":");
|
|
67
|
+
const right = cell.endsWith(":");
|
|
68
|
+
|
|
69
|
+
return left && right ? "center" : right ? "right" : "left";
|
|
70
|
+
});
|
|
71
|
+
const rows = lines.slice(2).map(splitTableRow);
|
|
72
|
+
|
|
73
|
+
return { aligns, header, rows };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Pad `text` to `width` cells under an alignment. */
|
|
77
|
+
function pad(text: string, width: number, align: ColumnAlign): string {
|
|
78
|
+
const slack = Math.max(0, width - text.length);
|
|
79
|
+
|
|
80
|
+
if (align === "right") return " ".repeat(slack) + text;
|
|
81
|
+
if (align === "center") {
|
|
82
|
+
const left = Math.floor(slack / 2);
|
|
83
|
+
|
|
84
|
+
return " ".repeat(left) + text + " ".repeat(slack - left);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return text + " ".repeat(slack);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Two spaces separate table columns; the read-only render draws no vertical rule. */
|
|
91
|
+
const COLUMN_GAP = " ";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Lay out a GFM table: a bold header row, a dim rule spanning the table width,
|
|
95
|
+
* then the body rows, every column padded to its widest cell under its alignment.
|
|
96
|
+
*/
|
|
97
|
+
export function layoutMarkdownTable(table: MarkdownTable): GridLine[] {
|
|
98
|
+
const columnCount = Math.max(table.header.length, ...table.rows.map((row) => row.length), 1);
|
|
99
|
+
const alignOf = (column: number): ColumnAlign => table.aligns[column] ?? "left";
|
|
100
|
+
const cellAt = (cells: string[], column: number): string => cells[column] ?? "";
|
|
101
|
+
const widths = Array.from({ length: columnCount }, (_unused, column) =>
|
|
102
|
+
Math.max(
|
|
103
|
+
cellAt(table.header, column).length,
|
|
104
|
+
...table.rows.map((row) => cellAt(row, column).length),
|
|
105
|
+
1,
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
const rowText = (cells: string[]): string =>
|
|
109
|
+
widths
|
|
110
|
+
.map((width, column) => pad(cellAt(cells, column), width, alignOf(column)))
|
|
111
|
+
.join(COLUMN_GAP);
|
|
112
|
+
const totalWidth =
|
|
113
|
+
widths.reduce((sum, width) => sum + width, 0) + COLUMN_GAP.length * (columnCount - 1);
|
|
114
|
+
const lines: GridLine[] = [
|
|
115
|
+
[{ text: rowText(table.header), bold: true, dim: false }],
|
|
116
|
+
[{ text: "─".repeat(totalWidth), bold: false, dim: true }],
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
for (const row of table.rows) lines.push([{ text: rowText(row), bold: false, dim: false }]);
|
|
120
|
+
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface FrontmatterRow {
|
|
125
|
+
key: string;
|
|
126
|
+
value: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Parse frontmatter YAML into top-level key/value rows; indented lines fold into the row above. */
|
|
130
|
+
export function parseFrontmatterRows(text: string): FrontmatterRow[] {
|
|
131
|
+
const rows: FrontmatterRow[] = [];
|
|
132
|
+
|
|
133
|
+
for (const line of text.split("\n")) {
|
|
134
|
+
if (line.trim() === "") continue;
|
|
135
|
+
const match = /^([^:\s][^:]*):\s?(.*)$/.exec(line);
|
|
136
|
+
|
|
137
|
+
if (match) {
|
|
138
|
+
rows.push({ key: match[1]!.trim(), value: match[2]! });
|
|
139
|
+
} else if (rows.length > 0) {
|
|
140
|
+
const previous = rows[rows.length - 1]!;
|
|
141
|
+
|
|
142
|
+
previous.value = previous.value === "" ? line.trim() : `${previous.value}\n${line.trim()}`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return rows;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The fixed cells a frontmatter grid row spends on borders and padding: `│ key │ value │`. */
|
|
150
|
+
const FRONTMATTER_CHROME = 7;
|
|
151
|
+
|
|
152
|
+
/** Wrap a cell value into lines no wider than `width`, honouring its own line breaks. */
|
|
153
|
+
function wrapCell(value: string, width: number): string[] {
|
|
154
|
+
if (value === "") return [""];
|
|
155
|
+
|
|
156
|
+
return wrapLines(value, width).map((line) => value.slice(line.start, line.end));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Lay out leading frontmatter as a full-bordered key/value grid: bold nowrap
|
|
161
|
+
* keys on the left, values wrapped to the remaining width on the right, a rule
|
|
162
|
+
* between every row.
|
|
163
|
+
*/
|
|
164
|
+
export function layoutFrontmatterGrid(rows: FrontmatterRow[], maxWidth: number): GridLine[] {
|
|
165
|
+
if (rows.length === 0) return [];
|
|
166
|
+
const keyWidth = Math.max(3, ...rows.map((row) => row.key.length));
|
|
167
|
+
// size the value column to its content, capped at the width left after the key column and borders,
|
|
168
|
+
// so a short frontmatter table stays compact and a long value wraps instead of overflowing
|
|
169
|
+
const available = Math.max(3, maxWidth - keyWidth - FRONTMATTER_CHROME);
|
|
170
|
+
const naturalWidth = Math.max(
|
|
171
|
+
3,
|
|
172
|
+
...rows.map((row) => Math.max(0, ...row.value.split("\n").map((line) => line.length))),
|
|
173
|
+
);
|
|
174
|
+
const valueWidth = Math.min(naturalWidth, available);
|
|
175
|
+
const rule = (left: string, mid: string, right: string): GridLine => [
|
|
176
|
+
{
|
|
177
|
+
text: left + "─".repeat(keyWidth + 2) + mid + "─".repeat(valueWidth + 2) + right,
|
|
178
|
+
bold: false,
|
|
179
|
+
dim: true,
|
|
180
|
+
},
|
|
181
|
+
];
|
|
182
|
+
const lines: GridLine[] = [rule("┌", "┬", "┐")];
|
|
183
|
+
|
|
184
|
+
rows.forEach((row, rowIndex) => {
|
|
185
|
+
if (rowIndex > 0) lines.push(rule("├", "┼", "┤"));
|
|
186
|
+
const valueLines = wrapCell(row.value, valueWidth);
|
|
187
|
+
|
|
188
|
+
valueLines.forEach((valueLine, valueIndex) => {
|
|
189
|
+
const keyCell = valueIndex === 0 ? row.key.padEnd(keyWidth) : " ".repeat(keyWidth);
|
|
190
|
+
|
|
191
|
+
lines.push([
|
|
192
|
+
{ text: "│ ", bold: false, dim: true },
|
|
193
|
+
{ text: keyCell, bold: valueIndex === 0, dim: false },
|
|
194
|
+
{ text: " │ ", bold: false, dim: true },
|
|
195
|
+
{ text: valueLine.padEnd(valueWidth), bold: false, dim: false },
|
|
196
|
+
{ text: " │", bold: false, dim: true },
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
lines.push(rule("└", "┴", "┘"));
|
|
201
|
+
|
|
202
|
+
return lines;
|
|
203
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { markdownHighlightRanges, type MarkdownHighlightGroup } from "./markdown-highlight";
|
|
3
|
+
|
|
4
|
+
/** The substring a range covers, paired with its group, for readable assertions. */
|
|
5
|
+
function painted(source: string): Array<{ text: string; group: MarkdownHighlightGroup }> {
|
|
6
|
+
return markdownHighlightRanges(source).map((range) => ({
|
|
7
|
+
text: source.slice(range.start, range.end),
|
|
8
|
+
group: range.group,
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("markdownHighlightRanges", () => {
|
|
13
|
+
test("a heading dims its marker and bolds the text without coloring it", () => {
|
|
14
|
+
expect(painted("## Rollout Plan")).toEqual([
|
|
15
|
+
{ text: "## ", group: "marker" },
|
|
16
|
+
{ text: "Rollout Plan", group: "heading" },
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("a plain link colors its brackets and href but leaves the label plain", () => {
|
|
21
|
+
const runs = painted("see [docs](https://x.dev)");
|
|
22
|
+
|
|
23
|
+
expect(runs).toContainEqual({ text: "[", group: "link" });
|
|
24
|
+
expect(runs).toContainEqual({ text: "](https://x.dev)", group: "link" });
|
|
25
|
+
expect(runs.some((run) => run.text.includes("docs"))).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("an @-scope link label colors along with the brackets and href", () => {
|
|
29
|
+
expect(painted("[@pierre/diffs](https://npm/@pierre/diffs)")).toEqual([
|
|
30
|
+
{ text: "[@pierre/diffs](https://npm/@pierre/diffs)", group: "link" },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("inline code grays the whole backtick span", () => {
|
|
35
|
+
expect(painted("call `run()` now")).toContainEqual({ text: "`run()`", group: "code" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("a fenced code block grays the whole block", () => {
|
|
39
|
+
const source = "```ts\nconst a = 1;\n```";
|
|
40
|
+
|
|
41
|
+
expect(painted(source)).toEqual([{ text: source, group: "code" }]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("inline code inside a list item resolves against the whole source offset", () => {
|
|
45
|
+
const source = "- run `cueloop dev`";
|
|
46
|
+
const code = markdownHighlightRanges(source).find((range) => range.group === "code");
|
|
47
|
+
|
|
48
|
+
expect(code && source.slice(code.start, code.end)).toBe("`cueloop dev`");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("bold, emphasis, list markers, quotes, and rules stay plain", () => {
|
|
52
|
+
expect(markdownHighlightRanges("ship **now** or _later_")).toEqual([]);
|
|
53
|
+
expect(markdownHighlightRanges("- a task")).toEqual([]);
|
|
54
|
+
expect(markdownHighlightRanges("> quoted line")).toEqual([]);
|
|
55
|
+
expect(markdownHighlightRanges("---")).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("plain prose yields no ranges", () => {
|
|
59
|
+
expect(markdownHighlightRanges("just words here")).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan raw markdown source into highlight ranges for the inline thread editor.
|
|
3
|
+
* The scheme is deliberately restrained, matching a plain markdown editor: a
|
|
4
|
+
* heading dims its `#` marker and bolds the title text (no color); links render
|
|
5
|
+
* in one link color; inline and fenced code gray out. Everything else - bold,
|
|
6
|
+
* emphasis, list and quote markers, rules - stays plain source. Block structure
|
|
7
|
+
* reuses the schema parser so a heading or code fence is defined in one place.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { parseBlocks, LEADING_BLOCK_MARKER, type Block } from "@cueloop/schema";
|
|
11
|
+
|
|
12
|
+
/** Every markdown token class the editor paints; "marker" is the dimmed heading punctuation. */
|
|
13
|
+
export const MARKDOWN_HIGHLIGHT_GROUPS = ["heading", "marker", "link", "code"] as const;
|
|
14
|
+
|
|
15
|
+
/** A markdown token class the editor paints. */
|
|
16
|
+
export type MarkdownHighlightGroup = (typeof MARKDOWN_HIGHLIGHT_GROUPS)[number];
|
|
17
|
+
|
|
18
|
+
/** A char range in the raw markdown source to paint with one group's style; end is exclusive. */
|
|
19
|
+
export interface MarkdownHighlightRange {
|
|
20
|
+
start: number;
|
|
21
|
+
end: number;
|
|
22
|
+
group: MarkdownHighlightGroup;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The absolute source offset where each 0-based line begins. */
|
|
26
|
+
function lineStartOffsets(source: string): number[] {
|
|
27
|
+
const offsets = [0];
|
|
28
|
+
for (let index = 0; index < source.length; index++) {
|
|
29
|
+
if (source[index] === "\n") offsets.push(index + 1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return offsets;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// gray a balanced backtick run and color a [text](href) link; bold and emphasis stay plain source
|
|
36
|
+
const INLINE_TOKEN = /(`+)(?:.+?)\1|\[[^\]]+\]\([^)]+\)/g;
|
|
37
|
+
|
|
38
|
+
/** Gray inline code and color links found in one text span; other inline markup stays plain. */
|
|
39
|
+
function scanInlineTokens(text: string, base: number, into: MarkdownHighlightRange[]): void {
|
|
40
|
+
INLINE_TOKEN.lastIndex = 0;
|
|
41
|
+
let match: RegExpExecArray | null;
|
|
42
|
+
while ((match = INLINE_TOKEN.exec(text)) !== null) {
|
|
43
|
+
const token = match[0];
|
|
44
|
+
const start = base + match.index;
|
|
45
|
+
const end = start + token.length;
|
|
46
|
+
|
|
47
|
+
if (token.startsWith("`")) {
|
|
48
|
+
into.push({ start, end, group: "code" });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// a link colors its brackets and href; the [] label stays plain unless it is an @-scope, matching how a
|
|
52
|
+
// markdown source view paints links
|
|
53
|
+
const closeBracket = token.indexOf("]");
|
|
54
|
+
|
|
55
|
+
if (token.slice(1, closeBracket).startsWith("@")) {
|
|
56
|
+
into.push({ start, end, group: "link" });
|
|
57
|
+
} else {
|
|
58
|
+
into.push({ start, end: start + 1, group: "link" });
|
|
59
|
+
into.push({ start: start + closeBracket, end, group: "link" });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isHeadingKind(kind: Block["kind"]): boolean {
|
|
65
|
+
return kind === "h1" || kind === "h2" || kind === "h3";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Dim a heading's `#` marker and bold the rest of the line (no color). */
|
|
69
|
+
function scanHeadingBlock(
|
|
70
|
+
firstLineStart: number,
|
|
71
|
+
source: string,
|
|
72
|
+
lineStarts: number[],
|
|
73
|
+
block: Block,
|
|
74
|
+
into: MarkdownHighlightRange[],
|
|
75
|
+
): void {
|
|
76
|
+
const lineText = source.slice(firstLineStart, lineStarts[block.lineStart + 1] ?? source.length);
|
|
77
|
+
const marker = lineText.match(LEADING_BLOCK_MARKER)?.[0] ?? "";
|
|
78
|
+
|
|
79
|
+
into.push({ start: firstLineStart, end: firstLineStart + marker.length, group: "marker" });
|
|
80
|
+
into.push({
|
|
81
|
+
start: firstLineStart + marker.length,
|
|
82
|
+
end: firstLineStart + lineText.trimEnd().length,
|
|
83
|
+
group: "heading",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Paint one block: heading marker and title, grayed code block, or inline code and links in prose. */
|
|
88
|
+
function scanBlock(
|
|
89
|
+
block: Block,
|
|
90
|
+
source: string,
|
|
91
|
+
lineStarts: number[],
|
|
92
|
+
into: MarkdownHighlightRange[],
|
|
93
|
+
): void {
|
|
94
|
+
const firstLineStart = lineStarts[block.lineStart] ?? 0;
|
|
95
|
+
|
|
96
|
+
if (isHeadingKind(block.kind)) {
|
|
97
|
+
scanHeadingBlock(firstLineStart, source, lineStarts, block, into);
|
|
98
|
+
} else if (block.kind === "code") {
|
|
99
|
+
into.push({
|
|
100
|
+
start: firstLineStart,
|
|
101
|
+
end: lineStarts[block.lineEnd + 1] ?? source.length,
|
|
102
|
+
group: "code",
|
|
103
|
+
});
|
|
104
|
+
} else if (block.kind !== "hr") {
|
|
105
|
+
for (let line = block.lineStart; line <= block.lineEnd; line++) {
|
|
106
|
+
const lineStart = lineStarts[line] ?? source.length;
|
|
107
|
+
const lineText = source.slice(lineStart, lineStarts[line + 1] ?? source.length);
|
|
108
|
+
|
|
109
|
+
scanInlineTokens(lineText.replace(/\r?\n$/, ""), lineStart, into);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Scan raw markdown source into highlight ranges over the source (markers included), for the inline editor. */
|
|
115
|
+
export function markdownHighlightRanges(source: string): MarkdownHighlightRange[] {
|
|
116
|
+
const lineStarts = lineStartOffsets(source);
|
|
117
|
+
const ranges: MarkdownHighlightRange[] = [];
|
|
118
|
+
|
|
119
|
+
for (const block of parseBlocks(source)) scanBlock(block, source, lineStarts, ranges);
|
|
120
|
+
|
|
121
|
+
return ranges;
|
|
122
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { imagePlaceholder, looksLikeBinaryPaste } from "./pasted-image";
|
|
3
|
+
|
|
4
|
+
function bytesOf(...values: number[]): Uint8Array {
|
|
5
|
+
return Uint8Array.from(values);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("looksLikeBinaryPaste", () => {
|
|
9
|
+
test("plain text pastes stay text", () => {
|
|
10
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("just a normal comment"))).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("multi-line text with tabs and newlines stays text", () => {
|
|
14
|
+
expect(
|
|
15
|
+
looksLikeBinaryPaste(new TextEncoder().encode("line one\n\tline two\r\nline three")),
|
|
16
|
+
).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("unicode text stays text", () => {
|
|
20
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("café — naïve — 日本語 — 🎉"))).toBe(
|
|
21
|
+
false,
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("a PNG signature is binary", () => {
|
|
26
|
+
expect(looksLikeBinaryPaste(bytesOf(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))).toBe(
|
|
27
|
+
true,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a JPEG signature is binary", () => {
|
|
32
|
+
expect(looksLikeBinaryPaste(bytesOf(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10))).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a GIF signature is binary", () => {
|
|
36
|
+
expect(looksLikeBinaryPaste(new TextEncoder().encode("GIF89a\x01\x02"))).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a WEBP signature is binary", () => {
|
|
40
|
+
expect(
|
|
41
|
+
looksLikeBinaryPaste(bytesOf(0x52, 0x49, 0x46, 0x46, 1, 2, 3, 4, 0x57, 0x45, 0x42, 0x50)),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a NUL byte marks binary", () => {
|
|
46
|
+
expect(looksLikeBinaryPaste(bytesOf(0x68, 0x69, 0x00, 0x74, 0x68, 0x65, 0x72, 0x65))).toBe(
|
|
47
|
+
true,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a burst of control bytes marks binary", () => {
|
|
52
|
+
expect(looksLikeBinaryPaste(bytesOf(0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08))).toBe(
|
|
53
|
+
true,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("an empty paste is not binary", () => {
|
|
58
|
+
expect(looksLikeBinaryPaste(new Uint8Array())).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("imagePlaceholder", () => {
|
|
63
|
+
test("numbers each pasted image", () => {
|
|
64
|
+
expect(imagePlaceholder(1)).toBe("[Image #1]");
|
|
65
|
+
expect(imagePlaceholder(2)).toBe("[Image #2]");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Detects an image or other binary paste so a composer shows an [Image #n] placeholder instead of dumping raw bytes into the draft. */
|
|
2
|
+
|
|
3
|
+
const IMAGE_MAGIC_SIGNATURES: number[][] = [
|
|
4
|
+
[0x89, 0x50, 0x4e, 0x47], // PNG
|
|
5
|
+
[0xff, 0xd8, 0xff], // JPEG
|
|
6
|
+
[0x47, 0x49, 0x46, 0x38], // GIF87a / GIF89a
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const CONTROL_RATIO_THRESHOLD = 0.15;
|
|
10
|
+
const PASTE_DECODER = new TextDecoder("utf-8", { fatal: false });
|
|
11
|
+
|
|
12
|
+
function hasImageMagic(bytes: Uint8Array): boolean {
|
|
13
|
+
for (const signature of IMAGE_MAGIC_SIGNATURES) {
|
|
14
|
+
if (signature.every((byte, index) => bytes[index] === byte)) return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const isRiffWebp =
|
|
18
|
+
bytes.length >= 12 &&
|
|
19
|
+
bytes[0] === 0x52 && // R
|
|
20
|
+
bytes[1] === 0x49 && // I
|
|
21
|
+
bytes[2] === 0x46 && // F
|
|
22
|
+
bytes[3] === 0x46 && // F
|
|
23
|
+
bytes[8] === 0x57 && // W
|
|
24
|
+
bytes[9] === 0x45 && // E
|
|
25
|
+
bytes[10] === 0x42 && // B
|
|
26
|
+
bytes[11] === 0x50; // P
|
|
27
|
+
|
|
28
|
+
return isRiffWebp;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isControlCodePoint(codePoint: number): boolean {
|
|
32
|
+
if (codePoint === 0x09 || codePoint === 0x0a || codePoint === 0x0d) return false;
|
|
33
|
+
|
|
34
|
+
return codePoint < 0x20 || codePoint === 0x7f;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when a paste is an image or other non-text binary payload and should collapse to a placeholder. */
|
|
38
|
+
export function looksLikeBinaryPaste(bytes: Uint8Array): boolean {
|
|
39
|
+
if (bytes.length === 0) return false;
|
|
40
|
+
if (hasImageMagic(bytes)) return true;
|
|
41
|
+
|
|
42
|
+
const decoded = PASTE_DECODER.decode(bytes);
|
|
43
|
+
let controlCount = 0;
|
|
44
|
+
let total = 0;
|
|
45
|
+
|
|
46
|
+
for (const character of decoded) {
|
|
47
|
+
total += 1;
|
|
48
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
49
|
+
|
|
50
|
+
if (codePoint === 0 || codePoint === 0xfffd) return true;
|
|
51
|
+
if (isControlCodePoint(codePoint)) controlCount += 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return total > 0 && controlCount / total > CONTROL_RATIO_THRESHOLD;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The draft placeholder for the nth image pasted into a composer: [Image #1], [Image #2], … */
|
|
58
|
+
export function imagePlaceholder(index: number): string {
|
|
59
|
+
return `[Image #${index}]`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildOtlpTracePayload,
|
|
4
|
+
phaseMarksToSpans,
|
|
5
|
+
postOtlpTrace,
|
|
6
|
+
type TraceSpan,
|
|
7
|
+
} from "./otlp-trace";
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const span: TraceSpan = {
|
|
14
|
+
traceId: "0123456789abcdef0123456789abcdef",
|
|
15
|
+
spanId: "0123456789abcdef",
|
|
16
|
+
name: "cueloop.interaction",
|
|
17
|
+
startTimeUnixNano: "1000",
|
|
18
|
+
endTimeUnixNano: "2000",
|
|
19
|
+
attributes: [
|
|
20
|
+
{ key: "terminal.trigger", value: { stringValue: "key:j" } },
|
|
21
|
+
{ key: "terminal.frame.budget_ms", value: { doubleValue: 16.7 } },
|
|
22
|
+
{ key: "terminal.backpressure", value: { boolValue: true } },
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
test("buildOtlpTracePayload carries service.name and maps scalar attributes to OTLP values", () => {
|
|
27
|
+
const payload = buildOtlpTracePayload("cueloop-client", [span]);
|
|
28
|
+
|
|
29
|
+
expect(payload).toEqual({
|
|
30
|
+
resourceSpans: [
|
|
31
|
+
{
|
|
32
|
+
resource: {
|
|
33
|
+
attributes: [{ key: "service.name", value: { stringValue: "cueloop-client" } }],
|
|
34
|
+
},
|
|
35
|
+
scopeSpans: [
|
|
36
|
+
{
|
|
37
|
+
spans: [
|
|
38
|
+
{
|
|
39
|
+
traceId: "0123456789abcdef0123456789abcdef",
|
|
40
|
+
spanId: "0123456789abcdef",
|
|
41
|
+
parentSpanId: "",
|
|
42
|
+
name: "cueloop.interaction",
|
|
43
|
+
startTimeUnixNano: "1000",
|
|
44
|
+
endTimeUnixNano: "2000",
|
|
45
|
+
attributes: [
|
|
46
|
+
{ key: "terminal.trigger", value: { stringValue: "key:j" } },
|
|
47
|
+
{ key: "terminal.frame.budget_ms", value: { doubleValue: 16.7 } },
|
|
48
|
+
{ key: "terminal.backpressure", value: { boolValue: true } },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("phaseMarksToSpans builds a scope root with one child span per phase under one trace", () => {
|
|
60
|
+
const spans = phaseMarksToSpans("startup", [
|
|
61
|
+
{ label: "renderer", elapsedMs: 10 },
|
|
62
|
+
{ label: "firstFrame", elapsedMs: 20 },
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
expect(spans).toHaveLength(3);
|
|
66
|
+
const [root, first, second] = spans;
|
|
67
|
+
expect(root!.name).toBe("cueloop.startup");
|
|
68
|
+
expect(root!.parentSpanId).toBeUndefined();
|
|
69
|
+
expect(first!.name).toBe("cueloop.startup.renderer");
|
|
70
|
+
expect(first!.parentSpanId).toBe(root!.spanId);
|
|
71
|
+
expect(second!.parentSpanId).toBe(root!.spanId);
|
|
72
|
+
expect(new Set(spans.map((each) => each.traceId)).size).toBe(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("postOtlpTrace sends no request when the endpoint is unset", async () => {
|
|
76
|
+
const requests: string[] = [];
|
|
77
|
+
const server = Bun.serve({
|
|
78
|
+
port: 0,
|
|
79
|
+
fetch(request) {
|
|
80
|
+
requests.push(request.url);
|
|
81
|
+
|
|
82
|
+
return new Response("{}");
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
await postOtlpTrace("cueloop-client", [span]);
|
|
87
|
+
} finally {
|
|
88
|
+
await server.stop(true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expect(requests).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("postOtlpTrace POSTs OTLP JSON to /v1/traces when the endpoint is set", async () => {
|
|
95
|
+
const requests: string[] = [];
|
|
96
|
+
const server = Bun.serve({
|
|
97
|
+
port: 0,
|
|
98
|
+
async fetch(request) {
|
|
99
|
+
requests.push(`${request.method} ${new URL(request.url).pathname}\n${await request.text()}`);
|
|
100
|
+
|
|
101
|
+
return new Response("{}");
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
// a trailing slash must not double up into "//v1/traces"
|
|
105
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = `http://127.0.0.1:${server.port}/`;
|
|
106
|
+
try {
|
|
107
|
+
await postOtlpTrace("cueloop-client", [span]);
|
|
108
|
+
} finally {
|
|
109
|
+
await server.stop(true);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
expect(requests).toHaveLength(1);
|
|
113
|
+
expect(requests[0]).toContain("POST /v1/traces");
|
|
114
|
+
expect(requests[0]).toContain('"service.name"');
|
|
115
|
+
expect(requests[0]).toContain("cueloop.interaction");
|
|
116
|
+
});
|