@tangle-network/ui 11.51.7 → 11.51.9
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/CHANGELOG.md +10 -0
- package/README.md +6 -0
- package/dist/chat/chat-message.js +2 -7
- package/dist/chat/user-message.js +3 -5
- package/dist/files/file-card.js +1 -1
- package/dist/files/file-tree.d.ts +0 -6
- package/dist/files/file-tree.js +2 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/primitives/hover-card.d.ts +22 -13
- package/dist/primitives/hover-card.js +149 -40
- package/dist/primitives/popover.d.ts +3 -1
- package/dist/primitives/popover.js +15 -12
- package/dist/primitives/upload-progress.d.ts +0 -6
- package/dist/primitives/upload-progress.js +2 -6
- package/dist/run/run-group.js +2 -2
- package/dist/run/run-item-primitives.js +2 -2
- package/dist/run/run-row-shell.js +2 -2
- package/dist/utils/format.d.ts +14 -12
- package/dist/utils/format.js +50 -30
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +2 -2
- package/package.json +11 -2
- package/src/chat/chat-message.tsx +2 -7
- package/src/chat/user-message.tsx +3 -5
- package/src/editor/source-markdown-editor.test.tsx +7 -7
- package/src/files/file-tree.tsx +2 -7
- package/src/primitives/hover-card.tsx +64 -74
- package/src/primitives/popover.tsx +3 -3
- package/src/primitives/upload-progress.tsx +2 -7
- package/src/utils/format.ts +50 -33
- package/src/utils/index.ts +1 -1
- package/src/editor/document-editor-pane.test.tsx +0 -60
- package/src/editor/editor-lazy.test.tsx +0 -102
- package/src/editor/editor-peers.test.ts +0 -250
- package/src/editor/editor-provider.test.tsx +0 -226
- package/src/editor/markdown-document-editor.test.tsx +0 -126
- package/src/editor/tiptap-editor.test.tsx +0 -228
- package/src/files/file-artifact-pane.test.tsx +0 -301
- package/src/files/file-card.test.tsx +0 -62
- package/src/files/file-list.test.tsx +0 -81
- package/src/files/pdf-loader.test.ts +0 -130
- package/src/files/pdf-viewer.test.tsx +0 -245
- package/src/files/rich-file-tree.test.tsx +0 -90
- package/src/markdown/api-presentation.test.tsx +0 -63
- package/src/markdown/code-block.test.tsx +0 -201
- package/src/markdown/markdown-table.test.tsx +0 -76
- package/src/markdown/markdown.test.tsx +0 -166
- package/src/openui/schema.test.tsx +0 -158
- package/src/primitives/button.test.tsx +0 -230
- package/src/primitives/card.test.tsx +0 -71
- package/src/primitives/chip.test.tsx +0 -200
- package/src/primitives/date-picker.test.tsx +0 -146
- package/src/primitives/dialog.test.tsx +0 -75
- package/src/primitives/input.test.tsx +0 -161
- package/src/primitives/interaction-states.test.tsx +0 -287
- package/src/primitives/pagination-alert.test.tsx +0 -180
- package/src/primitives/popover-collapsible-alert-dialog-context-menu-slider.test.tsx +0 -165
- package/src/primitives/radio-group.test.tsx +0 -26
- package/src/primitives/segmented-control.test.tsx +0 -451
- package/src/primitives/table.test.tsx +0 -72
- package/src/primitives/theme-toggle.test.tsx +0 -168
- package/src/primitives/toast.test.tsx +0 -130
- package/src/primitives/wizard.test.tsx +0 -106
- package/src/run/expandable-content.test.tsx +0 -52
- package/src/utils/format.test.ts +0 -58
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
-
import { Component, type ReactNode } from "react";
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { MarkdownDocumentEditor } from "./markdown-document-editor";
|
|
5
|
-
import type * as EditorPeers from "./editor-peers";
|
|
6
|
-
|
|
7
|
-
class ErrorTrap extends Component<{ children: ReactNode }, { message: string | null }> {
|
|
8
|
-
state = { message: null as string | null };
|
|
9
|
-
|
|
10
|
-
static getDerivedStateFromError(error: Error) {
|
|
11
|
-
return { message: error.message };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
render() {
|
|
15
|
-
return this.state.message ?? this.props.children;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Imports the editor with a fresh module graph, so its module-scope `lazy`
|
|
21
|
-
* has not resolved yet. `loadDocumentEditorPeers` is replaced by `load`.
|
|
22
|
-
*/
|
|
23
|
-
async function freshEditor(load: () => Promise<EditorPeers.DocumentEditorPeers>) {
|
|
24
|
-
vi.resetModules();
|
|
25
|
-
vi.doMock("./editor-peers", async () => ({
|
|
26
|
-
...(await vi.importActual<typeof EditorPeers>("./editor-peers")),
|
|
27
|
-
loadDocumentEditorPeers: load,
|
|
28
|
-
}));
|
|
29
|
-
return (await import("./markdown-document-editor")).MarkdownDocumentEditor;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// The first render imports tiptap for real. A cold import on a busy runner
|
|
33
|
-
// takes several seconds, well past waitFor's 1s and a test's 5s defaults.
|
|
34
|
-
const PEERS_LOADED = { timeout: 15_000 };
|
|
35
|
-
const LOADS_PEERS = 30_000;
|
|
36
|
-
|
|
37
|
-
afterEach(() => {
|
|
38
|
-
vi.doUnmock("./editor-peers");
|
|
39
|
-
vi.resetModules();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe("MarkdownDocumentEditor", () => {
|
|
43
|
-
it("renders the real tiptap surface once the peers load", async () => {
|
|
44
|
-
const { container } = render(
|
|
45
|
-
<MarkdownDocumentEditor value={"# Title\n\nBody text."} />,
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
await waitFor(() => {
|
|
49
|
-
expect(container.querySelector(".ProseMirror")).not.toBeNull();
|
|
50
|
-
}, PEERS_LOADED);
|
|
51
|
-
|
|
52
|
-
const surface = container.querySelector(".ProseMirror");
|
|
53
|
-
expect(surface?.querySelector("h1")?.textContent).toBe("Title");
|
|
54
|
-
expect(surface?.textContent).toContain("Body text.");
|
|
55
|
-
// StarterKit arrives through `peers.starterKit.default`. A broken hand-off
|
|
56
|
-
// leaves the document without the heading node that StarterKit configures.
|
|
57
|
-
expect(screen.getByTitle(/^Bold/)).toBeInTheDocument();
|
|
58
|
-
}, LOADS_PEERS);
|
|
59
|
-
|
|
60
|
-
it("holds a placeholder while the peers are still loading", async () => {
|
|
61
|
-
let releasePeers: (() => void) | null = null;
|
|
62
|
-
const gate = new Promise<void>((resolve) => {
|
|
63
|
-
releasePeers = resolve;
|
|
64
|
-
});
|
|
65
|
-
const HeldEditor = await freshEditor(async () => {
|
|
66
|
-
await gate;
|
|
67
|
-
const actual = await vi.importActual<typeof EditorPeers>("./editor-peers");
|
|
68
|
-
return actual.loadDocumentEditorPeers();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
const { container } = render(<HeldEditor value="# Title" />);
|
|
72
|
-
|
|
73
|
-
// A live region, so a screen reader hears that the editor is still coming.
|
|
74
|
-
expect(screen.getByRole("status")).toHaveTextContent("Loading editor…");
|
|
75
|
-
expect(container.querySelector(".ProseMirror")).toBeNull();
|
|
76
|
-
|
|
77
|
-
releasePeers?.();
|
|
78
|
-
|
|
79
|
-
await waitFor(() => {
|
|
80
|
-
expect(container.querySelector(".ProseMirror h1")?.textContent).toBe("Title");
|
|
81
|
-
});
|
|
82
|
-
expect(screen.queryByText("Loading editor…")).toBeNull();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("applies a new value prop to the loaded editor without reporting a change", async () => {
|
|
86
|
-
const onChange = vi.fn();
|
|
87
|
-
const { container, rerender } = render(
|
|
88
|
-
<MarkdownDocumentEditor value="start" onChange={onChange} />,
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
await waitFor(() => {
|
|
92
|
-
expect(container.querySelector(".ProseMirror")).not.toBeNull();
|
|
93
|
-
}, PEERS_LOADED);
|
|
94
|
-
|
|
95
|
-
// The editor reports once when it parses the initial content. What must
|
|
96
|
-
// not grow is the count across a value-prop write: pushing that back
|
|
97
|
-
// through onChange makes a controlled parent echo its own write.
|
|
98
|
-
const callsAfterMount = onChange.mock.calls.length;
|
|
99
|
-
|
|
100
|
-
rerender(<MarkdownDocumentEditor value="# Changed" onChange={onChange} />);
|
|
101
|
-
|
|
102
|
-
await waitFor(() => {
|
|
103
|
-
expect(container.querySelector(".ProseMirror h1")?.textContent).toBe("Changed");
|
|
104
|
-
});
|
|
105
|
-
expect(onChange).toHaveBeenCalledTimes(callsAfterMount);
|
|
106
|
-
}, LOADS_PEERS);
|
|
107
|
-
|
|
108
|
-
it("fails with the install list when the peers cannot be loaded", async () => {
|
|
109
|
-
const missing = "install @tiptap/react and @tiptap/starter-kit";
|
|
110
|
-
const LoadFailingEditor = await freshEditor(async () => {
|
|
111
|
-
throw new Error(missing);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
render(
|
|
115
|
-
<ErrorTrap>
|
|
116
|
-
<LoadFailingEditor value="start" />
|
|
117
|
-
</ErrorTrap>,
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
// Loud, and only here: the consumer's build already succeeded without the
|
|
121
|
-
// peers, so this render is the first place the gap can surface.
|
|
122
|
-
await waitFor(() => {
|
|
123
|
-
expect(screen.getByText(missing)).toBeInTheDocument();
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
});
|
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
-
import { Component, type ReactNode } from "react";
|
|
3
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { Doc } from "yjs";
|
|
5
|
-
import type * as EditorPeers from "./editor-peers";
|
|
6
|
-
import { createEditorProvider } from "./editor-provider";
|
|
7
|
-
import { createTiptapEditor } from "./tiptap-editor";
|
|
8
|
-
|
|
9
|
-
class ErrorTrap extends Component<{ children: ReactNode }, { message: string | null }> {
|
|
10
|
-
state = { message: null as string | null };
|
|
11
|
-
|
|
12
|
-
static getDerivedStateFromError(error: Error) {
|
|
13
|
-
return { message: error.message };
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
render() {
|
|
17
|
-
return this.state.message ?? this.props.children;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
class StubHocuspocusProvider {
|
|
22
|
-
static instances: StubHocuspocusProvider[] = [];
|
|
23
|
-
|
|
24
|
-
awareness = {
|
|
25
|
-
clientID: 1,
|
|
26
|
-
getStates: () => new Map(),
|
|
27
|
-
getLocalState: () => ({}),
|
|
28
|
-
setLocalStateField: vi.fn(),
|
|
29
|
-
on: vi.fn(),
|
|
30
|
-
off: vi.fn(),
|
|
31
|
-
};
|
|
32
|
-
connect = vi.fn();
|
|
33
|
-
disconnect = vi.fn();
|
|
34
|
-
destroy = vi.fn();
|
|
35
|
-
|
|
36
|
-
constructor(readonly options: { document: Doc }) {
|
|
37
|
-
StubHocuspocusProvider.instances.push(this);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** A tiptap extension records what the editor configured it with. */
|
|
42
|
-
function stubExtension(name: string) {
|
|
43
|
-
const calls: Record<string, unknown>[] = [];
|
|
44
|
-
return {
|
|
45
|
-
calls,
|
|
46
|
-
default: {
|
|
47
|
-
configure(options: Record<string, unknown>) {
|
|
48
|
-
calls.push(options);
|
|
49
|
-
return { name, options };
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Only the members the collaborative editor calls. yjs is the real package,
|
|
57
|
-
* because the provider and the editor must agree on one document.
|
|
58
|
-
*/
|
|
59
|
-
function collaborationStubs() {
|
|
60
|
-
const starterKit = stubExtension("starterKit");
|
|
61
|
-
const collaboration = stubExtension("collaboration");
|
|
62
|
-
const collaborationCaret = stubExtension("collaborationCaret");
|
|
63
|
-
const editorOptions: Record<string, unknown>[] = [];
|
|
64
|
-
const editor = {
|
|
65
|
-
setEditable: vi.fn(),
|
|
66
|
-
isEmpty: true,
|
|
67
|
-
commands: { setContent: vi.fn() },
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const peers = {
|
|
71
|
-
react: {
|
|
72
|
-
useEditor: (options: Record<string, unknown>) => {
|
|
73
|
-
editorOptions.push(options);
|
|
74
|
-
return editor;
|
|
75
|
-
},
|
|
76
|
-
EditorContent: () => <div data-testid="editor-content" />,
|
|
77
|
-
},
|
|
78
|
-
starterKit,
|
|
79
|
-
collaboration,
|
|
80
|
-
collaborationCaret,
|
|
81
|
-
hocuspocus: { HocuspocusProvider: StubHocuspocusProvider },
|
|
82
|
-
yjs: { Doc },
|
|
83
|
-
} as unknown as EditorPeers.CollaborationPeers;
|
|
84
|
-
|
|
85
|
-
return { peers, starterKit, collaboration, collaborationCaret, editorOptions, editor };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function renderCollaborativeEditor(stubs: ReturnType<typeof collaborationStubs>) {
|
|
89
|
-
const EditorProvider = createEditorProvider(stubs.peers);
|
|
90
|
-
const TiptapEditor = createTiptapEditor(stubs.peers);
|
|
91
|
-
return render(
|
|
92
|
-
<EditorProvider
|
|
93
|
-
websocketUrl="wss://collab.example/ws"
|
|
94
|
-
documentName="doc:readme"
|
|
95
|
-
token="jwt"
|
|
96
|
-
user={{ name: "Ada" }}
|
|
97
|
-
>
|
|
98
|
-
<TiptapEditor />
|
|
99
|
-
</EditorProvider>,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
afterEach(() => {
|
|
104
|
-
StubHocuspocusProvider.instances = [];
|
|
105
|
-
vi.doUnmock("./editor-peers");
|
|
106
|
-
vi.resetModules();
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe("createTiptapEditor", () => {
|
|
110
|
-
it("renders the editor surface from the loaded namespaces", () => {
|
|
111
|
-
renderCollaborativeEditor(collaborationStubs());
|
|
112
|
-
|
|
113
|
-
expect(screen.getByTestId("editor-content")).toBeInTheDocument();
|
|
114
|
-
expect(screen.getByText("Connecting...")).toBeInTheDocument();
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("gives Collaboration the document fragment the provider shares", () => {
|
|
118
|
-
const stubs = collaborationStubs();
|
|
119
|
-
renderCollaborativeEditor(stubs);
|
|
120
|
-
|
|
121
|
-
const [provider] = StubHocuspocusProvider.instances;
|
|
122
|
-
// The extensions rebuild once the transport arrives, so read the last
|
|
123
|
-
// configuration: the transport and the editor must bind to one Y.Doc, or
|
|
124
|
-
// remote edits never reach the surface.
|
|
125
|
-
expect(stubs.collaboration.calls.at(-1)?.fragment).toBe(
|
|
126
|
-
provider.options.document.getXmlFragment("prosemirror"),
|
|
127
|
-
);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("gives CollaborationCaret the provider and a named user", () => {
|
|
131
|
-
const stubs = collaborationStubs();
|
|
132
|
-
renderCollaborativeEditor(stubs);
|
|
133
|
-
|
|
134
|
-
const [provider] = StubHocuspocusProvider.instances;
|
|
135
|
-
// The caret joins only once the provider carries awareness.
|
|
136
|
-
expect(stubs.collaborationCaret.calls).toHaveLength(1);
|
|
137
|
-
const [caretOptions] = stubs.collaborationCaret.calls;
|
|
138
|
-
expect(caretOptions.provider).toBe(provider);
|
|
139
|
-
// The stub awareness carries no local user yet, so the editor names the
|
|
140
|
-
// fallback rather than passing undefined into the caret renderer.
|
|
141
|
-
expect(caretOptions.user).toEqual({ name: "Anonymous", color: "#808080" });
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("passes every configured extension to the editor", () => {
|
|
145
|
-
const stubs = collaborationStubs();
|
|
146
|
-
renderCollaborativeEditor(stubs);
|
|
147
|
-
|
|
148
|
-
expect(stubs.starterKit.calls.at(-1)).toMatchObject({ history: false });
|
|
149
|
-
const options = stubs.editorOptions.at(-1);
|
|
150
|
-
expect(
|
|
151
|
-
(options?.extensions as { name: string }[]).map((one) => one.name),
|
|
152
|
-
).toEqual(["starterKit", "collaboration", "collaborationCaret"]);
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
describe("TiptapEditor", () => {
|
|
157
|
-
it("holds a placeholder while the peers are still loading", async () => {
|
|
158
|
-
let releasePeers: (() => void) | null = null;
|
|
159
|
-
const gate = new Promise<void>((resolve) => {
|
|
160
|
-
releasePeers = resolve;
|
|
161
|
-
});
|
|
162
|
-
const stubs = collaborationStubs();
|
|
163
|
-
vi.resetModules();
|
|
164
|
-
vi.doMock("./editor-peers", async () => ({
|
|
165
|
-
...(await vi.importActual<typeof EditorPeers>("./editor-peers")),
|
|
166
|
-
loadCollaborationPeers: async () => {
|
|
167
|
-
await gate;
|
|
168
|
-
return stubs.peers;
|
|
169
|
-
},
|
|
170
|
-
}));
|
|
171
|
-
const { TiptapEditor } = await import("./tiptap-editor");
|
|
172
|
-
// Both modules come from the reset graph, so the provider and the editor
|
|
173
|
-
// share one EditorContext. A provider built from the loaded peers
|
|
174
|
-
// directly, because the exported EditorProvider waits on the same gate and
|
|
175
|
-
// would render nothing at all.
|
|
176
|
-
const freshProvider = await import("./editor-provider");
|
|
177
|
-
const ResolvedProvider = freshProvider.createEditorProvider(stubs.peers);
|
|
178
|
-
|
|
179
|
-
render(
|
|
180
|
-
<ResolvedProvider
|
|
181
|
-
websocketUrl="wss://collab.example/ws"
|
|
182
|
-
documentName="doc:readme"
|
|
183
|
-
token="jwt"
|
|
184
|
-
user={{ name: "Ada" }}
|
|
185
|
-
>
|
|
186
|
-
<TiptapEditor />
|
|
187
|
-
</ResolvedProvider>,
|
|
188
|
-
);
|
|
189
|
-
|
|
190
|
-
expect(screen.getByText("Loading editor…")).toBeInTheDocument();
|
|
191
|
-
expect(screen.queryByTestId("editor-content")).toBeNull();
|
|
192
|
-
|
|
193
|
-
releasePeers?.();
|
|
194
|
-
|
|
195
|
-
await waitFor(() => {
|
|
196
|
-
expect(screen.getByTestId("editor-content")).toBeInTheDocument();
|
|
197
|
-
});
|
|
198
|
-
expect(screen.queryByText("Loading editor…")).toBeNull();
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("fails with the install list when the collaboration peers cannot be loaded", async () => {
|
|
202
|
-
const missing =
|
|
203
|
-
"@tangle-network/ui/editor needs its optional collaboration peers. Install yjs.";
|
|
204
|
-
vi.resetModules();
|
|
205
|
-
vi.doMock("./editor-peers", async () => {
|
|
206
|
-
const actual = await vi.importActual<typeof EditorPeers>("./editor-peers");
|
|
207
|
-
return {
|
|
208
|
-
...actual,
|
|
209
|
-
loadCollaborationPeers: async () => {
|
|
210
|
-
throw new actual.MissingEditorPeersError(missing);
|
|
211
|
-
},
|
|
212
|
-
};
|
|
213
|
-
});
|
|
214
|
-
const { TiptapEditor } = await import("./tiptap-editor");
|
|
215
|
-
|
|
216
|
-
// The wrapper reaches its loader before it reads the editor context, so
|
|
217
|
-
// the install list surfaces without a provider around it.
|
|
218
|
-
render(
|
|
219
|
-
<ErrorTrap>
|
|
220
|
-
<TiptapEditor />
|
|
221
|
-
</ErrorTrap>,
|
|
222
|
-
);
|
|
223
|
-
|
|
224
|
-
await waitFor(() => {
|
|
225
|
-
expect(screen.getByText(missing)).toBeInTheDocument();
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
});
|
|
@@ -1,301 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
-
import { FileArtifactPane } from "./file-artifact-pane";
|
|
4
|
-
import { CSV_PREVIEW_ROW_LIMIT, parseCsv } from "./file-preview";
|
|
5
|
-
import { MissingPdfjsError, loadPdfjs } from "./pdf-loader";
|
|
6
|
-
|
|
7
|
-
vi.mock("./pdf-loader", async (importOriginal) => ({
|
|
8
|
-
...(await importOriginal<typeof import("./pdf-loader")>()),
|
|
9
|
-
loadPdfjs: vi.fn(),
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
const BLOB_URL = "blob:https://app.tangle.tools/1f2e3d4c";
|
|
13
|
-
|
|
14
|
-
function csvWithRows(rowCount: number): string {
|
|
15
|
-
const rows = Array.from({ length: rowCount }, (_, i) => `"Ada, Countess ${i}",x,${i}`);
|
|
16
|
-
return ["name,note,n", ...rows].join("\n");
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
describe("FileArtifactPane previews", () => {
|
|
20
|
-
it("renders an image from blobUrl and toggles natural size on click", () => {
|
|
21
|
-
const { container } = render(
|
|
22
|
-
<FileArtifactPane filename="chart.png" blobUrl={BLOB_URL} mimeType="image/png" />,
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
const img = container.querySelector("img");
|
|
26
|
-
expect(img).not.toBeNull();
|
|
27
|
-
expect(img).toHaveAttribute("src", BLOB_URL);
|
|
28
|
-
expect(img).toHaveAttribute("alt", "chart.png");
|
|
29
|
-
expect(img).toHaveClass("object-contain");
|
|
30
|
-
|
|
31
|
-
const toggle = screen.getByRole("button", { name: "Show image at natural size" });
|
|
32
|
-
expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
33
|
-
fireEvent.click(toggle);
|
|
34
|
-
expect(screen.getByRole("button", { name: "Fit image to pane" })).toHaveAttribute(
|
|
35
|
-
"aria-pressed",
|
|
36
|
-
"true",
|
|
37
|
-
);
|
|
38
|
-
expect(container.querySelector("img")).toHaveClass("max-w-none");
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it("renders a PDF in PdfViewer, and in an <object> of the blobUrl without pdfjs-dist", async () => {
|
|
42
|
-
vi.mocked(loadPdfjs).mockRejectedValue(new MissingPdfjsError());
|
|
43
|
-
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
44
|
-
const { container } = render(
|
|
45
|
-
<FileArtifactPane filename="report.pdf" blobUrl={BLOB_URL} mimeType="application/pdf" />,
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
expect(screen.getByRole("status")).toHaveTextContent("Loading report.pdf");
|
|
49
|
-
expect(await screen.findByText(/does not include the page viewer/)).toBeInTheDocument();
|
|
50
|
-
const viewer = container.querySelector("object");
|
|
51
|
-
expect(viewer).toHaveAttribute("data", BLOB_URL);
|
|
52
|
-
expect(viewer).toHaveAttribute("type", "application/pdf");
|
|
53
|
-
expect(viewer).toHaveAttribute("title", "report.pdf");
|
|
54
|
-
consoleError.mockRestore();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("renders video and audio with native controls", () => {
|
|
58
|
-
const video = render(<FileArtifactPane filename="demo.mp4" blobUrl={BLOB_URL} />);
|
|
59
|
-
const videoElement = video.container.querySelector("video");
|
|
60
|
-
expect(videoElement).not.toBeNull();
|
|
61
|
-
expect(videoElement).toHaveAttribute("src", BLOB_URL);
|
|
62
|
-
expect(videoElement).toHaveAttribute("controls");
|
|
63
|
-
video.unmount();
|
|
64
|
-
|
|
65
|
-
const audio = render(<FileArtifactPane filename="voice.m4a" blobUrl={BLOB_URL} />);
|
|
66
|
-
const audioElement = audio.container.querySelector("audio");
|
|
67
|
-
expect(audioElement).not.toBeNull();
|
|
68
|
-
expect(audioElement).toHaveAttribute("src", BLOB_URL);
|
|
69
|
-
expect(audioElement).toHaveAttribute("controls");
|
|
70
|
-
expect(audio.getAllByText("voice.m4a").length).toBeGreaterThan(1); // header + player label
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it("parses quoted commas in CSV and caps the table at the row limit", () => {
|
|
74
|
-
const total = CSV_PREVIEW_ROW_LIMIT + 1;
|
|
75
|
-
const { container } = render(
|
|
76
|
-
<FileArtifactPane filename="people.csv" content={csvWithRows(total)} />,
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
const headers = Array.from(container.querySelectorAll("thead th")).map((th) => th.textContent);
|
|
80
|
-
expect(headers).toEqual(["name", "note", "n"]);
|
|
81
|
-
expect(container.querySelectorAll("thead th")[0]).toHaveClass("sticky");
|
|
82
|
-
|
|
83
|
-
const bodyRows = container.querySelectorAll("tbody tr");
|
|
84
|
-
expect(bodyRows).toHaveLength(CSV_PREVIEW_ROW_LIMIT);
|
|
85
|
-
expect(bodyRows[0].querySelectorAll("td")[0]).toHaveTextContent("Ada, Countess 0");
|
|
86
|
-
expect(bodyRows[0].querySelectorAll("td")).toHaveLength(3);
|
|
87
|
-
|
|
88
|
-
expect(
|
|
89
|
-
screen.getByText(`Showing ${CSV_PREVIEW_ROW_LIMIT} of ${total} rows · 3 columns`),
|
|
90
|
-
).toBeInTheDocument();
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("reports the row count when the CSV is under the cap", () => {
|
|
94
|
-
render(<FileArtifactPane filename="people.csv" content={csvWithRows(3)} />);
|
|
95
|
-
expect(screen.getByText("3 rows · 3 columns")).toBeInTheDocument();
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("renders a spreadsheet as a download card and calls onDownload", () => {
|
|
99
|
-
const onDownload = vi.fn();
|
|
100
|
-
render(
|
|
101
|
-
<FileArtifactPane filename="book.xlsx" size={1536} onDownload={onDownload} />,
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
expect(screen.getByText("1.5 KB")).toBeInTheDocument();
|
|
105
|
-
expect(
|
|
106
|
-
screen.getByText("Download to open this workbook in a spreadsheet app."),
|
|
107
|
-
).toBeInTheDocument();
|
|
108
|
-
|
|
109
|
-
fireEvent.click(screen.getByRole("button", { name: "Download" }));
|
|
110
|
-
expect(onDownload).toHaveBeenCalledTimes(1);
|
|
111
|
-
|
|
112
|
-
// The header keeps its own download control.
|
|
113
|
-
fireEvent.click(screen.getByRole("button", { name: "Download book.xlsx" }));
|
|
114
|
-
expect(onDownload).toHaveBeenCalledTimes(2);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("renders markdown through the package Markdown renderer", () => {
|
|
118
|
-
const { container } = render(
|
|
119
|
-
<FileArtifactPane filename="README.md" content={"# Title\n\nBody with **bold**."} />,
|
|
120
|
-
);
|
|
121
|
-
expect(container.querySelector(".tangle-prose")).not.toBeNull();
|
|
122
|
-
expect(container.querySelector("h1")).toHaveTextContent("Title");
|
|
123
|
-
expect(container.querySelector("strong")).toHaveTextContent("bold");
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("forwards Markdown URL transforms to file and rich editor previews", async () => {
|
|
127
|
-
const urlTransform = vi.fn((url: string, key: string) => (
|
|
128
|
-
key === "href" && url === "sibling.md"
|
|
129
|
-
? "/app/ws-1/vault?file=docs%2Fsibling.md"
|
|
130
|
-
: url
|
|
131
|
-
));
|
|
132
|
-
const readOnly = render(
|
|
133
|
-
<FileArtifactPane
|
|
134
|
-
filename="README.md"
|
|
135
|
-
content={"[Sibling](sibling.md)"}
|
|
136
|
-
urlTransform={urlTransform}
|
|
137
|
-
/>,
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
expect(screen.getByRole("link", { name: "Sibling" })).toHaveAttribute(
|
|
141
|
-
"href",
|
|
142
|
-
"/app/ws-1/vault?file=docs%2Fsibling.md",
|
|
143
|
-
);
|
|
144
|
-
readOnly.unmount();
|
|
145
|
-
|
|
146
|
-
render(
|
|
147
|
-
<FileArtifactPane
|
|
148
|
-
path="docs/README.md"
|
|
149
|
-
filename="README.md"
|
|
150
|
-
content={"[Sibling](sibling.md)"}
|
|
151
|
-
urlTransform={urlTransform}
|
|
152
|
-
editor={{ enabled: true, defaultMode: "preview" }}
|
|
153
|
-
/>,
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
// The rich editor lazy-loads its editor packages; under a loaded host that takes
|
|
157
|
-
// longer than findBy's 1s default.
|
|
158
|
-
expect(await screen.findByRole("link", { name: "Sibling" }, { timeout: 10_000 })).toHaveAttribute(
|
|
159
|
-
"href",
|
|
160
|
-
"/app/ws-1/vault?file=docs%2Fsibling.md",
|
|
161
|
-
);
|
|
162
|
-
expect(urlTransform).toHaveBeenCalledWith(
|
|
163
|
-
"sibling.md",
|
|
164
|
-
"href",
|
|
165
|
-
expect.objectContaining({ tagName: "a" }),
|
|
166
|
-
);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("renders a binary file with no download link as one muted sentence", () => {
|
|
170
|
-
const onDownload = vi.fn();
|
|
171
|
-
const withDownload = render(
|
|
172
|
-
<FileArtifactPane filename="weights.bin" onDownload={onDownload} />,
|
|
173
|
-
);
|
|
174
|
-
expect(
|
|
175
|
-
withDownload.getByText("This file needs a download link to preview."),
|
|
176
|
-
).toBeInTheDocument();
|
|
177
|
-
fireEvent.click(withDownload.getByRole("button", { name: "Download" }));
|
|
178
|
-
expect(onDownload).toHaveBeenCalledTimes(1);
|
|
179
|
-
withDownload.unmount();
|
|
180
|
-
|
|
181
|
-
render(<FileArtifactPane filename="weights.bin" />);
|
|
182
|
-
expect(screen.getByText("This file needs a download link to preview.")).toBeInTheDocument();
|
|
183
|
-
expect(screen.queryByRole("button", { name: "Download" })).toBeNull();
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it("renders the same sentence for an image, PDF, or video without a blobUrl", () => {
|
|
187
|
-
for (const filename of ["chart.png", "report.pdf", "demo.mp4"]) {
|
|
188
|
-
const { getByText, unmount } = render(<FileArtifactPane filename={filename} />);
|
|
189
|
-
expect(getByText("This file needs a download link to preview.")).toBeInTheDocument();
|
|
190
|
-
unmount();
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it("renders an unknown file's text content as text", () => {
|
|
195
|
-
const { container } = render(
|
|
196
|
-
<FileArtifactPane filename="Makefile.custom" content={"build:\n\tgo build"} />,
|
|
197
|
-
);
|
|
198
|
-
expect(container.querySelector("pre")).toHaveTextContent("go build");
|
|
199
|
-
expect(container.querySelector("code")).toBeNull();
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it("keeps code on the syntax highlighter and text on a plain pre", () => {
|
|
203
|
-
const code = render(
|
|
204
|
-
<FileArtifactPane filename="server.ts" content={"const x = 1;\nexport { x };"} />,
|
|
205
|
-
);
|
|
206
|
-
expect(code.container.querySelector("code")).not.toBeNull();
|
|
207
|
-
expect(code.container.querySelector("table")).toBeNull();
|
|
208
|
-
expect(code.getByText(/ts · 2 lines/)).toBeInTheDocument();
|
|
209
|
-
code.unmount();
|
|
210
|
-
|
|
211
|
-
const text = render(<FileArtifactPane filename="notes.txt" content="just words" />);
|
|
212
|
-
expect(text.container.querySelector("pre")).toHaveTextContent("just words");
|
|
213
|
-
expect(text.container.querySelector("code")).toBeNull();
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it("renders SVG source as code when the host read it as text", () => {
|
|
217
|
-
const { container } = render(
|
|
218
|
-
<FileArtifactPane filename="icon.svg" content={'<svg xmlns="http://www.w3.org/2000/svg"/>'} />,
|
|
219
|
-
);
|
|
220
|
-
expect(container.querySelector("code")).not.toBeNull();
|
|
221
|
-
expect(container.querySelector("img")).toBeNull();
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
describe("parseCsv", () => {
|
|
226
|
-
it("keeps commas, line breaks, and doubled quotes inside quoted cells", () => {
|
|
227
|
-
expect(parseCsv('a,b\n"x, y","line 1\nline 2"\n"say ""hi""",z')).toEqual([
|
|
228
|
-
["a", "b"],
|
|
229
|
-
["x, y", "line 1\nline 2"],
|
|
230
|
-
['say "hi"', "z"],
|
|
231
|
-
]);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("trims unquoted cells and accepts CRLF, blank lines, and a trailing line break", () => {
|
|
235
|
-
expect(parseCsv("a, b\r\n\r\n1 ,2\r\n")).toEqual([
|
|
236
|
-
["a", "b"],
|
|
237
|
-
["1", "2"],
|
|
238
|
-
]);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it("returns no rows for empty input", () => {
|
|
242
|
-
expect(parseCsv("")).toEqual([]);
|
|
243
|
-
expect(parseCsv("\n\n")).toEqual([]);
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
describe("FileArtifactPane controlled drafts", () => {
|
|
248
|
-
it("retains a host draft and saves it against the persisted baseline", async () => {
|
|
249
|
-
const onSave = vi.fn();
|
|
250
|
-
const draft = "# Unsaved source draft";
|
|
251
|
-
const props = {
|
|
252
|
-
filename: "notes.md",
|
|
253
|
-
content: draft,
|
|
254
|
-
editor: { enabled: true, savedContent: "# Saved notes", onSave },
|
|
255
|
-
};
|
|
256
|
-
const view = render(<FileArtifactPane {...props} />);
|
|
257
|
-
const save = await screen.findByRole("button", { name: "Save changes" }, { timeout: 10_000 });
|
|
258
|
-
expect(screen.getByRole("heading", { name: "Unsaved source draft" })).toBeVisible();
|
|
259
|
-
expect(screen.getByText("Unsaved changes")).toBeVisible();
|
|
260
|
-
expect(save).toBeEnabled();
|
|
261
|
-
fireEvent.click(save);
|
|
262
|
-
expect(onSave).toHaveBeenCalledWith(draft);
|
|
263
|
-
|
|
264
|
-
view.rerender(<FileArtifactPane {...props} editor={{ ...props.editor, savedContent: draft }} />);
|
|
265
|
-
// One save state: "Saved", with no Save button left to press.
|
|
266
|
-
expect(screen.getByRole("status")).toHaveTextContent("Saved");
|
|
267
|
-
expect(screen.queryByRole("button", { name: "Save changes" })).toBeNull();
|
|
268
|
-
expect(screen.queryByText("Local draft")).toBeNull();
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it("follows the host's dirty flag over its own comparison, and saves on Ctrl+S", async () => {
|
|
272
|
-
const onSave = vi.fn();
|
|
273
|
-
const content = "# Same text the host saved";
|
|
274
|
-
const props = { filename: "notes.md", content, editor: { enabled: true, dirty: true, onSave } };
|
|
275
|
-
const view = render(<FileArtifactPane {...props} />);
|
|
276
|
-
const save = await screen.findByRole("button", { name: "Save changes" }, { timeout: 10_000 });
|
|
277
|
-
expect(save).toBeEnabled();
|
|
278
|
-
expect(screen.getByRole("status")).toHaveTextContent("Unsaved changes");
|
|
279
|
-
|
|
280
|
-
fireEvent.keyDown(save, { key: "s", ctrlKey: true });
|
|
281
|
-
expect(onSave).toHaveBeenCalledWith(content);
|
|
282
|
-
|
|
283
|
-
view.rerender(<FileArtifactPane {...props} editor={{ ...props.editor, dirty: false }} />);
|
|
284
|
-
expect(screen.getByRole("status")).toHaveTextContent("Saved");
|
|
285
|
-
fireEvent.keyDown(screen.getByRole("status"), { key: "s", metaKey: true });
|
|
286
|
-
expect(onSave).toHaveBeenCalledTimes(1);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("shows Saving while the host saves", async () => {
|
|
290
|
-
render(
|
|
291
|
-
<FileArtifactPane
|
|
292
|
-
filename="notes.md"
|
|
293
|
-
content="# Draft"
|
|
294
|
-
editor={{ enabled: true, dirty: true, saving: true, onSave: vi.fn() }}
|
|
295
|
-
/>,
|
|
296
|
-
);
|
|
297
|
-
const save = await screen.findByRole("button", { name: "Save changes" }, { timeout: 10_000 });
|
|
298
|
-
expect(save).toBeDisabled();
|
|
299
|
-
expect(screen.getByRole("status")).toHaveTextContent("Saving…");
|
|
300
|
-
});
|
|
301
|
-
});
|