@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,60 +0,0 @@
|
|
|
1
|
-
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import type * as EditorPeers from "./editor-peers";
|
|
4
|
-
|
|
5
|
-
const markdown = "# Release notes\n\nThe editor loads on demand.";
|
|
6
|
-
|
|
7
|
-
afterEach(() => {
|
|
8
|
-
vi.resetModules();
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
/** Loads the pane with every peer loader rejecting, as for a consumer that
|
|
12
|
-
* installed neither tiptap nor the collaboration stack. The spies report
|
|
13
|
-
* whether a render reached a loader at all. */
|
|
14
|
-
async function paneWithoutPeers(message: string) {
|
|
15
|
-
const loaders = {
|
|
16
|
-
loadDocumentEditorPeers: vi.fn(async () => {
|
|
17
|
-
throw new Error(message);
|
|
18
|
-
}),
|
|
19
|
-
loadCollaborationPeers: vi.fn(async () => {
|
|
20
|
-
throw new Error(message);
|
|
21
|
-
}),
|
|
22
|
-
loadEditorProviderPeers: vi.fn(async () => {
|
|
23
|
-
throw new Error(message);
|
|
24
|
-
}),
|
|
25
|
-
};
|
|
26
|
-
vi.resetModules();
|
|
27
|
-
vi.doMock("./editor-peers", async () => ({
|
|
28
|
-
...(await vi.importActual<typeof EditorPeers>("./editor-peers")),
|
|
29
|
-
...loaders,
|
|
30
|
-
}));
|
|
31
|
-
const { DocumentEditorPane } = await import("./document-editor-pane");
|
|
32
|
-
return { DocumentEditorPane, loaders };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// The pane is imported on a fresh module graph, which re-transforms its whole
|
|
36
|
-
// dependency tree: seconds on a busy runner, past the 5s default.
|
|
37
|
-
describe("DocumentEditorPane without the editor peers", () => {
|
|
38
|
-
it("renders the preview, because only the edit tab needs the peers", async () => {
|
|
39
|
-
const { DocumentEditorPane, loaders } = await paneWithoutPeers(
|
|
40
|
-
"peers are not installed",
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
render(<DocumentEditorPane title="Notes" markdown={markdown} />);
|
|
44
|
-
|
|
45
|
-
// The promise this pane's editors carry is exactly this: a consumer that
|
|
46
|
-
// never opens the edit tab pays nothing for tiptap.
|
|
47
|
-
expect(screen.getByText("Release notes")).toBeInTheDocument();
|
|
48
|
-
expect(screen.getByText("The editor loads on demand.")).toBeInTheDocument();
|
|
49
|
-
expect(screen.getByRole("tab", { name: "Edit" })).toBeInTheDocument();
|
|
50
|
-
|
|
51
|
-
// Reaching no loader is the claim. Asserting only that the placeholder is
|
|
52
|
-
// absent would also pass for a pane that rendered nothing at all.
|
|
53
|
-
await waitFor(() => {
|
|
54
|
-
expect(screen.queryByText("Loading editor…")).toBeNull();
|
|
55
|
-
});
|
|
56
|
-
expect(loaders.loadDocumentEditorPeers).not.toHaveBeenCalled();
|
|
57
|
-
expect(loaders.loadCollaborationPeers).not.toHaveBeenCalled();
|
|
58
|
-
expect(loaders.loadEditorProviderPeers).not.toHaveBeenCalled();
|
|
59
|
-
}, 30_000);
|
|
60
|
-
});
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
-
import { Component, type ReactNode, Suspense } from "react";
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { retryableLazyEditor } from "./editor-lazy";
|
|
5
|
-
import { MissingEditorPeersError } from "./editor-peers";
|
|
6
|
-
|
|
7
|
-
/** Catches the rejection the lazy component throws, and can mount it again. */
|
|
8
|
-
class ResettableBoundary extends Component<
|
|
9
|
-
{ children: ReactNode },
|
|
10
|
-
{ failure: Error | null }
|
|
11
|
-
> {
|
|
12
|
-
state = { failure: null as Error | null };
|
|
13
|
-
|
|
14
|
-
static getDerivedStateFromError(error: Error) {
|
|
15
|
-
return { failure: error };
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
render() {
|
|
19
|
-
if (this.state.failure) {
|
|
20
|
-
return (
|
|
21
|
-
<button type="button" onClick={() => this.setState({ failure: null })}>
|
|
22
|
-
failed: {this.state.failure.message}
|
|
23
|
-
</button>
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
return this.props.children;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function Loaded() {
|
|
31
|
-
return <span>editor</span>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** React reports a caught render error on the console; the assertions read it. */
|
|
35
|
-
let consoleError: ReturnType<typeof vi.spyOn>;
|
|
36
|
-
|
|
37
|
-
beforeEach(() => {
|
|
38
|
-
consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
afterEach(() => {
|
|
42
|
-
consoleError.mockRestore();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
function renderRetryable(load: () => Promise<typeof Loaded>) {
|
|
46
|
-
const lazyEditor = retryableLazyEditor(load);
|
|
47
|
-
function Host() {
|
|
48
|
-
const LazyEditor = lazyEditor();
|
|
49
|
-
return (
|
|
50
|
-
<Suspense fallback={<span>loading</span>}>
|
|
51
|
-
<LazyEditor />
|
|
52
|
-
</Suspense>
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
return render(
|
|
56
|
-
<ResettableBoundary>
|
|
57
|
-
<Host />
|
|
58
|
-
</ResettableBoundary>,
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
describe("retryableLazyEditor", () => {
|
|
63
|
-
it("re-attempts the load after a transient failure", async () => {
|
|
64
|
-
// `lazy` caches its first rejection for the life of the component, so one
|
|
65
|
-
// failed chunk fetch would otherwise hold the editor broken until a full
|
|
66
|
-
// page reload.
|
|
67
|
-
const load = vi
|
|
68
|
-
.fn<() => Promise<typeof Loaded>>()
|
|
69
|
-
.mockRejectedValueOnce(new Error("Failed to fetch dynamically imported module"))
|
|
70
|
-
.mockResolvedValue(Loaded);
|
|
71
|
-
|
|
72
|
-
renderRetryable(load);
|
|
73
|
-
|
|
74
|
-
const retry = await screen.findByRole("button");
|
|
75
|
-
expect(retry).toHaveTextContent("Failed to fetch dynamically imported module");
|
|
76
|
-
expect(load).toHaveBeenCalledTimes(1);
|
|
77
|
-
|
|
78
|
-
fireEvent.click(retry);
|
|
79
|
-
|
|
80
|
-
expect(await screen.findByText("editor")).toBeInTheDocument();
|
|
81
|
-
expect(load).toHaveBeenCalledTimes(2);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("keeps a missing peer permanent, so a remount does not import it again", async () => {
|
|
85
|
-
// A package does not install itself mid-session. Retrying would only
|
|
86
|
-
// repeat the same failure and hide the install list behind a loop.
|
|
87
|
-
const load = vi
|
|
88
|
-
.fn<() => Promise<typeof Loaded>>()
|
|
89
|
-
.mockRejectedValue(new MissingEditorPeersError("Install @tiptap/react."));
|
|
90
|
-
|
|
91
|
-
renderRetryable(load);
|
|
92
|
-
|
|
93
|
-
const retry = await screen.findByRole("button");
|
|
94
|
-
expect(retry).toHaveTextContent("Install @tiptap/react.");
|
|
95
|
-
expect(load).toHaveBeenCalledTimes(1);
|
|
96
|
-
|
|
97
|
-
fireEvent.click(retry);
|
|
98
|
-
|
|
99
|
-
expect(await screen.findByRole("button")).toHaveTextContent("Install @tiptap/react.");
|
|
100
|
-
expect(load).toHaveBeenCalledTimes(1);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
asMissingEditorPeersError,
|
|
4
|
-
isMissingEditorPeersError,
|
|
5
|
-
isMissingPeerError,
|
|
6
|
-
} from "./editor-peers";
|
|
7
|
-
|
|
8
|
-
/** The namespace shape each peer has when it is installed. */
|
|
9
|
-
const installedPeers: Record<string, Record<string, unknown>> = {
|
|
10
|
-
"@tiptap/react": { useEditor: () => null, EditorContent: () => null },
|
|
11
|
-
"@tiptap/starter-kit": { default: { configure: () => ({}) } },
|
|
12
|
-
"@tiptap/extension-collaboration": { default: { configure: () => ({}) } },
|
|
13
|
-
"@tiptap/extension-collaboration-caret": { default: { configure: () => ({}) } },
|
|
14
|
-
"@hocuspocus/provider": { HocuspocusProvider: class {} },
|
|
15
|
-
yjs: { Doc: class {} },
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Loads the module under test with the named peers replaced. The loaders read
|
|
20
|
-
* their peers through a dynamic import, so the substitution has to happen
|
|
21
|
-
* before the module graph is built — hence the reset and the dynamic import.
|
|
22
|
-
*/
|
|
23
|
-
async function loadPeerLoaders(
|
|
24
|
-
replacements: Record<string, Record<string, unknown>> = {},
|
|
25
|
-
) {
|
|
26
|
-
vi.resetModules();
|
|
27
|
-
for (const specifier of Object.keys(installedPeers)) {
|
|
28
|
-
const namespace = replacements[specifier] ?? installedPeers[specifier];
|
|
29
|
-
vi.doMock(specifier, () => namespace);
|
|
30
|
-
}
|
|
31
|
-
return await import("./editor-peers");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
afterEach(() => {
|
|
35
|
-
vi.resetModules();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe("isMissingPeerError", () => {
|
|
39
|
-
it("recognises what each bundler and runtime says for an uninstalled package", () => {
|
|
40
|
-
const messages = [
|
|
41
|
-
// Vite's stub for an optional peer that is not installed.
|
|
42
|
-
'Could not resolve "@tiptap/react" imported by "@tangle-network/ui". Is it installed?',
|
|
43
|
-
// Node ESM.
|
|
44
|
-
"Cannot find package '@tiptap/react' imported from /app/index.js",
|
|
45
|
-
// CommonJS.
|
|
46
|
-
"Cannot find module '@tiptap/react'",
|
|
47
|
-
// webpack.
|
|
48
|
-
"Module not found: Error: Can't resolve '@tiptap/react' in '/app'",
|
|
49
|
-
// esbuild.
|
|
50
|
-
'Failed to resolve entry for package "@tiptap/react"',
|
|
51
|
-
];
|
|
52
|
-
|
|
53
|
-
for (const message of messages) {
|
|
54
|
-
expect(isMissingPeerError(new Error(message)), message).toBe(true);
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("leaves a transient chunk failure to report its own cause", () => {
|
|
59
|
-
// Enriching this one with "install the peers" sends the reader to the
|
|
60
|
-
// wrong fix, and the peers are already installed.
|
|
61
|
-
expect(
|
|
62
|
-
isMissingPeerError(new Error("Failed to fetch dynamically imported module")),
|
|
63
|
-
).toBe(false);
|
|
64
|
-
expect(isMissingPeerError(new Error("Network request failed"))).toBe(false);
|
|
65
|
-
expect(isMissingPeerError("Could not resolve")).toBe(false);
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe("editor optional peer loaders", () => {
|
|
70
|
-
it("returns the namespaces the editors call", async () => {
|
|
71
|
-
const { loadCollaborationPeers, loadDocumentEditorPeers } = await loadPeerLoaders();
|
|
72
|
-
|
|
73
|
-
const documentPeers = await loadDocumentEditorPeers();
|
|
74
|
-
expect(documentPeers.react.useEditor).toBe(installedPeers["@tiptap/react"].useEditor);
|
|
75
|
-
expect(documentPeers.starterKit.default).toBe(
|
|
76
|
-
installedPeers["@tiptap/starter-kit"].default,
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
const collaborationPeers = await loadCollaborationPeers();
|
|
80
|
-
expect(collaborationPeers.hocuspocus.HocuspocusProvider).toBe(
|
|
81
|
-
installedPeers["@hocuspocus/provider"].HocuspocusProvider,
|
|
82
|
-
);
|
|
83
|
-
expect(collaborationPeers.yjs.Doc).toBe(installedPeers.yjs.Doc);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("names the install list when a peer resolves to a namespace with no editor in it", async () => {
|
|
87
|
-
// A bundler can stub a missing optional peer as a silent empty namespace
|
|
88
|
-
// rather than a throwing module. That shape must still fail with the peers
|
|
89
|
-
// named, and not as an undefined-property crash in the middle of a render.
|
|
90
|
-
const { loadDocumentEditorPeers } = await loadPeerLoaders({
|
|
91
|
-
"@tiptap/starter-kit": { default: undefined },
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
await expect(loadDocumentEditorPeers()).rejects.toThrow(
|
|
95
|
-
/Install @tiptap\/react and @tiptap\/starter-kit/,
|
|
96
|
-
);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("names the install list when an extension stub carries a default it cannot configure", async () => {
|
|
100
|
-
// The commonest stub shape is a default export that is present but empty.
|
|
101
|
-
// It passes a plain "is the default defined" test, and then the factory
|
|
102
|
-
// calls .configure() on it and throws an opaque TypeError mid-render.
|
|
103
|
-
const { loadDocumentEditorPeers } = await loadPeerLoaders({
|
|
104
|
-
"@tiptap/starter-kit": { default: {} },
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
await expect(loadDocumentEditorPeers()).rejects.toThrow(
|
|
108
|
-
/Install @tiptap\/react and @tiptap\/starter-kit/,
|
|
109
|
-
);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it.each([
|
|
113
|
-
"@tiptap/extension-collaboration",
|
|
114
|
-
"@tiptap/extension-collaboration-caret",
|
|
115
|
-
])("names the collaboration install list when %s is an empty default", async (specifier) => {
|
|
116
|
-
const { loadCollaborationPeers } = await loadPeerLoaders({
|
|
117
|
-
[specifier]: { default: {} },
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
await expect(loadCollaborationPeers()).rejects.toThrow(
|
|
121
|
-
/@hocuspocus\/provider and yjs/,
|
|
122
|
-
);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("marks a missing peer permanent and leaves any other failure retryable", async () => {
|
|
126
|
-
// editor-lazy.ts reads this distinction to decide whether a remount may
|
|
127
|
-
// try the import again.
|
|
128
|
-
const { loadDocumentEditorPeers } = await loadPeerLoaders({
|
|
129
|
-
"@tiptap/starter-kit": { default: {} },
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
await expect(loadDocumentEditorPeers()).rejects.toSatisfy(isMissingEditorPeersError);
|
|
133
|
-
expect(isMissingEditorPeersError(new Error("Failed to fetch dynamically imported module"))).toBe(
|
|
134
|
-
false,
|
|
135
|
-
);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("reports an unresolved import as a permanent missing peer and keeps the cause", async () => {
|
|
139
|
-
// The rejection a throwing stub gives must become the install list, so a
|
|
140
|
-
// consumer reads which packages to add, and must count as permanent so a
|
|
141
|
-
// remount does not retry an import that cannot start to succeed.
|
|
142
|
-
const resolutionFailure = new Error('Could not resolve "@tiptap/starter-kit"');
|
|
143
|
-
const mapped = asMissingEditorPeersError(resolutionFailure, "install the peers");
|
|
144
|
-
|
|
145
|
-
expect(isMissingEditorPeersError(mapped)).toBe(true);
|
|
146
|
-
expect(mapped).toHaveProperty("cause", resolutionFailure);
|
|
147
|
-
// The install list holds every peer the surface needs, so the message also
|
|
148
|
-
// names the one that is actually absent.
|
|
149
|
-
expect((mapped as Error).message).toBe(
|
|
150
|
-
"install the peers @tiptap/starter-kit did not resolve.",
|
|
151
|
-
);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("names the caret package rather than the collaboration package it contains", async () => {
|
|
155
|
-
// "@tiptap/extension-collaboration" is a prefix of the caret specifier, so
|
|
156
|
-
// a naive substring match reports the wrong package.
|
|
157
|
-
const caretFailure = new Error(
|
|
158
|
-
'Could not resolve "@tiptap/extension-collaboration-caret"',
|
|
159
|
-
);
|
|
160
|
-
const mapped = asMissingEditorPeersError(caretFailure, "install the peers");
|
|
161
|
-
|
|
162
|
-
expect((mapped as Error).message).toBe(
|
|
163
|
-
"install the peers @tiptap/extension-collaboration-caret did not resolve.",
|
|
164
|
-
);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("leaves a resolution failure that names no peer alone, so it stays retryable", async () => {
|
|
168
|
-
// An application chunk, or a dependency of a peer that did load, can fail
|
|
169
|
-
// to resolve too. Installing the editor peers would not fix that, and
|
|
170
|
-
// marking it permanent would stop a remount from trying again.
|
|
171
|
-
const unrelated = new Error("Cannot find module './app-chunk-a91f.js'");
|
|
172
|
-
const mapped = asMissingEditorPeersError(unrelated, "install the peers");
|
|
173
|
-
|
|
174
|
-
expect(mapped).toBe(unrelated);
|
|
175
|
-
expect(isMissingEditorPeersError(mapped)).toBe(false);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("treats a @tiptap/core failure as a missing peer, though no loader imports it", async () => {
|
|
179
|
-
// The tiptap packages need @tiptap/core in turn, so a consumer that
|
|
180
|
-
// resolves it to nothing breaks exactly as one missing @tiptap/react
|
|
181
|
-
// does. Leaving it out of the list left that failure retryable.
|
|
182
|
-
const coreFailure = new Error('Could not resolve "@tiptap/core"');
|
|
183
|
-
const mapped = asMissingEditorPeersError(coreFailure, "install the peers");
|
|
184
|
-
|
|
185
|
-
expect(isMissingEditorPeersError(mapped)).toBe(true);
|
|
186
|
-
expect((mapped as Error).message).toBe(
|
|
187
|
-
"install the peers @tiptap/core did not resolve.",
|
|
188
|
-
);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("keeps the install list alone when the failure names several peers", async () => {
|
|
192
|
-
const both = new Error(
|
|
193
|
-
'Could not resolve "@tiptap/react" or "@hocuspocus/provider"',
|
|
194
|
-
);
|
|
195
|
-
const mapped = asMissingEditorPeersError(both, "install the peers");
|
|
196
|
-
|
|
197
|
-
expect(isMissingEditorPeersError(mapped)).toBe(true);
|
|
198
|
-
expect((mapped as Error).message).toBe("install the peers");
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("leaves a transient chunk failure unchanged, so a remount can retry it", async () => {
|
|
202
|
-
const transient = new Error("Failed to fetch dynamically imported module");
|
|
203
|
-
|
|
204
|
-
expect(asMissingEditorPeersError(transient, "install the peers")).toBe(transient);
|
|
205
|
-
expect(isMissingEditorPeersError(transient)).toBe(false);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("names the install list when a peer carries the wrong member type", async () => {
|
|
209
|
-
const { loadDocumentEditorPeers } = await loadPeerLoaders({
|
|
210
|
-
"@tiptap/react": { useEditor: undefined, EditorContent: undefined },
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
await expect(loadDocumentEditorPeers()).rejects.toThrow(
|
|
214
|
-
/Install @tiptap\/react and @tiptap\/starter-kit/,
|
|
215
|
-
);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it("accepts the shapes the installed peers really have", async () => {
|
|
219
|
-
// The other cases in this file describe the peers, so nothing here would
|
|
220
|
-
// notice a tiptap release that moves `configure` off the default export.
|
|
221
|
-
// Loading the real packages is what turns that into a failure here rather
|
|
222
|
-
// than in a consumer's editor.
|
|
223
|
-
vi.resetModules();
|
|
224
|
-
for (const specifier of Object.keys(installedPeers)) {
|
|
225
|
-
vi.doUnmock(specifier);
|
|
226
|
-
}
|
|
227
|
-
const { loadCollaborationPeers } = await import("./editor-peers");
|
|
228
|
-
|
|
229
|
-
const peers = await loadCollaborationPeers();
|
|
230
|
-
// Prove the substitutions are gone; against the stubs this case would
|
|
231
|
-
// pass without reading a real package at all.
|
|
232
|
-
expect(peers.starterKit.default).not.toBe(installedPeers["@tiptap/starter-kit"].default);
|
|
233
|
-
expect(typeof peers.starterKit.default.configure).toBe("function");
|
|
234
|
-
expect(typeof peers.collaboration.default.configure).toBe("function");
|
|
235
|
-
expect(typeof peers.collaborationCaret.default.configure).toBe("function");
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
it("keeps the local editor usable when only a collaboration peer is missing", async () => {
|
|
239
|
-
// sandbox-ui installs the tiptap packages alone, so the local markdown
|
|
240
|
-
// editor must not start to require yjs or Hocuspocus.
|
|
241
|
-
const { loadCollaborationPeers, loadDocumentEditorPeers } = await loadPeerLoaders({
|
|
242
|
-
yjs: { Doc: undefined },
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
await expect(loadDocumentEditorPeers()).resolves.toBeDefined();
|
|
246
|
-
await expect(loadCollaborationPeers()).rejects.toThrow(
|
|
247
|
-
/@hocuspocus\/provider and yjs/,
|
|
248
|
-
);
|
|
249
|
-
});
|
|
250
|
-
});
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { Doc } from "yjs";
|
|
4
|
-
import { createEditorProvider, useEditorContext } from "./editor-provider";
|
|
5
|
-
import type * as EditorPeers from "./editor-peers";
|
|
6
|
-
import type { EditorProviderPeers } from "./editor-peers";
|
|
7
|
-
import { useEditorConnection } from "./use-editor";
|
|
8
|
-
|
|
9
|
-
interface ProviderOptions {
|
|
10
|
-
url: string;
|
|
11
|
-
name: string;
|
|
12
|
-
document: Doc;
|
|
13
|
-
onConnect: () => void;
|
|
14
|
-
onDisconnect: () => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
class StubHocuspocusProvider {
|
|
18
|
-
static instances: StubHocuspocusProvider[] = [];
|
|
19
|
-
|
|
20
|
-
awareness = {
|
|
21
|
-
clientID: 1,
|
|
22
|
-
getStates: () => new Map(),
|
|
23
|
-
getLocalState: () => ({}),
|
|
24
|
-
setLocalStateField: vi.fn(),
|
|
25
|
-
on: vi.fn(),
|
|
26
|
-
off: vi.fn(),
|
|
27
|
-
};
|
|
28
|
-
connect = vi.fn();
|
|
29
|
-
disconnect = vi.fn();
|
|
30
|
-
destroy = vi.fn();
|
|
31
|
-
|
|
32
|
-
constructor(readonly options: ProviderOptions) {
|
|
33
|
-
StubHocuspocusProvider.instances.push(this);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Only the members the provider reads; yjs is the real package. */
|
|
38
|
-
function stubPeers() {
|
|
39
|
-
return {
|
|
40
|
-
yjs: { Doc },
|
|
41
|
-
hocuspocus: { HocuspocusProvider: StubHocuspocusProvider },
|
|
42
|
-
} as unknown as EditorProviderPeers;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function ConnectionProbe() {
|
|
46
|
-
// Reads the context through the public hook, not through the provider
|
|
47
|
-
// module, so a context created per factory call would leave it unresolved.
|
|
48
|
-
const { state, isConnected } = useEditorConnection();
|
|
49
|
-
const { doc } = useEditorContext();
|
|
50
|
-
return (
|
|
51
|
-
<span data-testid="probe" data-fragment={doc.getXmlFragment("prosemirror").length}>
|
|
52
|
-
{state}
|
|
53
|
-
{isConnected ? " (live)" : ""}
|
|
54
|
-
</span>
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
afterEach(() => {
|
|
59
|
-
StubHocuspocusProvider.instances = [];
|
|
60
|
-
vi.doUnmock("./editor-peers");
|
|
61
|
-
vi.resetModules();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe("createEditorProvider", () => {
|
|
65
|
-
it("builds the document and the transport from the loaded peers", () => {
|
|
66
|
-
const EditorProvider = createEditorProvider(stubPeers());
|
|
67
|
-
|
|
68
|
-
render(
|
|
69
|
-
<EditorProvider
|
|
70
|
-
websocketUrl="wss://collab.example/ws"
|
|
71
|
-
documentName="doc:readme"
|
|
72
|
-
token="jwt"
|
|
73
|
-
user={{ name: "Ada" }}
|
|
74
|
-
>
|
|
75
|
-
<ConnectionProbe />
|
|
76
|
-
</EditorProvider>,
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
expect(StubHocuspocusProvider.instances).toHaveLength(1);
|
|
80
|
-
const [provider] = StubHocuspocusProvider.instances;
|
|
81
|
-
expect(provider.options.url).toBe("wss://collab.example/ws");
|
|
82
|
-
expect(provider.options.name).toBe("doc:readme");
|
|
83
|
-
// The Y.Doc came from `peers.yjs.Doc`, and the editor asks it for this
|
|
84
|
-
// fragment, so the transport and the editor must share the one instance.
|
|
85
|
-
expect(provider.options.document).toBeInstanceOf(Doc);
|
|
86
|
-
expect(provider.awareness.setLocalStateField).toHaveBeenCalledWith(
|
|
87
|
-
"user",
|
|
88
|
-
expect.objectContaining({ name: "Ada" }),
|
|
89
|
-
);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("serves the connection state to hooks that read the shared context", async () => {
|
|
93
|
-
const EditorProvider = createEditorProvider(stubPeers());
|
|
94
|
-
const onConnectionChange = vi.fn();
|
|
95
|
-
|
|
96
|
-
render(
|
|
97
|
-
<EditorProvider
|
|
98
|
-
websocketUrl="wss://collab.example/ws"
|
|
99
|
-
documentName="doc:readme"
|
|
100
|
-
token="jwt"
|
|
101
|
-
user={{ name: "Ada" }}
|
|
102
|
-
onConnectionChange={onConnectionChange}
|
|
103
|
-
>
|
|
104
|
-
<ConnectionProbe />
|
|
105
|
-
</EditorProvider>,
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
expect(screen.getByTestId("probe")).toHaveTextContent("connecting");
|
|
109
|
-
|
|
110
|
-
const [provider] = StubHocuspocusProvider.instances;
|
|
111
|
-
act(() => {
|
|
112
|
-
provider.options.onConnect();
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
await waitFor(() => {
|
|
116
|
-
expect(screen.getByTestId("probe")).toHaveTextContent("connected (live)");
|
|
117
|
-
});
|
|
118
|
-
expect(onConnectionChange).toHaveBeenCalledWith("connected");
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("destroys the transport when it unmounts", () => {
|
|
122
|
-
const EditorProvider = createEditorProvider(stubPeers());
|
|
123
|
-
|
|
124
|
-
const { unmount } = render(
|
|
125
|
-
<EditorProvider
|
|
126
|
-
websocketUrl="wss://collab.example/ws"
|
|
127
|
-
documentName="doc:readme"
|
|
128
|
-
token="jwt"
|
|
129
|
-
user={{ name: "Ada" }}
|
|
130
|
-
>
|
|
131
|
-
<ConnectionProbe />
|
|
132
|
-
</EditorProvider>,
|
|
133
|
-
);
|
|
134
|
-
|
|
135
|
-
const [provider] = StubHocuspocusProvider.instances;
|
|
136
|
-
unmount();
|
|
137
|
-
|
|
138
|
-
expect(provider.destroy).toHaveBeenCalledTimes(1);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("EditorProvider", () => {
|
|
143
|
-
it("renders with no tiptap package installed", async () => {
|
|
144
|
-
// The provider builds the document and the socket from yjs and Hocuspocus
|
|
145
|
-
// alone. A consumer that drives its own editor from this context installs
|
|
146
|
-
// those two and nothing else, so reaching for a tiptap namespace here
|
|
147
|
-
// would break that consumer's build-clean install.
|
|
148
|
-
const tiptapSpecifiers = [
|
|
149
|
-
"@tiptap/react",
|
|
150
|
-
"@tiptap/starter-kit",
|
|
151
|
-
"@tiptap/extension-collaboration",
|
|
152
|
-
"@tiptap/extension-collaboration-caret",
|
|
153
|
-
];
|
|
154
|
-
vi.resetModules();
|
|
155
|
-
for (const specifier of tiptapSpecifiers) {
|
|
156
|
-
vi.doMock(specifier, () => {
|
|
157
|
-
throw new Error(`Could not resolve "${specifier}"`);
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
vi.doMock("yjs", () => ({ Doc }));
|
|
161
|
-
vi.doMock("@hocuspocus/provider", () => ({
|
|
162
|
-
HocuspocusProvider: StubHocuspocusProvider,
|
|
163
|
-
}));
|
|
164
|
-
const { EditorProvider } = await import("./editor-provider");
|
|
165
|
-
|
|
166
|
-
render(
|
|
167
|
-
<EditorProvider
|
|
168
|
-
websocketUrl="wss://collab.example/ws"
|
|
169
|
-
documentName="doc:readme"
|
|
170
|
-
token="jwt"
|
|
171
|
-
user={{ name: "Ada" }}
|
|
172
|
-
>
|
|
173
|
-
<span data-testid="child">custom editor surface</span>
|
|
174
|
-
</EditorProvider>,
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
await waitFor(() => {
|
|
178
|
-
expect(screen.getByTestId("child")).toBeInTheDocument();
|
|
179
|
-
});
|
|
180
|
-
expect(StubHocuspocusProvider.instances).toHaveLength(1);
|
|
181
|
-
|
|
182
|
-
for (const specifier of [...tiptapSpecifiers, "yjs", "@hocuspocus/provider"]) {
|
|
183
|
-
vi.doUnmock(specifier);
|
|
184
|
-
}
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("holds children back until the peers load, then renders them", async () => {
|
|
188
|
-
let releasePeers: (() => void) | null = null;
|
|
189
|
-
const gate = new Promise<void>((resolve) => {
|
|
190
|
-
releasePeers = resolve;
|
|
191
|
-
});
|
|
192
|
-
vi.resetModules();
|
|
193
|
-
vi.doMock("./editor-peers", async () => ({
|
|
194
|
-
...(await vi.importActual<typeof EditorPeers>("./editor-peers")),
|
|
195
|
-
loadEditorProviderPeers: async () => {
|
|
196
|
-
await gate;
|
|
197
|
-
return stubPeers();
|
|
198
|
-
},
|
|
199
|
-
}));
|
|
200
|
-
const { EditorProvider } = await import("./editor-provider");
|
|
201
|
-
|
|
202
|
-
render(
|
|
203
|
-
<EditorProvider
|
|
204
|
-
websocketUrl="wss://collab.example/ws"
|
|
205
|
-
documentName="doc:readme"
|
|
206
|
-
token="jwt"
|
|
207
|
-
user={{ name: "Ada" }}
|
|
208
|
-
>
|
|
209
|
-
<span data-testid="child">connected surface</span>
|
|
210
|
-
</EditorProvider>,
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
// A child that rendered before the provider would throw out of
|
|
214
|
-
// `useEditorContext`, so the wrapper must render nothing while the gate
|
|
215
|
-
// holds the loader.
|
|
216
|
-
expect(screen.queryByTestId("child")).toBeNull();
|
|
217
|
-
expect(StubHocuspocusProvider.instances).toHaveLength(0);
|
|
218
|
-
|
|
219
|
-
releasePeers?.();
|
|
220
|
-
|
|
221
|
-
await waitFor(() => {
|
|
222
|
-
expect(screen.getByTestId("child")).toBeInTheDocument();
|
|
223
|
-
});
|
|
224
|
-
expect(StubHocuspocusProvider.instances).toHaveLength(1);
|
|
225
|
-
});
|
|
226
|
-
});
|