create-prisma-php-app 5.0.2 → 5.0.3
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/AGENTS.md +16 -0
- package/dist/index.js +1 -1
- package/dist/settings/browser-log.ts +518 -0
- package/dist/settings/bs-config.ts +43 -0
- package/dist/settings/dev-log-bridge.ts +596 -0
- package/package.json +1 -1
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the dev-session browser log and report per-route front-end health.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists
|
|
5
|
+
* ---------------
|
|
6
|
+
* `settings/dev-log-bridge.ts` forwards browser-side PulsePoint errors into the
|
|
7
|
+
* `npm run dev` terminal. That only helps whoever owns that terminal: an AI
|
|
8
|
+
* agent working in a different session cannot see stdout, and spawning a second
|
|
9
|
+
* `npm run dev` to get its own copy would bind different ports and orphan the
|
|
10
|
+
* browser tab the developer is actually looking at.
|
|
11
|
+
*
|
|
12
|
+
* So the bridge also appends every event to `.pp/browser-log.jsonl`, and this
|
|
13
|
+
* script renders it. `npm run logs`.
|
|
14
|
+
*
|
|
15
|
+
* The hard part is not reading errors, it is not lying about their absence.
|
|
16
|
+
* Four ways a naive error log misleads a reader, and how the format answers
|
|
17
|
+
* each:
|
|
18
|
+
*
|
|
19
|
+
* - **Empty is ambiguous.** No errors could mean the route is fine, or that
|
|
20
|
+
* nobody ever opened it, or that the dev server is not running. The log
|
|
21
|
+
* records `load` events and a `session` header, so those three are
|
|
22
|
+
* distinguishable and this script names them separately.
|
|
23
|
+
* - **Fixed errors look current.** A clean reload writes nothing, so an error
|
|
24
|
+
* from before the fix would sit in the file forever. Because every page load
|
|
25
|
+
* is recorded, a route's state is whatever happened during its *most recent*
|
|
26
|
+
* load.
|
|
27
|
+
* - **A reload does not re-test everything.** It re-runs mount, so it genuinely
|
|
28
|
+
* clears a mount-phase error -- but it never clicks a button. An error from a
|
|
29
|
+
* click handler survives the reload as NEEDS RECHECK instead of being
|
|
30
|
+
* reported CLEAN, which is how a live bug would otherwise get signed off.
|
|
31
|
+
* - **Reports race.** Two POSTs can arrive out of order, so an error is tied to
|
|
32
|
+
* its load by the client-generated `page` id, never by arrival time.
|
|
33
|
+
*
|
|
34
|
+
* Anyone reading the raw JSONL would see a fixed error as current, so the
|
|
35
|
+
* `session` line carries a `readme` explaining the supersession rule and a
|
|
36
|
+
* clean reload appends an explicit `resolved` event. An error whose `page`
|
|
37
|
+
* never produced a `load` in this log -- a tab left open across a dev restart
|
|
38
|
+
* -- is reported as UNCONFIRMED rather than as a fresh failure.
|
|
39
|
+
*
|
|
40
|
+
* Every source change compacts the file down to the session header, a `restart`
|
|
41
|
+
* marker, and the errors still open, so a dev session that runs for hours
|
|
42
|
+
* without a restart cannot grow an unbounded log. Errors that survive a
|
|
43
|
+
* compaction are marked `carried` and dropped at the next one, so a stale
|
|
44
|
+
* interaction error cannot haunt the log forever.
|
|
45
|
+
*
|
|
46
|
+
* Usage:
|
|
47
|
+
*
|
|
48
|
+
* npm run logs # human-readable digest
|
|
49
|
+
* npm run logs -- --json # machine-readable status
|
|
50
|
+
* npm run logs -- --fail-on-error # exit 1 if any route is dirty
|
|
51
|
+
*
|
|
52
|
+
* Exit code is 0 by default even when routes are failing: whether a route has
|
|
53
|
+
* been exercised depends on someone clicking around in a browser, so this must
|
|
54
|
+
* never become a flaky pass/fail gate.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import { existsSync, readFileSync } from "fs";
|
|
58
|
+
import { dirname, join, relative } from "path";
|
|
59
|
+
import { fileURLToPath } from "url";
|
|
60
|
+
import { Socket } from "net";
|
|
61
|
+
import chalk from "chalk";
|
|
62
|
+
|
|
63
|
+
const SETTINGS_DIR = dirname(fileURLToPath(import.meta.url));
|
|
64
|
+
const PROJECT_ROOT = join(SETTINGS_DIR, "..");
|
|
65
|
+
const LOG_FILE = join(PROJECT_ROOT, ".pp", "browser-log.jsonl");
|
|
66
|
+
|
|
67
|
+
type LogEvent = Record<string, unknown>;
|
|
68
|
+
|
|
69
|
+
/** One browser page load and everything the runtime reported during it. */
|
|
70
|
+
type PageLoad = {
|
|
71
|
+
page: string;
|
|
72
|
+
route: string;
|
|
73
|
+
at: string;
|
|
74
|
+
errors: LogEvent[];
|
|
75
|
+
warnings: LogEvent[];
|
|
76
|
+
/** True when the error arrived but the matching `load` event never did. */
|
|
77
|
+
orphan: boolean;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type RouteStatus = {
|
|
81
|
+
route: string;
|
|
82
|
+
lastLoad: string;
|
|
83
|
+
errors: LogEvent[];
|
|
84
|
+
warnings: LogEvent[];
|
|
85
|
+
/** Mount errors from *earlier* loads, retested and cleared by a later load. */
|
|
86
|
+
healed: number;
|
|
87
|
+
/**
|
|
88
|
+
* The newest errors came from a page with no `load` in this log -- typically
|
|
89
|
+
* a tab opened before the last dev restart. Real, but possibly already fixed.
|
|
90
|
+
*/
|
|
91
|
+
unconfirmed: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Interaction errors a reload could not retest, plus errors carried across a
|
|
94
|
+
* source change. Not proof of a live bug, and not proof of a fix either.
|
|
95
|
+
*/
|
|
96
|
+
recheck: LogEvent[];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function isClean(status: RouteStatus): boolean {
|
|
100
|
+
return status.errors.length === 0 && status.recheck.length === 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Everything a caller needs to describe front-end health without guessing. */
|
|
104
|
+
type LogReport = {
|
|
105
|
+
/** "missing" (no dev session ever wrote), "live", "ended", "stale". */
|
|
106
|
+
session: "missing" | "live" | "ended" | "stale";
|
|
107
|
+
started: string;
|
|
108
|
+
pid: number;
|
|
109
|
+
port: number;
|
|
110
|
+
routes: RouteStatus[];
|
|
111
|
+
/** When the log was last compacted because source files changed. */
|
|
112
|
+
lastRestart: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function failing(report: LogReport): RouteStatus[] {
|
|
116
|
+
return report.routes.filter((r) => !isClean(r));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readEvents(path: string): LogEvent[] {
|
|
120
|
+
if (!existsSync(path)) return [];
|
|
121
|
+
const events: LogEvent[] = [];
|
|
122
|
+
for (const rawLine of readFileSync(path, "utf-8").split("\n")) {
|
|
123
|
+
const line = rawLine.trim();
|
|
124
|
+
if (!line) continue;
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(line);
|
|
127
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
128
|
+
events.push(parsed as LogEvent);
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// A torn final line (server killed mid-write) must not hide the rest.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return events;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function portIsListening(port: number): Promise<boolean> {
|
|
138
|
+
if (!port) return Promise.resolve(false);
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
const socket = new Socket();
|
|
141
|
+
socket.setTimeout(250);
|
|
142
|
+
socket.on("connect", () => {
|
|
143
|
+
socket.destroy();
|
|
144
|
+
resolve(true);
|
|
145
|
+
});
|
|
146
|
+
socket.on("timeout", () => {
|
|
147
|
+
socket.destroy();
|
|
148
|
+
resolve(false);
|
|
149
|
+
});
|
|
150
|
+
socket.on("error", () => {
|
|
151
|
+
socket.destroy();
|
|
152
|
+
resolve(false);
|
|
153
|
+
});
|
|
154
|
+
socket.connect(port, "127.0.0.1");
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const str = (value: unknown): string => (value == null ? "" : String(value));
|
|
159
|
+
|
|
160
|
+
/** Collapse the raw event stream into current per-route status. */
|
|
161
|
+
export async function buildReport(path: string = LOG_FILE): Promise<LogReport> {
|
|
162
|
+
const events = readEvents(path);
|
|
163
|
+
if (events.length === 0) {
|
|
164
|
+
return {
|
|
165
|
+
session: "missing",
|
|
166
|
+
started: "",
|
|
167
|
+
pid: 0,
|
|
168
|
+
port: 0,
|
|
169
|
+
routes: [],
|
|
170
|
+
lastRestart: "",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let started = "";
|
|
175
|
+
let pid = 0;
|
|
176
|
+
let port = 0;
|
|
177
|
+
let ended = false;
|
|
178
|
+
let lastRestart = "";
|
|
179
|
+
for (const event of events) {
|
|
180
|
+
if (event.type === "session") {
|
|
181
|
+
started = str(event.t);
|
|
182
|
+
pid = Number(event.pid) || 0;
|
|
183
|
+
port = Number(event.port) || 0;
|
|
184
|
+
ended = false;
|
|
185
|
+
} else if (event.type === "session-end") {
|
|
186
|
+
ended = true;
|
|
187
|
+
} else if (event.type === "restart") {
|
|
188
|
+
lastRestart = str(event.t);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Group by the client's page id so an error is attributed to the load that
|
|
193
|
+
// produced it regardless of the order the two POSTs landed in.
|
|
194
|
+
const pages = new Map<string, PageLoad>();
|
|
195
|
+
const order: string[] = [];
|
|
196
|
+
// Errors that survived a compaction. Their `load` event was dropped with the
|
|
197
|
+
// rest of the history, so they are tracked by route instead of by page.
|
|
198
|
+
const carried = new Map<string, LogEvent[]>();
|
|
199
|
+
|
|
200
|
+
const pageFor = (event: LogEvent): PageLoad => {
|
|
201
|
+
const key = str(event.page) || `anon-${order.length}`;
|
|
202
|
+
let page = pages.get(key);
|
|
203
|
+
if (!page) {
|
|
204
|
+
page = {
|
|
205
|
+
page: key,
|
|
206
|
+
route: str(event.route) || "?",
|
|
207
|
+
at: str(event.t),
|
|
208
|
+
errors: [],
|
|
209
|
+
warnings: [],
|
|
210
|
+
orphan: true,
|
|
211
|
+
};
|
|
212
|
+
pages.set(key, page);
|
|
213
|
+
order.push(key);
|
|
214
|
+
}
|
|
215
|
+
return page;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
for (const event of events) {
|
|
219
|
+
const kind = event.type;
|
|
220
|
+
if (event.carried) {
|
|
221
|
+
const route = str(event.route) || "?";
|
|
222
|
+
carried.set(route, [...(carried.get(route) ?? []), event]);
|
|
223
|
+
} else if (kind === "load") {
|
|
224
|
+
const page = pageFor(event);
|
|
225
|
+
page.orphan = false;
|
|
226
|
+
page.at = str(event.t) || page.at;
|
|
227
|
+
page.route = str(event.route) || page.route;
|
|
228
|
+
} else if (kind === "error") {
|
|
229
|
+
pageFor(event).errors.push(event);
|
|
230
|
+
} else if (kind === "warn") {
|
|
231
|
+
pageFor(event).warnings.push(event);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Latest load wins: an error is only current if it happened on the most
|
|
236
|
+
// recent load of that route.
|
|
237
|
+
const byRoute = new Map<string, PageLoad[]>();
|
|
238
|
+
for (const key of order) {
|
|
239
|
+
const page = pages.get(key)!;
|
|
240
|
+
byRoute.set(page.route, [...(byRoute.get(page.route) ?? []), page]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const routes: RouteStatus[] = [];
|
|
244
|
+
for (const route of [...new Set([...byRoute.keys(), ...carried.keys()])].sort()) {
|
|
245
|
+
const loads = byRoute.get(route) ?? [];
|
|
246
|
+
// Carried errors always need rechecking: the code changed under them.
|
|
247
|
+
const recheck = [...(carried.get(route) ?? [])];
|
|
248
|
+
|
|
249
|
+
if (loads.length === 0) {
|
|
250
|
+
routes.push({
|
|
251
|
+
route,
|
|
252
|
+
lastLoad: "",
|
|
253
|
+
errors: [],
|
|
254
|
+
warnings: [],
|
|
255
|
+
healed: 0,
|
|
256
|
+
unconfirmed: false,
|
|
257
|
+
recheck,
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const latest = loads[loads.length - 1];
|
|
263
|
+
let healed = 0;
|
|
264
|
+
for (const page of loads.slice(0, -1)) {
|
|
265
|
+
for (const err of page.errors) {
|
|
266
|
+
// A later load re-ran mount, so a mount error is genuinely retested.
|
|
267
|
+
// An interaction error is not: nothing reloaded here clicked anything,
|
|
268
|
+
// so it stays open rather than reading CLEAN.
|
|
269
|
+
if (err.phase === "interaction") {
|
|
270
|
+
recheck.push(err);
|
|
271
|
+
} else {
|
|
272
|
+
healed += 1;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
routes.push({
|
|
278
|
+
route,
|
|
279
|
+
lastLoad: latest.at,
|
|
280
|
+
errors: latest.errors,
|
|
281
|
+
warnings: latest.warnings,
|
|
282
|
+
healed,
|
|
283
|
+
unconfirmed: latest.orphan,
|
|
284
|
+
recheck,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let session: LogReport["session"];
|
|
289
|
+
if (ended) {
|
|
290
|
+
session = "ended";
|
|
291
|
+
} else if (await portIsListening(port)) {
|
|
292
|
+
session = "live";
|
|
293
|
+
} else {
|
|
294
|
+
session = "stale";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { session, started, pid, port, routes, lastRestart };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function age(iso: string): string {
|
|
301
|
+
if (!iso) return "";
|
|
302
|
+
const moment = Date.parse(iso);
|
|
303
|
+
if (Number.isNaN(moment)) return "";
|
|
304
|
+
const seconds = Math.floor((Date.now() - moment) / 1000);
|
|
305
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
306
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
307
|
+
return `${Math.floor(seconds / 3600)}h ago`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function clock(iso: string): string {
|
|
311
|
+
if (!iso) return "?";
|
|
312
|
+
const moment = new Date(iso);
|
|
313
|
+
if (Number.isNaN(moment.getTime())) return iso;
|
|
314
|
+
return moment.toLocaleTimeString("en-GB", { hour12: false });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const SESSION_LINES: Record<string, [string, string]> = {
|
|
318
|
+
missing: [
|
|
319
|
+
"no browser log for this dev session",
|
|
320
|
+
"Nothing has been observed. Start `npm run dev` and open the route " +
|
|
321
|
+
"in a browser before drawing any conclusion about the front end.",
|
|
322
|
+
],
|
|
323
|
+
stale: [
|
|
324
|
+
"dev server is NOT running",
|
|
325
|
+
"This log is left over from an exited dev session. Treat every line " +
|
|
326
|
+
"below as history, not as the current state of the app.",
|
|
327
|
+
],
|
|
328
|
+
ended: [
|
|
329
|
+
"dev session ended cleanly",
|
|
330
|
+
"The dev server has shut down. The results below are from that finished session.",
|
|
331
|
+
],
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** Print each distinct message once, with the frames that locate it. */
|
|
335
|
+
function printErrors(errors: LogEvent[]): void {
|
|
336
|
+
const seen = new Set<string>();
|
|
337
|
+
for (const err of errors) {
|
|
338
|
+
const message = str(err.message).trim();
|
|
339
|
+
if (!message || seen.has(message)) continue;
|
|
340
|
+
seen.add(message);
|
|
341
|
+
const after = err.afterMs;
|
|
342
|
+
let timing = "";
|
|
343
|
+
if (err.phase === "interaction" && typeof after === "number") {
|
|
344
|
+
timing = chalk.gray(
|
|
345
|
+
` (fired ${Math.floor(after / 1000)}s after load -- interaction, not mount)`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
for (const line of message.split("\n")) {
|
|
349
|
+
console.log(` ${chalk.red(line)}${timing}`);
|
|
350
|
+
timing = "";
|
|
351
|
+
}
|
|
352
|
+
const stack = Array.isArray(err.stack) ? err.stack : [];
|
|
353
|
+
for (const frame of stack.slice(0, 2)) {
|
|
354
|
+
console.log(chalk.gray(` ${frame}`));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function printReport(report: LogReport): void {
|
|
360
|
+
const logRel = relative(PROJECT_ROOT, LOG_FILE).replace(/\\/g, "/");
|
|
361
|
+
console.log("");
|
|
362
|
+
console.log(chalk.bold("Browser log") + chalk.gray(` (${logRel})`));
|
|
363
|
+
console.log("=".repeat(60));
|
|
364
|
+
|
|
365
|
+
if (report.session === "live") {
|
|
366
|
+
const startedAge = age(report.started);
|
|
367
|
+
const detail =
|
|
368
|
+
`pid ${report.pid}, port ${report.port}, started ${clock(report.started)}` +
|
|
369
|
+
(startedAge ? `, ${startedAge}` : "");
|
|
370
|
+
console.log(` ${chalk.green("LIVE")} dev session active ${chalk.gray(`(${detail})`)}`);
|
|
371
|
+
} else {
|
|
372
|
+
const [headline, advice] = SESSION_LINES[report.session];
|
|
373
|
+
const mark = report.session !== "missing" ? chalk.yellow("WARN") : chalk.gray("NONE");
|
|
374
|
+
console.log(` ${mark} ${headline}`);
|
|
375
|
+
console.log(chalk.gray(` ${advice}`));
|
|
376
|
+
if (report.session === "missing") {
|
|
377
|
+
console.log("");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
console.log("");
|
|
383
|
+
|
|
384
|
+
if (report.routes.length === 0) {
|
|
385
|
+
console.log(chalk.yellow(" No page loads recorded yet."));
|
|
386
|
+
console.log(chalk.gray(" The log only knows about routes someone actually opened."));
|
|
387
|
+
console.log(chalk.gray(" An empty log is not evidence that the front end is healthy."));
|
|
388
|
+
console.log("");
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const sorted = [...report.routes].sort((a, b) => {
|
|
393
|
+
const cleanDiff = Number(isClean(a)) - Number(isClean(b));
|
|
394
|
+
return cleanDiff !== 0 ? cleanDiff : a.route.localeCompare(b.route);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
for (const status of sorted) {
|
|
398
|
+
// Compaction drops load events, so a carried-only route has no load time.
|
|
399
|
+
const when = status.lastLoad
|
|
400
|
+
? chalk.gray(`last load ${clock(status.lastLoad)} ${age(status.lastLoad)}`)
|
|
401
|
+
: chalk.gray("no load since the last source change");
|
|
402
|
+
|
|
403
|
+
if (isClean(status)) {
|
|
404
|
+
const healed = status.healed
|
|
405
|
+
? chalk.gray(` (${status.healed} earlier error(s) resolved)`)
|
|
406
|
+
: "";
|
|
407
|
+
const warn = status.warnings.length
|
|
408
|
+
? chalk.yellow(` ${status.warnings.length} warning(s)`)
|
|
409
|
+
: "";
|
|
410
|
+
console.log(
|
|
411
|
+
` ${chalk.green("CLEAN")} ${chalk.cyan(status.route)} ${when}${warn}${healed}`,
|
|
412
|
+
);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const label = status.errors.length
|
|
417
|
+
? status.unconfirmed
|
|
418
|
+
? "UNCONFIRMED"
|
|
419
|
+
: `${status.errors.length} ERROR(S)`
|
|
420
|
+
: "NEEDS RECHECK";
|
|
421
|
+
const header = ` ${chalk.red(chalk.bold(label))} ${chalk.cyan(status.route)}`;
|
|
422
|
+
console.log(status.unconfirmed ? header : `${header} ${when}`);
|
|
423
|
+
|
|
424
|
+
if (status.unconfirmed) {
|
|
425
|
+
// No matching load: the reporting tab was opened before this log existed,
|
|
426
|
+
// so the error may already be fixed. Say that plainly rather than sending
|
|
427
|
+
// someone hunting a bug that no longer exists.
|
|
428
|
+
console.log(
|
|
429
|
+
chalk.gray(
|
|
430
|
+
" Reported by a page loaded before this log started " +
|
|
431
|
+
"(e.g. before the last dev restart).",
|
|
432
|
+
),
|
|
433
|
+
);
|
|
434
|
+
console.log(
|
|
435
|
+
chalk.gray(" Reload the route and re-run to confirm whether it is still live."),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
printErrors(status.errors);
|
|
440
|
+
|
|
441
|
+
if (status.recheck.length) {
|
|
442
|
+
// The critical honesty case: a reload re-runs mount, so it clears a mount
|
|
443
|
+
// error -- but it never clicks a button. Reporting these as CLEAN is how
|
|
444
|
+
// a live bug gets signed off.
|
|
445
|
+
console.log(chalk.gray(" Not re-tested by the reloads since:"));
|
|
446
|
+
printErrors(status.recheck);
|
|
447
|
+
if (status.recheck.some((e) => e.carried)) {
|
|
448
|
+
console.log(
|
|
449
|
+
chalk.gray(" Source changed since these fired; they may already be fixed."),
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
console.log(
|
|
453
|
+
chalk.gray(" Repeat the interaction (click/submit) and re-run to confirm."),
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
console.log("");
|
|
459
|
+
const failingRoutes = failing(report);
|
|
460
|
+
if (failingRoutes.length) {
|
|
461
|
+
console.log(chalk.red(chalk.bold(`${failingRoutes.length} route(s) failing in the browser.`)));
|
|
462
|
+
} else {
|
|
463
|
+
console.log(chalk.green(chalk.bold("Every route loaded this session is clean.")));
|
|
464
|
+
}
|
|
465
|
+
console.log(chalk.gray("Routes not listed were never opened -- that is no signal, not a pass."));
|
|
466
|
+
console.log("");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function toJson(report: LogReport): string {
|
|
470
|
+
return JSON.stringify(
|
|
471
|
+
{
|
|
472
|
+
session: report.session,
|
|
473
|
+
started: report.started,
|
|
474
|
+
pid: report.pid,
|
|
475
|
+
port: report.port,
|
|
476
|
+
lastRestart: report.lastRestart,
|
|
477
|
+
routes: report.routes.map((r) => ({
|
|
478
|
+
route: r.route,
|
|
479
|
+
lastLoad: r.lastLoad,
|
|
480
|
+
clean: isClean(r),
|
|
481
|
+
unconfirmed: r.unconfirmed,
|
|
482
|
+
needsRecheck: r.recheck.map((e) => ({
|
|
483
|
+
message: e.message ?? null,
|
|
484
|
+
phase: e.phase ?? null,
|
|
485
|
+
carried: Boolean(e.carried),
|
|
486
|
+
})),
|
|
487
|
+
errors: r.errors.map((e) => ({
|
|
488
|
+
message: e.message ?? null,
|
|
489
|
+
stack: e.stack ?? null,
|
|
490
|
+
})),
|
|
491
|
+
warnings: r.warnings.map((w) => ({ message: w.message ?? null })),
|
|
492
|
+
healed: r.healed,
|
|
493
|
+
})),
|
|
494
|
+
},
|
|
495
|
+
null,
|
|
496
|
+
2,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function main(): Promise<number> {
|
|
501
|
+
const args = process.argv.slice(2);
|
|
502
|
+
const report = await buildReport();
|
|
503
|
+
|
|
504
|
+
if (args.includes("--json")) {
|
|
505
|
+
console.log(toJson(report));
|
|
506
|
+
} else {
|
|
507
|
+
printReport(report);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// --fail-on-error is off by default: presence of a signal depends on someone
|
|
511
|
+
// opening the page, so this must never become a flaky pass/fail gate.
|
|
512
|
+
if (args.includes("--fail-on-error") && report.session === "live" && failing(report).length) {
|
|
513
|
+
return 1;
|
|
514
|
+
}
|
|
515
|
+
return 0;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
process.exitCode = await main();
|
|
@@ -11,6 +11,13 @@ import { join, dirname, relative } from "path";
|
|
|
11
11
|
import { getFileMeta, PUBLIC_DIR, SRC_DIR } from "./utils.js";
|
|
12
12
|
import { updateComponentMap } from "./component-map";
|
|
13
13
|
import { DebouncedWorker, createSrcWatcher, DEFAULT_AWF } from "./utils.js";
|
|
14
|
+
import {
|
|
15
|
+
compactBrowserLog,
|
|
16
|
+
devLogMiddleware,
|
|
17
|
+
endBrowserLogSession,
|
|
18
|
+
injectDevLogScript,
|
|
19
|
+
startBrowserLogSession,
|
|
20
|
+
} from "./dev-log-bridge.js";
|
|
14
21
|
import chalk from "chalk";
|
|
15
22
|
|
|
16
23
|
const { __dirname } = getFileMeta();
|
|
@@ -32,6 +39,12 @@ function getExternalIP(): string | null {
|
|
|
32
39
|
|
|
33
40
|
const pipeline = new DebouncedWorker(
|
|
34
41
|
async () => {
|
|
42
|
+
// Source edits invalidate the browser log: everything in it was produced by
|
|
43
|
+
// code that just changed. Compact rather than truncate, so an interaction
|
|
44
|
+
// error nobody has re-tested survives an unrelated save. Only `src/` edits
|
|
45
|
+
// land here; public-asset churn goes through `publicPipeline` untouched.
|
|
46
|
+
compactBrowserLog("source file change(s)");
|
|
47
|
+
|
|
35
48
|
await generateFileListJson();
|
|
36
49
|
await updateComponentMap();
|
|
37
50
|
|
|
@@ -140,6 +153,10 @@ bs.init(
|
|
|
140
153
|
proxy: "http://localhost:3000",
|
|
141
154
|
online: true,
|
|
142
155
|
middleware: [
|
|
156
|
+
// Serves the browser console hook and receives its reports, so browser-side
|
|
157
|
+
// PulsePoint errors show up in this terminal (and `.pp/browser-log.jsonl`)
|
|
158
|
+
// instead of only in DevTools. First so its POSTs skip the proxy logger.
|
|
159
|
+
devLogMiddleware,
|
|
143
160
|
pulsePointSocketProxy,
|
|
144
161
|
(_req, res, next) => {
|
|
145
162
|
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
@@ -198,6 +215,14 @@ bs.init(
|
|
|
198
215
|
async (responseBuffer, proxyRes, _req, _res) => {
|
|
199
216
|
const contentType = proxyRes.headers["content-type"] || "";
|
|
200
217
|
|
|
218
|
+
// Dev-only: inject the browser console bridge into full HTML
|
|
219
|
+
// documents so PulsePoint errors reach this terminal and the
|
|
220
|
+
// session log. Fragments without </head> pass through untouched,
|
|
221
|
+
// and nothing here runs outside `npm run dev`.
|
|
222
|
+
if (contentType.includes("text/html")) {
|
|
223
|
+
return injectDevLogScript(responseBuffer.toString("utf8"));
|
|
224
|
+
}
|
|
225
|
+
|
|
201
226
|
if (!contentType.includes("application/json")) {
|
|
202
227
|
return responseBuffer;
|
|
203
228
|
}
|
|
@@ -252,6 +277,11 @@ bs.init(
|
|
|
252
277
|
}
|
|
253
278
|
|
|
254
279
|
const bsPort = bsInstance.getOption("port");
|
|
280
|
+
|
|
281
|
+
// Open the session browser log once the port is settled: the header's port
|
|
282
|
+
// is how `npm run logs` tells a live session from a stale leftover file.
|
|
283
|
+
startBrowserLogSession(Number(bsPort) || 0);
|
|
284
|
+
|
|
255
285
|
const urls = bsInstance.getOption("urls");
|
|
256
286
|
const localUrl = urls.get("local") || `http://localhost:${bsPort}`;
|
|
257
287
|
const externalIP = getExternalIP();
|
|
@@ -297,3 +327,16 @@ bs.init(
|
|
|
297
327
|
console.log(`\n${chalk.gray("Press Ctrl+C to stop.")}\n`);
|
|
298
328
|
},
|
|
299
329
|
);
|
|
330
|
+
|
|
331
|
+
// Close the browser log on the way out: a reader that finds no end marker
|
|
332
|
+
// treats the session as abandoned, so a clean exit should say so.
|
|
333
|
+
function shutdown(exitCode: number): void {
|
|
334
|
+
endBrowserLogSession();
|
|
335
|
+
if (bs.active) {
|
|
336
|
+
bs.exit();
|
|
337
|
+
}
|
|
338
|
+
process.exit(exitCode);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
process.once("SIGINT", () => shutdown(0));
|
|
342
|
+
process.once("SIGTERM", () => shutdown(0));
|