birdclaw 0.5.0 → 0.5.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/CHANGELOG.md +20 -0
- package/README.md +5 -0
- package/bin/birdclaw.mjs +50 -11
- package/package.json +7 -6
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +399 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +29 -0
- package/src/cli.ts +46 -1
- package/src/components/AppNav.tsx +3 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +24 -9
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +105 -0
- package/src/components/ThemeSlider.tsx +10 -59
- package/src/components/TimelineCard.tsx +157 -61
- package/src/components/TimelineRouteFrame.tsx +156 -0
- package/src/components/TweetMediaGrid.tsx +120 -23
- package/src/components/TweetRichText.tsx +13 -2
- package/src/components/useTimelineRouteData.ts +137 -0
- package/src/lib/api-client.ts +229 -0
- package/src/lib/archive-finder.ts +24 -20
- package/src/lib/archive-import.ts +18 -3
- package/src/lib/conversation-surface.ts +174 -0
- package/src/lib/db.ts +2 -0
- package/src/lib/queries.ts +93 -28
- package/src/lib/ui.ts +11 -3
- package/src/lib/web-sync.ts +443 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/api/sync.tsx +59 -0
- package/src/routes/dms.tsx +100 -27
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.5.1 - 2026-05-15
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Harden the published CLI wrapper and release checks so the packaged `birdclaw` binary avoids `tsx` CLI IPC startup and stays covered by lint, format, and smoke tests.
|
|
10
|
+
- Forward shutdown signals through the published CLI and bundled web server, and include referenced script helpers in npm packages.
|
|
11
|
+
- Keep the selected DM conversation visible while its thread refreshes so the reply composer no longer flashes away mid-action.
|
|
12
|
+
- Send the selected web account through manual sync controls so multi-account timelines sync the intended profile.
|
|
13
|
+
- Run web sync requests as background jobs with status polling so the UI no longer holds one blocking sync request open.
|
|
14
|
+
- Add typed web API fetch handling and explicit DMs loading/error/empty states so failed local reads surface cleanly.
|
|
15
|
+
- Add explicit web app sync controls for home timeline, mentions, likes, bookmarks, and DMs so fresh live data can be pulled without leaving the UI.
|
|
16
|
+
- Refine the web app sidebar tagline and theme selector so the brand chrome reads more clearly in compact layouts.
|
|
17
|
+
- Add shared web feed loading/error/empty states with timeline-shaped skeleton rows and move conversation expansion into a cached single-thread surface with hover prefetch.
|
|
18
|
+
- Use the Birdclaw crab-bird mark in the web app chrome, loading states, and empty states; soften dark-mode contrast and replace text-only reply warnings with conversation/replied indicators.
|
|
19
|
+
- Allow the local web app to respond when Tailscale Serve forwards requests through the `clawmac.sheep-coho.ts.net` hostname.
|
|
20
|
+
- Speed up the default home timeline load on large local databases and keep malformed archived media URL entities from crashing the web timeline.
|
|
21
|
+
- Preserve tweet media aspect ratios, open timeline media in an inline viewer, and suppress duplicate media URL cards.
|
|
22
|
+
|
|
3
23
|
## 0.5.0 - 2026-05-15
|
|
4
24
|
|
|
5
25
|
### Added
|
package/README.md
CHANGED
|
@@ -256,6 +256,10 @@ Start the app:
|
|
|
256
256
|
birdclaw serve
|
|
257
257
|
```
|
|
258
258
|
|
|
259
|
+
Use the Sync button in Home, Mentions, Likes, Bookmarks, or DMs to run the matching live sync from the web UI and then reload the local view. These controls are explicit because live reads can be slow, auth-dependent, or rate-limited.
|
|
260
|
+
|
|
261
|
+
When running behind a trusted reverse proxy such as Tailscale Serve, add any extra proxy hostnames to `BIRDCLAW_ALLOWED_HOSTS`. The clawmac Tailscale hostname is allowed by default.
|
|
262
|
+
|
|
259
263
|
First moderation pass:
|
|
260
264
|
|
|
261
265
|
```bash
|
|
@@ -359,6 +363,7 @@ Notes:
|
|
|
359
363
|
- `bird` mode uses your local `bird` CLI and caches its mentions output into birdclaw's canonical store
|
|
360
364
|
- filters still work in `xurl` mode; filtered payloads are rebuilt from the local canonical store after sync
|
|
361
365
|
- `sync authored`, `sync mentions`, `sync mention-threads`, `sync likes`, `sync bookmarks`, and `sync timeline` store live results in the canonical local store; per-account authored/home/mention/like/bookmark membership is kept as edges so shared tweets do not clobber account ownership
|
|
366
|
+
- the web UI has explicit Sync buttons for home timeline, mentions, likes, bookmarks, and DMs; they call the same sync paths and then reload the local DB-backed view
|
|
362
367
|
|
|
363
368
|
### Research bookmarks and threads
|
|
364
369
|
|
package/bin/birdclaw.mjs
CHANGED
|
@@ -1,30 +1,69 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
|
|
7
7
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
8
|
const require = createRequire(import.meta.url);
|
|
9
|
-
const
|
|
9
|
+
const tsxLoader = pathToFileURL(require.resolve("tsx")).href;
|
|
10
10
|
const birdclawCli = join(packageRoot, "src", "cli.ts");
|
|
11
11
|
|
|
12
|
-
const
|
|
12
|
+
const child = spawn(
|
|
13
13
|
process.execPath,
|
|
14
|
-
[
|
|
14
|
+
["--import", tsxLoader, birdclawCli, ...process.argv.slice(2)],
|
|
15
15
|
{
|
|
16
16
|
stdio: "inherit",
|
|
17
17
|
env: process.env,
|
|
18
|
+
detached: process.platform !== "win32",
|
|
18
19
|
},
|
|
19
20
|
);
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
|
|
23
|
+
|
|
24
|
+
function removeSignalHandlers() {
|
|
25
|
+
for (const signal of forwardedSignals) {
|
|
26
|
+
process.removeListener(signal, forwardSignal);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function forwardSignal(signal) {
|
|
31
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
32
|
+
signalChild(signal);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function signalChild(signal) {
|
|
37
|
+
if (child.pid === undefined) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const targetPid = process.platform === "win32" ? child.pid : -child.pid;
|
|
41
|
+
try {
|
|
42
|
+
process.kill(targetPid, signal);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error?.code !== "ESRCH") {
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
24
48
|
}
|
|
25
49
|
|
|
26
|
-
|
|
27
|
-
process.
|
|
50
|
+
for (const signal of forwardedSignals) {
|
|
51
|
+
process.on(signal, forwardSignal);
|
|
28
52
|
}
|
|
29
53
|
|
|
30
|
-
|
|
54
|
+
child.on("error", (error) => {
|
|
55
|
+
removeSignalHandlers();
|
|
56
|
+
console.error(error.message);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
child.on("exit", (code, signal) => {
|
|
61
|
+
removeSignalHandlers();
|
|
62
|
+
|
|
63
|
+
if (signal) {
|
|
64
|
+
process.kill(process.pid, signal);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.exit(code ?? 0);
|
|
69
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "birdclaw",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Local Twitter memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
|
|
5
5
|
"homepage": "https://github.com/steipete/birdclaw#readme",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bin/",
|
|
16
|
+
"scripts/",
|
|
16
17
|
"src/",
|
|
17
18
|
"!src/**/*.test.ts",
|
|
18
19
|
"!src/**/*.test.tsx",
|
|
@@ -38,13 +39,13 @@
|
|
|
38
39
|
"dev": "vite dev --port 3000",
|
|
39
40
|
"build": "vite build",
|
|
40
41
|
"preview": "vite preview",
|
|
41
|
-
"test": "
|
|
42
|
-
"coverage": "
|
|
42
|
+
"test": "node ./scripts/run-vitest.mjs run",
|
|
43
|
+
"coverage": "node ./scripts/run-vitest.mjs run --coverage",
|
|
43
44
|
"e2e": "playwright test",
|
|
44
45
|
"typecheck": "tsgo --noEmit",
|
|
45
|
-
"format": "oxfmt --write src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
|
|
46
|
-
"format:check": "oxfmt --check src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
|
|
47
|
-
"lint": "oxlint --import-plugin --node-plugin --vitest-plugin --deny-warnings -A require-mock-type-parameters -A no-control-regex src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
|
|
46
|
+
"format": "oxfmt --write bin src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
|
|
47
|
+
"format:check": "oxfmt --check bin src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
|
|
48
|
+
"lint": "oxlint --import-plugin --node-plugin --vitest-plugin --deny-warnings -A require-mock-type-parameters -A no-control-regex bin src scripts playwright vite.config.ts vitest.config.ts playwright.config.ts",
|
|
48
49
|
"check": "pnpm run format:check && pnpm run lint",
|
|
49
50
|
"perf:browser": "node ./scripts/browser-perf.mjs",
|
|
50
51
|
"cli": "tsx src/cli.ts",
|
|
Binary file
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { chromium } from "@playwright/test";
|
|
6
|
+
import { withSanitizedNodeOptions } from "./sanitize-node-options.mjs";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_BASE_URL = "http://localhost:3000";
|
|
9
|
+
const DEFAULT_ITERATIONS = 5;
|
|
10
|
+
const READY_TIMEOUT_MS = 20_000;
|
|
11
|
+
|
|
12
|
+
const SCENARIOS = {
|
|
13
|
+
home: {
|
|
14
|
+
path: "/",
|
|
15
|
+
ready: async (page) => waitForAny(page, ['[data-perf="timeline-card"]']),
|
|
16
|
+
},
|
|
17
|
+
mentions: {
|
|
18
|
+
path: "/mentions",
|
|
19
|
+
ready: async (page) => waitForAny(page, ['[data-perf="timeline-card"]']),
|
|
20
|
+
},
|
|
21
|
+
"mentions-search": {
|
|
22
|
+
path: "/mentions",
|
|
23
|
+
ready: async (page) => {
|
|
24
|
+
await page.getByPlaceholder("Search mentions").fill("peekaboo");
|
|
25
|
+
await waitForAny(page, ['[data-perf="timeline-card"]']);
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
links: {
|
|
29
|
+
path: "/links",
|
|
30
|
+
ready: async (page) =>
|
|
31
|
+
waitForAny(page, [
|
|
32
|
+
'[data-perf="link-insight-row"]',
|
|
33
|
+
"text=No links in this window.",
|
|
34
|
+
]),
|
|
35
|
+
},
|
|
36
|
+
"links-toggle": {
|
|
37
|
+
path: "/links",
|
|
38
|
+
ready: async (page) =>
|
|
39
|
+
waitForAny(page, [
|
|
40
|
+
'[data-perf="link-insight-row"]',
|
|
41
|
+
"text=No links in this window.",
|
|
42
|
+
]),
|
|
43
|
+
action: async (page) => {
|
|
44
|
+
await page.getByRole("button", { name: "videos" }).click();
|
|
45
|
+
await waitForAny(page, [
|
|
46
|
+
'[data-perf="link-insight-row"]',
|
|
47
|
+
"text=No links in this window.",
|
|
48
|
+
]);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function parseArgs(argv) {
|
|
54
|
+
const options = {
|
|
55
|
+
baseUrl: process.env.BIRDCLAW_PERF_URL || DEFAULT_BASE_URL,
|
|
56
|
+
iterations: DEFAULT_ITERATIONS,
|
|
57
|
+
scenarios: Object.keys(SCENARIOS),
|
|
58
|
+
json: false,
|
|
59
|
+
budgets: {},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
for (const arg of argv) {
|
|
63
|
+
if (arg === "--json") {
|
|
64
|
+
options.json = true;
|
|
65
|
+
} else if (arg.startsWith("--url=")) {
|
|
66
|
+
options.baseUrl = arg.slice("--url=".length).replace(/\/$/, "");
|
|
67
|
+
} else if (arg.startsWith("--iterations=")) {
|
|
68
|
+
options.iterations = Math.max(
|
|
69
|
+
1,
|
|
70
|
+
Number.parseInt(arg.split("=")[1] ?? "", 10),
|
|
71
|
+
);
|
|
72
|
+
} else if (arg.startsWith("--scenario=")) {
|
|
73
|
+
options.scenarios = arg
|
|
74
|
+
.slice("--scenario=".length)
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((value) => value.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
} else if (arg.startsWith("--budget-ready-ms=")) {
|
|
79
|
+
options.budgets.readyMs = Number(arg.split("=")[1]);
|
|
80
|
+
} else if (arg.startsWith("--budget-action-ms=")) {
|
|
81
|
+
options.budgets.actionMs = Number(arg.split("=")[1]);
|
|
82
|
+
} else if (arg.startsWith("--budget-api-p95-ms=")) {
|
|
83
|
+
options.budgets.apiP95Ms = Number(arg.split("=")[1]);
|
|
84
|
+
} else if (arg.startsWith("--budget-preview-calls=")) {
|
|
85
|
+
options.budgets.previewCalls = Number(arg.split("=")[1]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const scenario of options.scenarios) {
|
|
90
|
+
if (!SCENARIOS[scenario]) {
|
|
91
|
+
throw new Error(`Unknown scenario: ${scenario}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return options;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function isReachable(baseUrl) {
|
|
99
|
+
try {
|
|
100
|
+
const response = await fetch(baseUrl, {
|
|
101
|
+
signal: AbortSignal.timeout(1000),
|
|
102
|
+
});
|
|
103
|
+
return response.ok || response.status < 500;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function startServerIfNeeded(baseUrl) {
|
|
110
|
+
if (await isReachable(baseUrl)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const url = new URL(baseUrl);
|
|
115
|
+
const port = url.port || "3000";
|
|
116
|
+
const viteBin = path.join(
|
|
117
|
+
process.cwd(),
|
|
118
|
+
"node_modules",
|
|
119
|
+
"vite",
|
|
120
|
+
"bin",
|
|
121
|
+
"vite.js",
|
|
122
|
+
);
|
|
123
|
+
const child = spawn(
|
|
124
|
+
process.execPath,
|
|
125
|
+
[viteBin, "dev", "--port", port, "--host", "127.0.0.1"],
|
|
126
|
+
{
|
|
127
|
+
cwd: process.cwd(),
|
|
128
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
129
|
+
env: {
|
|
130
|
+
...withSanitizedNodeOptions(process.env),
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const deadline = Date.now() + 30_000;
|
|
136
|
+
while (Date.now() < deadline) {
|
|
137
|
+
if (await isReachable(baseUrl)) {
|
|
138
|
+
return child;
|
|
139
|
+
}
|
|
140
|
+
await sleep(250);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
child.kill("SIGTERM");
|
|
144
|
+
throw new Error(`Timed out waiting for ${baseUrl}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function waitForAny(page, selectors) {
|
|
148
|
+
await page.waitForFunction(
|
|
149
|
+
(values) =>
|
|
150
|
+
values.some((selector) => {
|
|
151
|
+
if (selector.startsWith("text=")) {
|
|
152
|
+
return document.body.innerText.includes(selector.slice(5));
|
|
153
|
+
}
|
|
154
|
+
return Boolean(document.querySelector(selector));
|
|
155
|
+
}),
|
|
156
|
+
selectors,
|
|
157
|
+
{ timeout: READY_TIMEOUT_MS },
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function percentile(values, point) {
|
|
162
|
+
if (values.length === 0) return 0;
|
|
163
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
164
|
+
const index = Math.min(
|
|
165
|
+
sorted.length - 1,
|
|
166
|
+
Math.ceil(sorted.length * point) - 1,
|
|
167
|
+
);
|
|
168
|
+
return sorted[index] ?? 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function median(values) {
|
|
172
|
+
return percentile(values, 0.5);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function round(value) {
|
|
176
|
+
return Math.round(value);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function apiBucket(rawUrl) {
|
|
180
|
+
const url = new URL(rawUrl);
|
|
181
|
+
if (url.pathname === "/api/link-insights") {
|
|
182
|
+
return `${url.pathname}:${url.searchParams.get("kind") ?? "unknown"}`;
|
|
183
|
+
}
|
|
184
|
+
return url.pathname;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function summarizeRuns(runs) {
|
|
188
|
+
const ready = runs.map((run) => run.readyMs);
|
|
189
|
+
const action = runs
|
|
190
|
+
.map((run) => run.actionMs)
|
|
191
|
+
.filter((value) => typeof value === "number");
|
|
192
|
+
const apiDurations = runs.flatMap((run) => run.apiDurationsMs);
|
|
193
|
+
return {
|
|
194
|
+
readyMedianMs: round(median(ready)),
|
|
195
|
+
readyP95Ms: round(percentile(ready, 0.95)),
|
|
196
|
+
actionMedianMs: action.length > 0 ? round(median(action)) : null,
|
|
197
|
+
actionP95Ms: action.length > 0 ? round(percentile(action, 0.95)) : null,
|
|
198
|
+
apiP95Ms: round(percentile(apiDurations, 0.95)),
|
|
199
|
+
apiCallsMedian: round(median(runs.map((run) => run.apiCalls))),
|
|
200
|
+
previewCallsMedian: round(median(runs.map((run) => run.previewCalls))),
|
|
201
|
+
rowsMedian: round(median(runs.map((run) => run.rows))),
|
|
202
|
+
previewsMedian: round(median(runs.map((run) => run.previews))),
|
|
203
|
+
endpoints: summarizeEndpoints(runs),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function summarizeEndpoints(runs) {
|
|
208
|
+
const totals = new Map();
|
|
209
|
+
for (const run of runs) {
|
|
210
|
+
for (const [endpoint, count] of Object.entries(run.apiEndpoints)) {
|
|
211
|
+
totals.set(endpoint, (totals.get(endpoint) ?? 0) + count);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return [...totals.entries()]
|
|
215
|
+
.map(([endpoint, count]) => ({
|
|
216
|
+
endpoint,
|
|
217
|
+
medianCalls: Math.round(count / runs.length),
|
|
218
|
+
}))
|
|
219
|
+
.sort((left, right) => right.medianCalls - left.medianCalls)
|
|
220
|
+
.slice(0, 8);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function runScenario(browser, baseUrl, name) {
|
|
224
|
+
const scenario = SCENARIOS[name];
|
|
225
|
+
const page = await browser.newPage({
|
|
226
|
+
viewport: { width: 1474, height: 910 },
|
|
227
|
+
});
|
|
228
|
+
const requestStart = new Map();
|
|
229
|
+
const apiDurationsMs = [];
|
|
230
|
+
let apiCalls = 0;
|
|
231
|
+
let previewCalls = 0;
|
|
232
|
+
const apiEndpoints = {};
|
|
233
|
+
|
|
234
|
+
page.on("request", (request) => {
|
|
235
|
+
if (request.url().includes("/api/")) {
|
|
236
|
+
requestStart.set(request, performance.now());
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
page.on("response", (response) => {
|
|
240
|
+
const request = response.request();
|
|
241
|
+
const startedAt = requestStart.get(request);
|
|
242
|
+
if (startedAt === undefined) return;
|
|
243
|
+
requestStart.delete(request);
|
|
244
|
+
apiCalls += 1;
|
|
245
|
+
const endpoint = apiBucket(response.url());
|
|
246
|
+
apiEndpoints[endpoint] = (apiEndpoints[endpoint] ?? 0) + 1;
|
|
247
|
+
apiDurationsMs.push(performance.now() - startedAt);
|
|
248
|
+
if (response.url().includes("/api/link-preview")) {
|
|
249
|
+
previewCalls += 1;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const startedAt = performance.now();
|
|
254
|
+
await page.goto(`${baseUrl}${scenario.path}`, {
|
|
255
|
+
waitUntil: "domcontentloaded",
|
|
256
|
+
});
|
|
257
|
+
await scenario.ready(page);
|
|
258
|
+
const readyMs = performance.now() - startedAt;
|
|
259
|
+
|
|
260
|
+
let actionMs = null;
|
|
261
|
+
if (scenario.action) {
|
|
262
|
+
const actionStartedAt = performance.now();
|
|
263
|
+
await scenario.action(page);
|
|
264
|
+
actionMs = performance.now() - actionStartedAt;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await page.waitForTimeout(250);
|
|
268
|
+
const counts = await page.evaluate(() => ({
|
|
269
|
+
rows: document.querySelectorAll(
|
|
270
|
+
'[data-perf="timeline-card"], [data-perf="link-insight-row"]',
|
|
271
|
+
).length,
|
|
272
|
+
previews: document.querySelectorAll('[data-perf="link-preview-card"]')
|
|
273
|
+
.length,
|
|
274
|
+
}));
|
|
275
|
+
await page.close();
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
readyMs,
|
|
279
|
+
actionMs,
|
|
280
|
+
apiCalls,
|
|
281
|
+
previewCalls,
|
|
282
|
+
apiEndpoints,
|
|
283
|
+
apiDurationsMs,
|
|
284
|
+
rows: counts.rows,
|
|
285
|
+
previews: counts.previews,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function budgetFailures(name, summary, budgets) {
|
|
290
|
+
const failures = [];
|
|
291
|
+
if (
|
|
292
|
+
Number.isFinite(budgets.readyMs) &&
|
|
293
|
+
summary.readyP95Ms > budgets.readyMs
|
|
294
|
+
) {
|
|
295
|
+
failures.push(
|
|
296
|
+
`${name} ready p95 ${summary.readyP95Ms}ms > ${budgets.readyMs}ms`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (
|
|
300
|
+
Number.isFinite(budgets.actionMs) &&
|
|
301
|
+
summary.actionP95Ms !== null &&
|
|
302
|
+
summary.actionP95Ms > budgets.actionMs
|
|
303
|
+
) {
|
|
304
|
+
failures.push(
|
|
305
|
+
`${name} action p95 ${summary.actionP95Ms}ms > ${budgets.actionMs}ms`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (
|
|
309
|
+
Number.isFinite(budgets.apiP95Ms) &&
|
|
310
|
+
summary.apiP95Ms > budgets.apiP95Ms
|
|
311
|
+
) {
|
|
312
|
+
failures.push(
|
|
313
|
+
`${name} api p95 ${summary.apiP95Ms}ms > ${budgets.apiP95Ms}ms`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
if (
|
|
317
|
+
Number.isFinite(budgets.previewCalls) &&
|
|
318
|
+
summary.previewCallsMedian > budgets.previewCalls
|
|
319
|
+
) {
|
|
320
|
+
failures.push(
|
|
321
|
+
`${name} preview calls median ${summary.previewCallsMedian} > ${budgets.previewCalls}`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
return failures;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function printHuman(results, failures) {
|
|
328
|
+
console.log("browser perf");
|
|
329
|
+
for (const result of results) {
|
|
330
|
+
const summary = result.summary;
|
|
331
|
+
console.log("");
|
|
332
|
+
console.log(`scenario: ${result.name}`);
|
|
333
|
+
console.log(`ready median: ${summary.readyMedianMs}ms`);
|
|
334
|
+
console.log(`ready p95: ${summary.readyP95Ms}ms`);
|
|
335
|
+
if (summary.actionMedianMs !== null) {
|
|
336
|
+
console.log(`action median: ${summary.actionMedianMs}ms`);
|
|
337
|
+
console.log(`action p95: ${summary.actionP95Ms}ms`);
|
|
338
|
+
}
|
|
339
|
+
console.log(`api p95: ${summary.apiP95Ms}ms`);
|
|
340
|
+
console.log(`api calls median: ${summary.apiCallsMedian}`);
|
|
341
|
+
console.log(`preview calls median: ${summary.previewCallsMedian}`);
|
|
342
|
+
console.log(`rows median: ${summary.rowsMedian}`);
|
|
343
|
+
console.log(`preview cards median: ${summary.previewsMedian}`);
|
|
344
|
+
if (summary.endpoints.length > 0) {
|
|
345
|
+
console.log("top endpoints:");
|
|
346
|
+
for (const endpoint of summary.endpoints) {
|
|
347
|
+
console.log(`- ${endpoint.endpoint}: ${endpoint.medianCalls}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (failures.length > 0) {
|
|
352
|
+
console.log("");
|
|
353
|
+
console.log("budget failures:");
|
|
354
|
+
for (const failure of failures) {
|
|
355
|
+
console.log(`- ${failure}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function main() {
|
|
361
|
+
const options = parseArgs(process.argv.slice(2));
|
|
362
|
+
const server = await startServerIfNeeded(options.baseUrl);
|
|
363
|
+
const browser = await chromium.launch({ headless: true });
|
|
364
|
+
const results = [];
|
|
365
|
+
const failures = [];
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
for (const name of options.scenarios) {
|
|
369
|
+
await runScenario(browser, options.baseUrl, name);
|
|
370
|
+
const runs = [];
|
|
371
|
+
for (let index = 0; index < options.iterations; index += 1) {
|
|
372
|
+
runs.push(await runScenario(browser, options.baseUrl, name));
|
|
373
|
+
}
|
|
374
|
+
const summary = summarizeRuns(runs);
|
|
375
|
+
results.push({ name, summary, runs });
|
|
376
|
+
failures.push(...budgetFailures(name, summary, options.budgets));
|
|
377
|
+
}
|
|
378
|
+
} finally {
|
|
379
|
+
await browser.close();
|
|
380
|
+
if (server) {
|
|
381
|
+
server.kill("SIGTERM");
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (options.json) {
|
|
386
|
+
console.log(JSON.stringify({ results, failures }, null, 2));
|
|
387
|
+
} else {
|
|
388
|
+
printHuman(results, failures);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (failures.length > 0) {
|
|
392
|
+
process.exitCode = 1;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
main().catch((error) => {
|
|
397
|
+
console.error(error instanceof Error ? error.stack || error.message : error);
|
|
398
|
+
process.exit(1);
|
|
399
|
+
});
|