@peaske7/readit 0.1.8 → 0.2.1
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/.claude/CLAUDE.md +118 -76
- package/.claude/commands/review.md +1 -1
- package/.claude/roadmap.md +32 -9
- package/.claude/user-stories.md +100 -15
- package/AGENTS.md +30 -26
- package/Makefile +32 -0
- package/README.md +90 -5
- package/biome.json +18 -8
- package/bun.lock +426 -710
- package/bunfig.toml +2 -0
- package/docs/perf-baseline.md +130 -0
- package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
- package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
- package/e2e/comments.spec.ts +14 -58
- package/e2e/document-load.spec.ts +1 -23
- package/e2e/export.spec.ts +4 -4
- package/e2e/perf/add-comment.spec.ts +116 -0
- package/e2e/perf/fixtures/generate.ts +327 -0
- package/e2e/perf/initial-load.spec.ts +49 -0
- package/e2e/perf/perf.setup.ts +23 -0
- package/e2e/perf/perf.teardown.ts +9 -0
- package/e2e/perf/screenshot-final.png +0 -0
- package/e2e/perf/scroll.spec.ts +39 -0
- package/e2e/perf/tab-switch.spec.ts +69 -0
- package/e2e/perf/text-selection.spec.ts +119 -0
- package/e2e/perf/utils/metrics.ts +350 -0
- package/e2e/perf/utils/perf-cli.ts +86 -0
- package/e2e/persistence-file.spec.ts +41 -26
- package/e2e/utils/selection.ts +17 -73
- package/go/cmd/readit/main.go +416 -0
- package/go/go.mod +20 -0
- package/go/go.sum +41 -0
- package/go/internal/server/anchor.go +302 -0
- package/go/internal/server/anchor_test.go +111 -0
- package/go/internal/server/comments.go +390 -0
- package/go/internal/server/documents.go +113 -0
- package/go/internal/server/embed.go +17 -0
- package/go/internal/server/headings.go +33 -0
- package/go/internal/server/headings_test.go +75 -0
- package/go/internal/server/htmltext.go +123 -0
- package/go/internal/server/markdown.go +157 -0
- package/go/internal/server/markdown_bench_test.go +42 -0
- package/go/internal/server/markdown_test.go +79 -0
- package/go/internal/server/server.go +453 -0
- package/go/internal/server/server_bench_test.go +122 -0
- package/go/internal/server/settings.go +110 -0
- package/go/internal/server/sse.go +140 -0
- package/go/internal/server/storage.go +275 -0
- package/go/internal/server/storage_test.go +118 -0
- package/go/internal/server/template.go +66 -0
- package/go/internal/server/types.go +101 -0
- package/go/internal/server/watcher.go +74 -0
- package/index.html +4 -14
- package/nvim-readit/lua/readit/health.lua +64 -0
- package/nvim-readit/lua/readit/init.lua +463 -0
- package/nvim-readit/plugin/readit.lua +19 -0
- package/package.json +24 -41
- package/playwright.config.ts +12 -0
- package/shell/_readit +158 -0
- package/shell/readit.zsh +87 -0
- package/src/App.svelte +881 -0
- package/src/{cli/index.ts → cli.ts} +216 -70
- package/src/components/ActionsMenu.svelte +95 -0
- package/src/components/CommentBadge.svelte +67 -0
- package/src/components/CommentErrorBanner.svelte +33 -0
- package/src/components/CommentInput.svelte +75 -0
- package/src/components/CommentListItem.svelte +95 -0
- package/src/components/CommentManager.svelte +129 -0
- package/src/components/CommentNav.svelte +109 -0
- package/src/components/DocumentViewer.svelte +218 -0
- package/src/components/FloatingComment.svelte +107 -0
- package/src/components/Header.svelte +76 -0
- package/src/components/InlineEditor.svelte +72 -0
- package/src/components/MarginNote.svelte +167 -0
- package/src/components/MarginNotesContainer.svelte +33 -0
- package/src/components/RawModal.svelte +126 -0
- package/src/components/ReanchorConfirm.svelte +30 -0
- package/src/components/SettingsModal.svelte +220 -0
- package/src/components/ShortcutCapture.svelte +82 -0
- package/src/components/ShortcutList.svelte +145 -0
- package/src/components/TabBar.svelte +52 -0
- package/src/components/TableOfContents.svelte +125 -0
- package/src/components/ui/ActionLink.svelte +40 -0
- package/src/components/ui/Button.svelte +53 -0
- package/src/components/ui/Dialog.svelte +97 -0
- package/src/components/ui/DropdownMenu.svelte +85 -0
- package/src/components/ui/DropdownMenuItem.svelte +38 -0
- package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
- package/src/components/ui/Text.svelte +42 -0
- package/src/env.d.ts +6 -0
- package/src/index.css +36 -166
- package/src/lib/__fixtures__/bench-data.ts +1 -54
- package/src/lib/anchor.bench.ts +47 -68
- package/src/lib/anchor.test.ts +5 -9
- package/src/lib/anchor.ts +9 -93
- package/src/lib/comment-storage.bench.ts +6 -20
- package/src/lib/comment-storage.test.ts +45 -37
- package/src/lib/comment-storage.ts +23 -64
- package/src/lib/export.bench.ts +9 -23
- package/src/lib/export.ts +7 -14
- package/src/lib/headings.test.ts +103 -0
- package/src/lib/headings.ts +44 -0
- package/src/lib/highlight/core.test.ts +1 -6
- package/src/lib/highlight/dom.ts +53 -280
- package/src/lib/highlight/highlight-registry.ts +221 -0
- package/src/lib/highlight/highlight.bench.ts +92 -0
- package/src/lib/highlight/highlighter.ts +122 -302
- package/src/lib/highlight/{core.ts → resolver.ts} +3 -19
- package/src/lib/highlight/types.ts +0 -40
- package/src/lib/html-text.test.ts +162 -0
- package/src/lib/html-text.ts +161 -0
- package/src/lib/i18n/en.ts +13 -36
- package/src/lib/i18n/ja.ts +14 -37
- package/src/lib/i18n/types.ts +13 -36
- package/src/lib/margin-layout.bench.ts +48 -15
- package/src/lib/margin-layout.ts +2 -31
- package/src/lib/markdown-renderer.test.ts +154 -0
- package/src/lib/markdown-renderer.ts +177 -0
- package/src/lib/mermaid-config.ts +38 -0
- package/src/lib/mermaid-renderer.ts +162 -0
- package/src/lib/mermaid-worker.ts +60 -0
- package/src/lib/positions.ts +157 -0
- package/src/lib/shortcut-registry.ts +138 -103
- package/src/lib/utils.ts +2 -48
- package/src/main.ts +16 -0
- package/src/schema.ts +92 -0
- package/src/{server/index.ts → server.ts} +427 -163
- package/src/stores/app.svelte.ts +231 -0
- package/src/stores/locale.svelte.ts +46 -0
- package/src/stores/settings.svelte.ts +90 -0
- package/src/stores/shortcuts.svelte.ts +104 -0
- package/src/stores/ui.svelte.ts +12 -0
- package/src/template.ts +104 -0
- package/src/test-setup.ts +47 -0
- package/svelte.config.js +5 -0
- package/tsconfig.json +2 -2
- package/vite.config.ts +31 -3
- package/vscode-readit/.mcp.json +7 -0
- package/vscode-readit/.vscodeignore +7 -0
- package/vscode-readit/bun.lock +78 -0
- package/vscode-readit/icon.svg +10 -0
- package/vscode-readit/package.json +110 -0
- package/vscode-readit/src/extension.ts +117 -0
- package/vscode-readit/src/server-manager.ts +272 -0
- package/vscode-readit/src/webview-provider.ts +204 -0
- package/vscode-readit/tsconfig.json +20 -0
- package/e2e/fixtures/sample.html +0 -13
- package/src/App.tsx +0 -416
- package/src/components/ActionsMenu.tsx +0 -112
- package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
- package/src/components/DocumentViewer/DocumentViewer.tsx +0 -259
- package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
- package/src/components/DocumentViewer/InlineCode.tsx +0 -60
- package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -137
- package/src/components/DocumentViewer/index.ts +0 -1
- package/src/components/FloatingTOC.tsx +0 -61
- package/src/components/Header.tsx +0 -65
- package/src/components/InlineEditor.tsx +0 -74
- package/src/components/MarginNote.tsx +0 -207
- package/src/components/MarginNotes.tsx +0 -50
- package/src/components/RawModal.tsx +0 -143
- package/src/components/ReanchorConfirm.tsx +0 -36
- package/src/components/SettingsModal.tsx +0 -310
- package/src/components/ShortcutCapture.tsx +0 -48
- package/src/components/ShortcutList.tsx +0 -198
- package/src/components/TabBar.tsx +0 -60
- package/src/components/TableOfContents.tsx +0 -108
- package/src/components/comments/CommentBadge.tsx +0 -49
- package/src/components/comments/CommentInput.tsx +0 -114
- package/src/components/comments/CommentListItem.tsx +0 -92
- package/src/components/comments/CommentManager.tsx +0 -113
- package/src/components/comments/CommentMinimap.tsx +0 -62
- package/src/components/comments/CommentNav.tsx +0 -109
- package/src/components/ui/ActionBar.tsx +0 -16
- package/src/components/ui/ActionLink.tsx +0 -32
- package/src/components/ui/Button.tsx +0 -55
- package/src/components/ui/Dialog.tsx +0 -156
- package/src/components/ui/DropdownMenu.tsx +0 -114
- package/src/components/ui/SeparatorDot.tsx +0 -9
- package/src/components/ui/Text.tsx +0 -54
- package/src/contexts/CommentContext.tsx +0 -229
- package/src/contexts/LayoutContext.tsx +0 -88
- package/src/contexts/LocaleContext.tsx +0 -35
- package/src/hooks/useClickOutside.ts +0 -35
- package/src/hooks/useClipboard.ts +0 -82
- package/src/hooks/useCommentNavigation.ts +0 -130
- package/src/hooks/useComments.ts +0 -323
- package/src/hooks/useDocument.ts +0 -156
- package/src/hooks/useEditorScheme.ts +0 -51
- package/src/hooks/useFontPreference.ts +0 -59
- package/src/hooks/useHeadings.test.ts +0 -159
- package/src/hooks/useHeadings.ts +0 -129
- package/src/hooks/useKeybindings.ts +0 -108
- package/src/hooks/useKeyboardShortcuts.ts +0 -63
- package/src/hooks/useLayoutMode.ts +0 -44
- package/src/hooks/useLocalePreference.ts +0 -42
- package/src/hooks/useReanchorMode.ts +0 -33
- package/src/hooks/useScrollMetrics.ts +0 -56
- package/src/hooks/useScrollSpy.ts +0 -81
- package/src/hooks/useTextSelection.ts +0 -123
- package/src/hooks/useThemePreference.ts +0 -66
- package/src/lib/context.bench.ts +0 -41
- package/src/lib/context.test.ts +0 -224
- package/src/lib/context.ts +0 -193
- package/src/lib/editor-links.ts +0 -59
- package/src/lib/highlight/colors.ts +0 -37
- package/src/lib/highlight/index.ts +0 -23
- package/src/lib/highlight/script-builder.ts +0 -485
- package/src/lib/html-processor.test.tsx +0 -170
- package/src/lib/html-processor.tsx +0 -95
- package/src/lib/i18n/completeness.test.ts +0 -51
- package/src/lib/i18n/translations.test.ts +0 -39
- package/src/lib/layout-constants.ts +0 -12
- package/src/lib/scroll.test.ts +0 -118
- package/src/lib/scroll.ts +0 -47
- package/src/lib/shortcut-registry.test.ts +0 -173
- package/src/lib/utils.test.ts +0 -110
- package/src/main.tsx +0 -13
- package/src/store/index.test.ts +0 -242
- package/src/store/index.ts +0 -254
- package/src/types/index.ts +0 -127
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import type { Page, TestInfo } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface LoadMetrics {
|
|
6
|
+
fcp: number | null;
|
|
7
|
+
domContentLoaded: number;
|
|
8
|
+
allHighlightsPainted: number;
|
|
9
|
+
pageReady: number | null;
|
|
10
|
+
highlightCount: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ScrollMetrics {
|
|
14
|
+
totalTimeMs: number;
|
|
15
|
+
longTaskCount: number;
|
|
16
|
+
longTaskDurations: number[];
|
|
17
|
+
p50: number;
|
|
18
|
+
p95: number;
|
|
19
|
+
p99: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface InteractionMetrics {
|
|
23
|
+
durationMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Wait for highlights ─────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Polls the DOM for comment highlights until either:
|
|
30
|
+
* - The expected count is reached, OR
|
|
31
|
+
* - The count stabilizes (no change for `stableMs` milliseconds)
|
|
32
|
+
*
|
|
33
|
+
* Returns the timestamp when highlights finished painting.
|
|
34
|
+
* Not all comments may resolve to highlights (anchor failures, overlaps).
|
|
35
|
+
*/
|
|
36
|
+
export async function waitForHighlightCount(
|
|
37
|
+
page: Page,
|
|
38
|
+
expectedCount: number,
|
|
39
|
+
timeoutMs = 60_000,
|
|
40
|
+
): Promise<number> {
|
|
41
|
+
return page.evaluate(
|
|
42
|
+
({ expected, timeout, stableWindow }) => {
|
|
43
|
+
return new Promise<number>((resolve, reject) => {
|
|
44
|
+
const deadline = performance.now() + timeout;
|
|
45
|
+
let lastCount = 0;
|
|
46
|
+
let lastChangeTime = performance.now();
|
|
47
|
+
|
|
48
|
+
const check = () => {
|
|
49
|
+
// Use the CSS Custom Highlight API observability hook
|
|
50
|
+
const highlights = (window as unknown as Record<string, unknown>)
|
|
51
|
+
.__readitHighlights as { commentIds: string[] } | undefined;
|
|
52
|
+
const count = highlights?.commentIds?.length ?? 0;
|
|
53
|
+
|
|
54
|
+
// Exact target reached
|
|
55
|
+
if (count >= expected) {
|
|
56
|
+
resolve(performance.now());
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Track changes for stabilization
|
|
61
|
+
if (count !== lastCount) {
|
|
62
|
+
lastCount = count;
|
|
63
|
+
lastChangeTime = performance.now();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Stabilized: count hasn't changed for stableWindow ms and we have > 0 highlights
|
|
67
|
+
if (count > 0 && performance.now() - lastChangeTime > stableWindow) {
|
|
68
|
+
resolve(performance.now());
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (performance.now() > deadline) {
|
|
73
|
+
reject(
|
|
74
|
+
new Error(
|
|
75
|
+
`Timed out: expected ${expected} highlights, found ${count}`,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
requestAnimationFrame(check);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
requestAnimationFrame(check);
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
{ expected: expectedCount, timeout: timeoutMs, stableWindow: 2000 },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Wait for positions ─────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Waits until Positions.cache() has run AFTER highlights were painted.
|
|
95
|
+
* The `afterTimestamp` ensures we don't capture the initial empty cache()
|
|
96
|
+
* that runs before highlights exist.
|
|
97
|
+
*/
|
|
98
|
+
async function waitForPositionsReady(
|
|
99
|
+
page: Page,
|
|
100
|
+
afterTimestamp: number,
|
|
101
|
+
timeoutMs = 60_000,
|
|
102
|
+
): Promise<number> {
|
|
103
|
+
return page.evaluate(
|
|
104
|
+
({ after, timeout }) => {
|
|
105
|
+
return new Promise<number>((resolve, reject) => {
|
|
106
|
+
const deadline = performance.now() + timeout;
|
|
107
|
+
|
|
108
|
+
const check = () => {
|
|
109
|
+
const ts = (window as unknown as Record<string, unknown>)
|
|
110
|
+
.__readitPositionsReady as number | undefined;
|
|
111
|
+
|
|
112
|
+
// Only resolve if positions were computed AFTER highlights painted
|
|
113
|
+
if (ts !== undefined && ts > after) {
|
|
114
|
+
resolve(ts);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (performance.now() > deadline) {
|
|
119
|
+
reject(new Error("Timed out waiting for positions to be ready"));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
requestAnimationFrame(check);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
requestAnimationFrame(check);
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
{ after: afterTimestamp, timeout: timeoutMs },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Collect load metrics ────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Navigates to the URL and collects load + highlight timing metrics.
|
|
137
|
+
*/
|
|
138
|
+
export async function collectLoadMetrics(
|
|
139
|
+
page: Page,
|
|
140
|
+
url: string,
|
|
141
|
+
expectedComments: number,
|
|
142
|
+
): Promise<LoadMetrics> {
|
|
143
|
+
await page.goto(url);
|
|
144
|
+
|
|
145
|
+
const highlightTimestamp = await waitForHighlightCount(
|
|
146
|
+
page,
|
|
147
|
+
expectedComments,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Wait for positions to be computed AFTER highlights
|
|
151
|
+
// (captures the forced reflow cost from getBoundingClientRect)
|
|
152
|
+
let pageReadyTimestamp: number | null;
|
|
153
|
+
try {
|
|
154
|
+
pageReadyTimestamp = await waitForPositionsReady(
|
|
155
|
+
page,
|
|
156
|
+
highlightTimestamp,
|
|
157
|
+
10_000,
|
|
158
|
+
);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (
|
|
161
|
+
!(error instanceof Error) ||
|
|
162
|
+
!error.message.includes("Timed out waiting for positions")
|
|
163
|
+
) {
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
// Positions may not fire in all configurations
|
|
167
|
+
pageReadyTimestamp = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const navMetrics = await page.evaluate(() => {
|
|
171
|
+
const nav = performance.getEntriesByType(
|
|
172
|
+
"navigation",
|
|
173
|
+
)[0] as PerformanceNavigationTiming;
|
|
174
|
+
const paintEntries = performance.getEntriesByType("paint");
|
|
175
|
+
const fcp = paintEntries.find((e) => e.name === "first-contentful-paint");
|
|
176
|
+
// Use CSS Custom Highlight API observability hook
|
|
177
|
+
const highlights = (window as unknown as Record<string, unknown>)
|
|
178
|
+
.__readitHighlights as { commentIds: string[] } | undefined;
|
|
179
|
+
const actualCount = highlights?.commentIds?.length ?? 0;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
fcp: fcp ? fcp.startTime : null,
|
|
183
|
+
domContentLoaded: nav.domContentLoadedEventEnd,
|
|
184
|
+
actualCount,
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
fcp: navMetrics.fcp,
|
|
190
|
+
domContentLoaded: navMetrics.domContentLoaded,
|
|
191
|
+
allHighlightsPainted: highlightTimestamp,
|
|
192
|
+
pageReady: pageReadyTimestamp,
|
|
193
|
+
highlightCount: navMetrics.actualCount,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Collect scroll metrics ──────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Scrolls programmatically from top to bottom, collecting Long Task entries.
|
|
201
|
+
*/
|
|
202
|
+
export async function collectScrollMetrics(
|
|
203
|
+
page: Page,
|
|
204
|
+
stepPx = 600,
|
|
205
|
+
intervalMs = 150,
|
|
206
|
+
): Promise<ScrollMetrics> {
|
|
207
|
+
return page.evaluate(
|
|
208
|
+
({ step, interval }) => {
|
|
209
|
+
return new Promise<{
|
|
210
|
+
totalTimeMs: number;
|
|
211
|
+
longTaskCount: number;
|
|
212
|
+
longTaskDurations: number[];
|
|
213
|
+
p50: number;
|
|
214
|
+
p95: number;
|
|
215
|
+
p99: number;
|
|
216
|
+
}>((resolve) => {
|
|
217
|
+
const longTasks: number[] = [];
|
|
218
|
+
|
|
219
|
+
// Observe Long Tasks during scroll only (no buffered — excludes page load jank)
|
|
220
|
+
const obs = new PerformanceObserver((list) => {
|
|
221
|
+
for (const entry of list.getEntries()) {
|
|
222
|
+
longTasks.push(entry.duration);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
obs.observe({ type: "longtask" });
|
|
226
|
+
|
|
227
|
+
const totalHeight = document.documentElement.scrollHeight;
|
|
228
|
+
const start = performance.now();
|
|
229
|
+
let currentY = 0;
|
|
230
|
+
|
|
231
|
+
const scrollStep = () => {
|
|
232
|
+
currentY += step;
|
|
233
|
+
window.scrollTo(0, currentY);
|
|
234
|
+
|
|
235
|
+
if (currentY < totalHeight) {
|
|
236
|
+
setTimeout(scrollStep, interval);
|
|
237
|
+
} else {
|
|
238
|
+
// Wait a beat for final Long Tasks to fire
|
|
239
|
+
setTimeout(() => {
|
|
240
|
+
obs.disconnect();
|
|
241
|
+
const elapsed = performance.now() - start;
|
|
242
|
+
|
|
243
|
+
const sorted = [...longTasks].sort((a, b) => a - b);
|
|
244
|
+
const pct = (p: number) => {
|
|
245
|
+
if (sorted.length === 0) return 0;
|
|
246
|
+
const idx = Math.ceil((p / 100) * sorted.length) - 1;
|
|
247
|
+
return sorted[Math.max(0, idx)];
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
resolve({
|
|
251
|
+
totalTimeMs: elapsed,
|
|
252
|
+
longTaskCount: sorted.length,
|
|
253
|
+
longTaskDurations: sorted,
|
|
254
|
+
p50: pct(50),
|
|
255
|
+
p95: pct(95),
|
|
256
|
+
p99: pct(99),
|
|
257
|
+
});
|
|
258
|
+
}, 500);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Start scrolling
|
|
263
|
+
window.scrollTo(0, 0);
|
|
264
|
+
setTimeout(scrollStep, interval);
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
{ step: stepPx, interval: intervalMs },
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ─── Measure interaction timing ──────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Sets a performance mark, runs the trigger, waits for condition, returns duration.
|
|
275
|
+
*/
|
|
276
|
+
export async function measureInteraction(
|
|
277
|
+
page: Page,
|
|
278
|
+
name: string,
|
|
279
|
+
trigger: () => Promise<void>,
|
|
280
|
+
waitFor: () => Promise<void>,
|
|
281
|
+
): Promise<number> {
|
|
282
|
+
await page.evaluate((n) => performance.mark(`${n}-start`), name);
|
|
283
|
+
|
|
284
|
+
await trigger();
|
|
285
|
+
await waitFor();
|
|
286
|
+
|
|
287
|
+
return page.evaluate((n) => {
|
|
288
|
+
performance.mark(`${n}-end`);
|
|
289
|
+
const measure = performance.measure(n, `${n}-start`, `${n}-end`);
|
|
290
|
+
return measure.duration;
|
|
291
|
+
}, name);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── Reporting ───────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
export function reportLoadMetrics(
|
|
297
|
+
testInfo: TestInfo,
|
|
298
|
+
label: string,
|
|
299
|
+
metrics: LoadMetrics,
|
|
300
|
+
): void {
|
|
301
|
+
const lines = [
|
|
302
|
+
`--- ${label} ---`,
|
|
303
|
+
` FCP: ${metrics.fcp !== null ? `${Math.round(metrics.fcp)}ms` : "N/A"}`,
|
|
304
|
+
` DOM Content Loaded: ${Math.round(metrics.domContentLoaded)}ms`,
|
|
305
|
+
` All highlights painted: ${Math.round(metrics.allHighlightsPainted)}ms`,
|
|
306
|
+
` Page ready (+ layout): ${metrics.pageReady !== null ? `${Math.round(metrics.pageReady)}ms` : "N/A (positions timed out)"}`,
|
|
307
|
+
` Highlights found: ${metrics.highlightCount}`,
|
|
308
|
+
];
|
|
309
|
+
console.log(lines.join("\n"));
|
|
310
|
+
|
|
311
|
+
testInfo.annotations.push({
|
|
312
|
+
type: "perf-metric",
|
|
313
|
+
description: JSON.stringify({ label, ...metrics }),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function reportScrollMetrics(
|
|
318
|
+
testInfo: TestInfo,
|
|
319
|
+
label: string,
|
|
320
|
+
metrics: ScrollMetrics,
|
|
321
|
+
): void {
|
|
322
|
+
const lines = [
|
|
323
|
+
`--- ${label} ---`,
|
|
324
|
+
` Total scroll time: ${Math.round(metrics.totalTimeMs)}ms`,
|
|
325
|
+
` Long tasks (>50ms): ${metrics.longTaskCount}`,
|
|
326
|
+
` P50 long task: ${Math.round(metrics.p50)}ms`,
|
|
327
|
+
` P95 long task: ${Math.round(metrics.p95)}ms`,
|
|
328
|
+
` P99 long task: ${Math.round(metrics.p99)}ms`,
|
|
329
|
+
];
|
|
330
|
+
console.log(lines.join("\n"));
|
|
331
|
+
|
|
332
|
+
testInfo.annotations.push({
|
|
333
|
+
type: "perf-metric",
|
|
334
|
+
description: JSON.stringify({ label, ...metrics }),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function reportInteraction(
|
|
339
|
+
testInfo: TestInfo,
|
|
340
|
+
label: string,
|
|
341
|
+
durationMs: number,
|
|
342
|
+
): void {
|
|
343
|
+
console.log(`--- ${label} ---`);
|
|
344
|
+
console.log(` Duration: ${Math.round(durationMs)}ms`);
|
|
345
|
+
|
|
346
|
+
testInfo.annotations.push({
|
|
347
|
+
type: "perf-metric",
|
|
348
|
+
description: JSON.stringify({ label, durationMs }),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
interface SpawnPerfCliResult {
|
|
6
|
+
url: string;
|
|
7
|
+
process: ChildProcess;
|
|
8
|
+
cleanup: () => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SpawnPerfCliOptions {
|
|
12
|
+
port?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CLI_PATH = resolve(import.meta.dirname, "../../../dist/cli.js");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Start the readit CLI with one or more fixture files for perf testing.
|
|
19
|
+
* Uses --no-open (no --clean, so pre-seeded comments are loaded).
|
|
20
|
+
*/
|
|
21
|
+
export async function spawnPerfCli(
|
|
22
|
+
fixturePaths: string | string[],
|
|
23
|
+
options: SpawnPerfCliOptions = {},
|
|
24
|
+
): Promise<SpawnPerfCliResult> {
|
|
25
|
+
const { port = 4600 } = options;
|
|
26
|
+
|
|
27
|
+
const paths = Array.isArray(fixturePaths) ? fixturePaths : [fixturePaths];
|
|
28
|
+
const args = [CLI_PATH, ...paths, "--no-open", "--port", String(port)];
|
|
29
|
+
|
|
30
|
+
const cliProcess = spawn("bun", args, {
|
|
31
|
+
cwd: resolve(import.meta.dirname, "../../.."),
|
|
32
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const url = await waitForServerReady(cliProcess);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
url,
|
|
39
|
+
process: cliProcess,
|
|
40
|
+
cleanup: async () => {
|
|
41
|
+
cliProcess.kill("SIGTERM");
|
|
42
|
+
await new Promise<void>((resolve) => {
|
|
43
|
+
cliProcess.once("exit", () => resolve());
|
|
44
|
+
setTimeout(resolve, 2000);
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function waitForServerReady(cliProcess: ChildProcess): Promise<string> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
let output = "";
|
|
53
|
+
|
|
54
|
+
const timeout = setTimeout(() => {
|
|
55
|
+
reject(
|
|
56
|
+
new Error(`Server did not start within timeout. Output: ${output}`),
|
|
57
|
+
);
|
|
58
|
+
}, 30_000);
|
|
59
|
+
|
|
60
|
+
cliProcess.stdout?.on("data", (data: Buffer) => {
|
|
61
|
+
output += data.toString();
|
|
62
|
+
|
|
63
|
+
const urlMatch = output.match(/URL:\s+(http:\/\/[^\s]+)/);
|
|
64
|
+
if (urlMatch) {
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
resolve(urlMatch[1]);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
cliProcess.stderr?.on("data", (data: Buffer) => {
|
|
71
|
+
output += data.toString();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
cliProcess.on("error", (err) => {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
reject(err);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
cliProcess.on("exit", (code) => {
|
|
80
|
+
if (code !== 0 && code !== null) {
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
reject(new Error(`CLI exited with code ${code}: ${output}`));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -57,7 +57,7 @@ test.describe("File-Based Comment Persistence", () => {
|
|
|
57
57
|
await page.goto(url);
|
|
58
58
|
|
|
59
59
|
// Wait for document to load
|
|
60
|
-
const article = page.locator("article");
|
|
60
|
+
const article = page.locator("article#document-content");
|
|
61
61
|
await expect(article).toBeVisible();
|
|
62
62
|
|
|
63
63
|
// Add a comment
|
|
@@ -96,7 +96,7 @@ test.describe("File-Based Comment Persistence", () => {
|
|
|
96
96
|
try {
|
|
97
97
|
await page.goto(url);
|
|
98
98
|
|
|
99
|
-
const article = page.locator("article");
|
|
99
|
+
const article = page.locator("article#document-content");
|
|
100
100
|
await expect(article).toBeVisible();
|
|
101
101
|
|
|
102
102
|
// Add a comment
|
|
@@ -119,8 +119,14 @@ test.describe("File-Based Comment Persistence", () => {
|
|
|
119
119
|
await expect(page.locator("body")).toContainText(commentText);
|
|
120
120
|
|
|
121
121
|
// Verify highlight still exists
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
await page.waitForFunction(
|
|
123
|
+
() => {
|
|
124
|
+
const h = (window as unknown as Record<string, unknown>)
|
|
125
|
+
.__readitHighlights as { commentIds: string[] } | undefined;
|
|
126
|
+
return h && h.commentIds.length > 0;
|
|
127
|
+
},
|
|
128
|
+
{ timeout: 10_000 },
|
|
129
|
+
);
|
|
124
130
|
} finally {
|
|
125
131
|
await cleanup();
|
|
126
132
|
}
|
|
@@ -140,7 +146,7 @@ test.describe("File-Based Comment Persistence", () => {
|
|
|
140
146
|
try {
|
|
141
147
|
await page.goto(url1);
|
|
142
148
|
|
|
143
|
-
const article = page.locator("article");
|
|
149
|
+
const article = page.locator("article#document-content");
|
|
144
150
|
await expect(article).toBeVisible();
|
|
145
151
|
|
|
146
152
|
await selectTextInArticle(page, "testing text selection");
|
|
@@ -164,15 +170,21 @@ test.describe("File-Based Comment Persistence", () => {
|
|
|
164
170
|
try {
|
|
165
171
|
await page.goto(url2);
|
|
166
172
|
|
|
167
|
-
const article = page.locator("article");
|
|
173
|
+
const article = page.locator("article#document-content");
|
|
168
174
|
await expect(article).toBeVisible();
|
|
169
175
|
|
|
170
176
|
// Comment should still exist from previous session
|
|
171
177
|
await expect(page.locator("body")).toContainText(commentText);
|
|
172
178
|
|
|
173
179
|
// Highlight should still exist
|
|
174
|
-
|
|
175
|
-
|
|
180
|
+
await page.waitForFunction(
|
|
181
|
+
() => {
|
|
182
|
+
const h = (window as unknown as Record<string, unknown>)
|
|
183
|
+
.__readitHighlights as { commentIds: string[] } | undefined;
|
|
184
|
+
return h && h.commentIds.length > 0;
|
|
185
|
+
},
|
|
186
|
+
{ timeout: 10_000 },
|
|
187
|
+
);
|
|
176
188
|
} finally {
|
|
177
189
|
await cleanup2();
|
|
178
190
|
}
|
|
@@ -214,15 +226,19 @@ Pre-existing comment to be cleared.
|
|
|
214
226
|
try {
|
|
215
227
|
await page.goto(url);
|
|
216
228
|
|
|
217
|
-
const article = page.locator("article");
|
|
229
|
+
const article = page.locator("article#document-content");
|
|
218
230
|
await expect(article).toBeVisible();
|
|
219
231
|
|
|
220
232
|
// Wait for clean operation to complete
|
|
221
233
|
await page.waitForTimeout(500);
|
|
222
234
|
|
|
223
|
-
// Verify no
|
|
224
|
-
const
|
|
225
|
-
|
|
235
|
+
// Verify no highlights via observability hook
|
|
236
|
+
const highlightCount = await page.evaluate(() => {
|
|
237
|
+
const h = (window as unknown as Record<string, unknown>)
|
|
238
|
+
.__readitHighlights as { commentIds: string[] } | undefined;
|
|
239
|
+
return h?.commentIds?.length ?? 0;
|
|
240
|
+
});
|
|
241
|
+
expect(highlightCount).toBe(0);
|
|
226
242
|
} finally {
|
|
227
243
|
await cleanup();
|
|
228
244
|
}
|
|
@@ -237,7 +253,7 @@ Pre-existing comment to be cleared.
|
|
|
237
253
|
try {
|
|
238
254
|
await page.goto(url);
|
|
239
255
|
|
|
240
|
-
const article = page.locator("article");
|
|
256
|
+
const article = page.locator("article#document-content");
|
|
241
257
|
await expect(article).toBeVisible();
|
|
242
258
|
|
|
243
259
|
// Add initial comment
|
|
@@ -256,26 +272,25 @@ Pre-existing comment to be cleared.
|
|
|
256
272
|
let fileContent = readFileSync(commentPath, "utf-8");
|
|
257
273
|
expect(fileContent).toContain(initialComment);
|
|
258
274
|
|
|
259
|
-
// Find the margin note
|
|
260
|
-
const marginNote = page
|
|
261
|
-
.locator(".group")
|
|
262
|
-
.filter({ hasText: textToSelect })
|
|
263
|
-
.first();
|
|
264
|
-
await marginNote.hover();
|
|
275
|
+
// Find the margin note by data-comment-id (first one)
|
|
276
|
+
const marginNote = page.locator("[data-comment-id]").first();
|
|
265
277
|
|
|
266
|
-
//
|
|
267
|
-
const editButton = marginNote.
|
|
268
|
-
await editButton.click();
|
|
278
|
+
// Force-click edit button (may be hidden behind opacity-0 group-hover)
|
|
279
|
+
const editButton = marginNote.getByText("Edit").first();
|
|
280
|
+
await editButton.click({ force: true });
|
|
269
281
|
|
|
270
|
-
// Wait for edit mode
|
|
271
|
-
const textarea =
|
|
282
|
+
// Wait for edit mode — find textarea globally since hasText filter breaks in edit mode
|
|
283
|
+
const textarea = page.locator("[data-comment-id] textarea");
|
|
272
284
|
await expect(textarea).toBeVisible();
|
|
273
285
|
|
|
274
286
|
// Clear and type new text
|
|
275
287
|
await textarea.fill("Updated comment text");
|
|
276
288
|
|
|
277
289
|
// Save edit
|
|
278
|
-
const saveButton =
|
|
290
|
+
const saveButton = page
|
|
291
|
+
.locator("[data-comment-id]")
|
|
292
|
+
.getByText("Save")
|
|
293
|
+
.first();
|
|
279
294
|
await saveButton.click();
|
|
280
295
|
|
|
281
296
|
// Wait for file to be updated
|
|
@@ -298,7 +313,7 @@ Pre-existing comment to be cleared.
|
|
|
298
313
|
try {
|
|
299
314
|
await page.goto(url);
|
|
300
315
|
|
|
301
|
-
const article = page.locator("article");
|
|
316
|
+
const article = page.locator("article#document-content");
|
|
302
317
|
await expect(article).toBeVisible();
|
|
303
318
|
|
|
304
319
|
// Add a comment
|
package/e2e/utils/selection.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Page } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wait for the Svelte app to be fully mounted and interactive.
|
|
5
|
+
* The app sets data-readit-ready="true" on <html> after onMount.
|
|
6
|
+
*/
|
|
7
|
+
export async function waitForAppReady(page: Page): Promise<void> {
|
|
8
|
+
await page.waitForFunction(
|
|
9
|
+
() => document.documentElement.dataset.readitReady === "true",
|
|
10
|
+
{ timeout: 10_000 },
|
|
11
|
+
);
|
|
12
|
+
}
|
|
2
13
|
|
|
3
14
|
/**
|
|
4
15
|
* Select text within an article element (for markdown documents)
|
|
@@ -8,9 +19,12 @@ export async function selectTextInArticle(
|
|
|
8
19
|
page: Page,
|
|
9
20
|
textToSelect: string,
|
|
10
21
|
): Promise<void> {
|
|
22
|
+
// Ensure the Svelte app is fully mounted before dispatching events
|
|
23
|
+
await waitForAppReady(page);
|
|
24
|
+
|
|
11
25
|
// Find text and calculate offsets, then dispatch custom event
|
|
12
26
|
await page.evaluate((text) => {
|
|
13
|
-
const article = document.querySelector("article");
|
|
27
|
+
const article = document.querySelector("article#document-content");
|
|
14
28
|
if (!article) throw new Error("Article element not found");
|
|
15
29
|
|
|
16
30
|
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
|
|
@@ -39,78 +53,10 @@ export async function selectTextInArticle(
|
|
|
39
53
|
throw new Error(`Text "${text}" not found in article`);
|
|
40
54
|
}, textToSelect);
|
|
41
55
|
|
|
42
|
-
// Wait for
|
|
56
|
+
// Wait for Svelte to process the selection
|
|
43
57
|
await page.waitForTimeout(100);
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
/**
|
|
47
|
-
* Select text within an iframe (for HTML documents)
|
|
48
|
-
* Uses custom event to trigger selection handler (same as markdown)
|
|
49
|
-
*/
|
|
50
|
-
export async function selectTextInIframe(
|
|
51
|
-
page: Page,
|
|
52
|
-
_iframe: FrameLocator,
|
|
53
|
-
textToSelect: string,
|
|
54
|
-
): Promise<void> {
|
|
55
|
-
// Get the actual frame object for evaluation
|
|
56
|
-
const frame = page.frame({ url: /^about:srcdoc/ }) || page.frames()[1];
|
|
57
|
-
if (!frame) throw new Error("Could not find iframe frame");
|
|
58
|
-
|
|
59
|
-
// Calculate offsets in iframe, then dispatch custom event to parent
|
|
60
|
-
const offsets = await frame.evaluate((text) => {
|
|
61
|
-
const body = document.body;
|
|
62
|
-
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
|
|
63
|
-
let currentOffset = 0;
|
|
64
|
-
|
|
65
|
-
while (walker.nextNode()) {
|
|
66
|
-
const textNode = walker.currentNode as Text;
|
|
67
|
-
const content = textNode.textContent || "";
|
|
68
|
-
const index = content.indexOf(text);
|
|
69
|
-
|
|
70
|
-
if (index !== -1) {
|
|
71
|
-
const startOffset = currentOffset + index;
|
|
72
|
-
const endOffset = startOffset + text.length;
|
|
73
|
-
return { text, startOffset, endOffset };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
currentOffset += content.length;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
throw new Error(`Text "${text}" not found in iframe`);
|
|
80
|
-
}, textToSelect);
|
|
81
|
-
|
|
82
|
-
// Dispatch custom event to parent window (IframeContainer listens for this)
|
|
83
|
-
await page.evaluate((detail) => {
|
|
84
|
-
const event = new CustomEvent("test:select-text", { detail });
|
|
85
|
-
window.dispatchEvent(event);
|
|
86
|
-
}, offsets);
|
|
87
|
-
|
|
88
|
-
// Wait for React to process state update
|
|
89
|
-
await page.waitForTimeout(200);
|
|
90
|
-
|
|
91
|
-
// Manually send applyHighlights to iframe (workaround for isReadyRef timing)
|
|
92
|
-
// This ensures the iframe gets the highlight even if React's useEffect hasn't fired
|
|
93
|
-
await page.evaluate(
|
|
94
|
-
(selection) => {
|
|
95
|
-
const iframe = document.querySelector("iframe");
|
|
96
|
-
if (iframe?.contentWindow) {
|
|
97
|
-
iframe.contentWindow.postMessage(
|
|
98
|
-
{
|
|
99
|
-
type: "applyHighlights",
|
|
100
|
-
comments: [],
|
|
101
|
-
pendingSelection: selection,
|
|
102
|
-
},
|
|
103
|
-
"*",
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
{ startOffset: offsets.startOffset, endOffset: offsets.endOffset },
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
// Wait for iframe to apply highlights
|
|
111
|
-
await page.waitForTimeout(200);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
60
|
/**
|
|
115
61
|
* Add a comment to the current selection
|
|
116
62
|
* Assumes CommentInputArea is visible
|
|
@@ -119,8 +65,6 @@ export async function addComment(
|
|
|
119
65
|
page: Page,
|
|
120
66
|
commentText: string,
|
|
121
67
|
): Promise<void> {
|
|
122
|
-
// Wait for the comment input textarea to appear
|
|
123
|
-
// Give more time for iframe postMessage round-trip
|
|
124
68
|
const textarea = page.locator('textarea[placeholder="Add your comment..."]');
|
|
125
69
|
await textarea.waitFor({ state: "visible", timeout: 10000 });
|
|
126
70
|
|