@lunardinos/browser 0.2.4 → 0.2.6
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/dist/recorder-core.js +330 -397
- package/dist/src/recorder-core.d.ts.map +1 -1
- package/dist/src/recorder-core.js +74 -58
- package/dist/src/recorder.browser.test.d.ts +2 -0
- package/dist/src/recorder.browser.test.d.ts.map +1 -0
- package/dist/src/recorder.browser.test.js +239 -0
- package/dist/src/session-lifecycle.d.ts +29 -1
- package/dist/src/session-lifecycle.d.ts.map +1 -1
- package/dist/src/session-lifecycle.js +34 -3
- package/dist/src/session-lifecycle.test.js +30 -2
- package/dist/src/testing/measure-bytes.d.ts +2 -0
- package/dist/src/testing/measure-bytes.d.ts.map +1 -0
- package/dist/src/testing/measure-bytes.js +171 -0
- package/dist/src/testing/recorder-bundle.d.ts +6 -0
- package/dist/src/testing/recorder-bundle.d.ts.map +1 -0
- package/dist/src/testing/recorder-bundle.js +19 -0
- package/package.json +3 -2
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measure what the recorder uploads per minute of an active and an idle user.
|
|
3
|
+
*
|
|
4
|
+
* bun packages/browser/src/testing/measure-bytes.ts [--minutes 5] [--idle 10] [--seed 1]
|
|
5
|
+
*
|
|
6
|
+
* Runs the production recorder (`createLunarDinosRecorder`) in real Chromium on
|
|
7
|
+
* a fake clock, against an app-like page: a shift list with cards and avatars,
|
|
8
|
+
* a detail view and a form, switched by SPA navigations the way the framework
|
|
9
|
+
* SDKs report them. The user is active the whole time — mouse, scroll, clicks,
|
|
10
|
+
* typing, a navigation every 20 s. Then the user leaves the tab open and idle
|
|
11
|
+
* for `--idle` minutes while the page keeps re-rendering its clock and badges,
|
|
12
|
+
* as real apps poll and animate. Every upload is intercepted and counted as it
|
|
13
|
+
* goes over the wire (gzip where the recorder compresses).
|
|
14
|
+
*
|
|
15
|
+
* The page is synthetic, so the absolute number is a baseline, not a forecast:
|
|
16
|
+
* compare runs of this script before and after a recorder change.
|
|
17
|
+
*/
|
|
18
|
+
import { chromium } from "playwright";
|
|
19
|
+
import { buildRecorderBundle } from "./recorder-bundle";
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const opt = (n, d) => {
|
|
22
|
+
const i = args.indexOf(`--${n}`);
|
|
23
|
+
return i === -1 ? d : args[i + 1];
|
|
24
|
+
};
|
|
25
|
+
const minutes = Number(opt("minutes", "5"));
|
|
26
|
+
const idleMinutes = Number(opt("idle", "10"));
|
|
27
|
+
const seed = Number(opt("seed", "1"));
|
|
28
|
+
const CSS = `
|
|
29
|
+
body { font-family: system-ui, sans-serif; margin: 0; color: #222; background: #fafafa; }
|
|
30
|
+
header { display: flex; gap: 16px; padding: 12px 24px; background: #1e1e2e; color: #fff; }
|
|
31
|
+
header a { color: inherit; text-decoration: none; }
|
|
32
|
+
main { padding: 24px; max-width: 960px; margin: 0 auto; }
|
|
33
|
+
.card { display: flex; gap: 12px; align-items: center; padding: 12px; margin: 8px 0; background: #fff; border: 1px solid #ddd; border-radius: 8px; }
|
|
34
|
+
.card img { width: 32px; height: 32px; border-radius: 50%; }
|
|
35
|
+
.card .title { font-weight: 600; }
|
|
36
|
+
.card .meta { color: #666; font-size: 13px; }
|
|
37
|
+
.badge { padding: 2px 8px; border-radius: 999px; background: #e0f2e9; color: #14532d; font-size: 12px; }
|
|
38
|
+
form label { display: block; margin: 12px 0 4px; }
|
|
39
|
+
form input, form textarea, form select { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 6px; }
|
|
40
|
+
button { padding: 8px 16px; border-radius: 6px; border: 0; background: #4f46e5; color: #fff; }
|
|
41
|
+
`;
|
|
42
|
+
// An avatar of the kind an app serves: same-origin, so the recorder inlines it.
|
|
43
|
+
const AVATAR = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><rect width="256" height="256" fill="#89b4fa"/><circle cx="128" cy="100" r="48" fill="#fff"/><rect x="48" y="170" width="160" height="80" rx="40" fill="#fff"/></svg>`;
|
|
44
|
+
const HTML = `<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><title>Schichten</title><style>${CSS}</style></head>
|
|
45
|
+
<body><header><a href="#" data-route="/shifts">Schichten</a><a href="#" data-route="/profile">Profil</a><span id="clock"></span></header>
|
|
46
|
+
<main id="app"></main>
|
|
47
|
+
<script src="/recorder.js"></script>
|
|
48
|
+
<script>{
|
|
49
|
+
let s = ${seed};
|
|
50
|
+
const rnd = () => ((s = (s * 1103515245 + 12345) % 2147483648) / 2147483648);
|
|
51
|
+
const app = document.getElementById('app');
|
|
52
|
+
const views = {
|
|
53
|
+
'/shifts': () => {
|
|
54
|
+
let h = '<h1>Offene Schichten</h1><input id="search" placeholder="Suchen">';
|
|
55
|
+
for (let i = 0; i < 40; i++) h += '<div class="card" data-id="' + i + '"><img src="/avatar.svg?u=' + (i % 8) + '"><div><div class="title">Frühdienst Station ' + (i % 7 + 1) + '</div><div class="meta">Klinikum Nord · 06:00–14:00 · ' + (20 + i) + ' €/h</div></div><span class="badge">offen</span></div>';
|
|
56
|
+
return h;
|
|
57
|
+
},
|
|
58
|
+
'/shifts/[id]': (id) => '<h1>Schicht ' + id + '</h1><div class="card"><img src="/avatar.svg?u=1"><div><div class="title">Frühdienst</div><div class="meta">Klinikum Nord</div></div></div><p>' + 'Beschreibung der Schicht. '.repeat(30) + '</p><button id="apply">Bewerben</button>',
|
|
59
|
+
'/profile': () => '<h1>Profil</h1><form><label>Name</label><input id="name"><label>Qualifikation</label><select id="qual"><option>Pflegefachkraft</option><option>Pflegehelfer</option></select><label>Notizen</label><textarea id="notes"></textarea><button type="button">Speichern</button></form>',
|
|
60
|
+
};
|
|
61
|
+
const recorder = LunarDinos.createLunarDinosRecorder({ apiKey: 'measure', endpoint: 'https://ingest.test' });
|
|
62
|
+
function render(pattern, params) {
|
|
63
|
+
app.innerHTML = views[pattern](params && params.id);
|
|
64
|
+
}
|
|
65
|
+
function meta(pattern, params) {
|
|
66
|
+
const href = pattern === '/shifts/[id]' ? '/shifts/' + params.id : pattern;
|
|
67
|
+
return { href: location.origin + href, title: document.title, routePattern: pattern, params };
|
|
68
|
+
}
|
|
69
|
+
window.__navigate = (pattern, params = null) => {
|
|
70
|
+
render(pattern, params);
|
|
71
|
+
recorder.emitPageEvent(meta(pattern, params));
|
|
72
|
+
};
|
|
73
|
+
render('/shifts');
|
|
74
|
+
recorder.start({ pageMetadata: meta('/shifts', null) });
|
|
75
|
+
setInterval(() => {
|
|
76
|
+
document.getElementById('clock').textContent = new Date().toLocaleTimeString();
|
|
77
|
+
const card = app.querySelector('.card .badge');
|
|
78
|
+
if (card && rnd() < 0.3) card.textContent = rnd() < 0.5 ? 'offen' : 'vergeben';
|
|
79
|
+
}, 1000);
|
|
80
|
+
window.__flush = () => recorder.flush();
|
|
81
|
+
}</script></body></html>`;
|
|
82
|
+
const recorderJs = await buildRecorderBundle();
|
|
83
|
+
const newTally = () => ({ wire: 0, json: 0, requests: 0, events: {} });
|
|
84
|
+
const active = newTally();
|
|
85
|
+
const idle = newTally();
|
|
86
|
+
let tally = active;
|
|
87
|
+
const browser = await chromium.launch();
|
|
88
|
+
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
|
89
|
+
await page.route("https://app.test/**", (route) => {
|
|
90
|
+
const u = new URL(route.request().url());
|
|
91
|
+
if (u.pathname === "/")
|
|
92
|
+
return route.fulfill({ contentType: "text/html", body: HTML });
|
|
93
|
+
if (u.pathname === "/recorder.js")
|
|
94
|
+
return route.fulfill({ contentType: "application/javascript", body: recorderJs });
|
|
95
|
+
if (u.pathname === "/avatar.svg")
|
|
96
|
+
return route.fulfill({ contentType: "image/svg+xml", body: AVATAR });
|
|
97
|
+
return route.fulfill({ status: 404, body: "" });
|
|
98
|
+
});
|
|
99
|
+
await page.route("https://ingest.test/**", async (route) => {
|
|
100
|
+
const req = route.request();
|
|
101
|
+
if (req.method() !== "POST")
|
|
102
|
+
return route.fulfill({ status: 204, body: "" });
|
|
103
|
+
const body = req.postDataBuffer() ?? Buffer.alloc(0);
|
|
104
|
+
tally.wire += body.length;
|
|
105
|
+
tally.requests++;
|
|
106
|
+
const gzipped = req.headers()["content-encoding"] === "gzip";
|
|
107
|
+
const json = gzipped ? Bun.gunzipSync(new Uint8Array(body)) : body;
|
|
108
|
+
tally.json += json.length;
|
|
109
|
+
const { events } = JSON.parse(new TextDecoder().decode(json));
|
|
110
|
+
for (const e of events) {
|
|
111
|
+
const k = e.type === 3 ? `3.${e.data?.source}` : e.type === 5 ? `5.${e.data?.tag}` : String(e.type);
|
|
112
|
+
tally.events[k] = (tally.events[k] ?? 0) + 1;
|
|
113
|
+
}
|
|
114
|
+
return route.fulfill({ status: 200, body: "{}" });
|
|
115
|
+
});
|
|
116
|
+
await page.clock.install({ time: new Date("2026-01-01T10:00:00Z") });
|
|
117
|
+
page.on("pageerror", (e) => console.error("pageerror", e.message));
|
|
118
|
+
await page.goto("https://app.test/");
|
|
119
|
+
/** Let the recorder's async gzip and fetch settle on the real clock. */
|
|
120
|
+
const settle = () => new Promise((r) => setTimeout(r, 30));
|
|
121
|
+
const routes = [
|
|
122
|
+
["/shifts", null],
|
|
123
|
+
["/shifts/[id]", { id: "7" }],
|
|
124
|
+
["/profile", null],
|
|
125
|
+
["/shifts/[id]", { id: "12" }],
|
|
126
|
+
];
|
|
127
|
+
let navs = 0;
|
|
128
|
+
for (let sec = 0; sec < minutes * 60; sec++) {
|
|
129
|
+
await page.mouse.move(200 + ((sec * 37) % 800), 150 + ((sec * 53) % 500), { steps: 4 });
|
|
130
|
+
if (sec % 3 === 0)
|
|
131
|
+
await page.mouse.wheel(0, sec % 6 === 0 ? 300 : -200);
|
|
132
|
+
if (sec % 20 === 19) {
|
|
133
|
+
const [pattern, params] = routes[++navs % routes.length];
|
|
134
|
+
await page.evaluate(([p, q]) => window.__navigate(p, q), [pattern, params]);
|
|
135
|
+
}
|
|
136
|
+
if (sec % 7 === 0) {
|
|
137
|
+
const input = page.locator("#search, #name").first();
|
|
138
|
+
if (await input.count())
|
|
139
|
+
await input.fill(`eingabe ${sec}`);
|
|
140
|
+
}
|
|
141
|
+
if (sec % 9 === 0)
|
|
142
|
+
await page.mouse.click(300, 300);
|
|
143
|
+
await page.clock.runFor(1000);
|
|
144
|
+
await settle();
|
|
145
|
+
}
|
|
146
|
+
/** Upload what's buffered and let it land, so it counts toward the phase that made it. */
|
|
147
|
+
async function drain() {
|
|
148
|
+
await page.evaluate(() => window.__flush());
|
|
149
|
+
await page.clock.runFor(100);
|
|
150
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
151
|
+
}
|
|
152
|
+
await drain();
|
|
153
|
+
tally = idle;
|
|
154
|
+
for (let sec = 0; sec < idleMinutes * 60; sec += 10) {
|
|
155
|
+
await page.clock.runFor(10_000);
|
|
156
|
+
await settle();
|
|
157
|
+
}
|
|
158
|
+
await drain();
|
|
159
|
+
await browser.close();
|
|
160
|
+
function report(label, t, mins) {
|
|
161
|
+
if (mins === 0)
|
|
162
|
+
return;
|
|
163
|
+
const perMin = (n) => `${(n / mins / 1024).toFixed(1)} KB`;
|
|
164
|
+
console.log(`${mins} ${label} minutes, ${t.requests} uploads`);
|
|
165
|
+
console.log(` wire: ${perMin(t.wire)} per ${label} minute (${(t.wire / 1024).toFixed(0)} KB total)`);
|
|
166
|
+
console.log(` json: ${perMin(t.json)} per ${label} minute (${(t.json / 1024).toFixed(0)} KB total)`);
|
|
167
|
+
console.log(" events:", t.events);
|
|
168
|
+
}
|
|
169
|
+
console.log(`${navs} navigations`);
|
|
170
|
+
report("active", active, minutes);
|
|
171
|
+
report("idle", idle, idleMinutes);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder-bundle.d.ts","sourceRoot":"","sources":["../../../src/testing/recorder-bundle.ts"],"names":[],"mappings":"AAIA;;;GAGG;AACH,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC,CAa3D"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { mkdtempSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* The production recorder built for the browser, exposing
|
|
6
|
+
* `LunarDinos.createLunarDinosRecorder` on the page.
|
|
7
|
+
*/
|
|
8
|
+
export async function buildRecorderBundle() {
|
|
9
|
+
const outdir = mkdtempSync(join(tmpdir(), "recorder-bundle-"));
|
|
10
|
+
const entry = join(outdir, "entry.ts");
|
|
11
|
+
await Bun.write(entry, `import { createLunarDinosRecorder } from "${import.meta.dir}/../recorder-core";\n(globalThis as any).LunarDinos = { createLunarDinosRecorder };\n`);
|
|
12
|
+
// Via the CLI: Bun.build inside `bun test` can't resolve workspace packages.
|
|
13
|
+
const build = Bun.spawnSync(["bun", "build", entry, "--target=browser", `--outdir=${outdir}`], {
|
|
14
|
+
cwd: import.meta.dir,
|
|
15
|
+
});
|
|
16
|
+
if (build.exitCode !== 0)
|
|
17
|
+
throw new Error(`bundle failed: ${build.stderr}`);
|
|
18
|
+
return Bun.file(join(outdir, "entry.js")).text();
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunardinos/browser",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@lunardinos/rrweb-record": "0.1.0"
|
|
22
|
+
"@lunardinos/rrweb-record": "0.1.0",
|
|
23
|
+
"playwright": "^1.50.0"
|
|
23
24
|
}
|
|
24
25
|
}
|