ushman-characterize 0.4.0
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/AGENTS.md +110 -0
- package/CHANGELOG.md +41 -0
- package/LICENSE.md +21 -0
- package/README.md +193 -0
- package/bin/ushman-characterize +19 -0
- package/dist/babel-config.d.ts +7 -0
- package/dist/babel-config.d.ts.map +1 -0
- package/dist/babel-config.js +17 -0
- package/dist/capture-server.d.ts +31 -0
- package/dist/capture-server.d.ts.map +1 -0
- package/dist/capture-server.js +199 -0
- package/dist/capture.d.ts +97 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +620 -0
- package/dist/cli/logger.d.ts +7 -0
- package/dist/cli/logger.d.ts.map +1 -0
- package/dist/cli/logger.js +14 -0
- package/dist/cli/parse-flags.d.ts +8 -0
- package/dist/cli/parse-flags.d.ts.map +1 -0
- package/dist/cli/parse-flags.js +60 -0
- package/dist/cli.d.ts +39 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +439 -0
- package/dist/constants.d.ts +20 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +19 -0
- package/dist/dedupe-contract.d.ts +26 -0
- package/dist/dedupe-contract.d.ts.map +1 -0
- package/dist/dedupe-contract.js +12 -0
- package/dist/default-export.d.ts +6 -0
- package/dist/default-export.d.ts.map +1 -0
- package/dist/default-export.js +52 -0
- package/dist/format-contract.d.ts +25 -0
- package/dist/format-contract.d.ts.map +1 -0
- package/dist/format-contract.js +96 -0
- package/dist/function-utils.d.ts +6 -0
- package/dist/function-utils.d.ts.map +1 -0
- package/dist/function-utils.js +22 -0
- package/dist/generate-replay.d.ts +18 -0
- package/dist/generate-replay.d.ts.map +1 -0
- package/dist/generate-replay.js +158 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/instrument.d.ts +39 -0
- package/dist/instrument.d.ts.map +1 -0
- package/dist/instrument.js +605 -0
- package/dist/ledger.d.ts +19 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +50 -0
- package/dist/puppeteer-harness.d.ts +74 -0
- package/dist/puppeteer-harness.d.ts.map +1 -0
- package/dist/puppeteer-harness.js +248 -0
- package/dist/purity-classifier.d.ts +28 -0
- package/dist/purity-classifier.d.ts.map +1 -0
- package/dist/purity-classifier.js +363 -0
- package/dist/rebind.d.ts +26 -0
- package/dist/rebind.d.ts.map +1 -0
- package/dist/rebind.js +356 -0
- package/dist/replay-report.d.ts +18 -0
- package/dist/replay-report.d.ts.map +1 -0
- package/dist/replay-report.js +12 -0
- package/dist/scene.d.ts +24 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +235 -0
- package/dist/schema-types.d.ts +40 -0
- package/dist/schema-types.d.ts.map +1 -0
- package/dist/schema-types.js +32 -0
- package/dist/seed-scaffolds.d.ts +31 -0
- package/dist/seed-scaffolds.d.ts.map +1 -0
- package/dist/seed-scaffolds.js +96 -0
- package/dist/shared.d.ts +36 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +390 -0
- package/dist/state-dag.d.ts +5 -0
- package/dist/state-dag.d.ts.map +1 -0
- package/dist/state-dag.js +27 -0
- package/dist/stub-pure.d.ts +57 -0
- package/dist/stub-pure.d.ts.map +1 -0
- package/dist/stub-pure.js +987 -0
- package/dist/time.d.ts +3 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +10 -0
- package/dist/trace-format.d.ts +24 -0
- package/dist/trace-format.d.ts.map +1 -0
- package/dist/trace-format.js +213 -0
- package/dist/trace-serializer.d.ts +94 -0
- package/dist/trace-serializer.d.ts.map +1 -0
- package/dist/trace-serializer.js +607 -0
- package/dist/tracer-runtime.d.ts +25 -0
- package/dist/tracer-runtime.d.ts.map +1 -0
- package/dist/tracer-runtime.js +291 -0
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +0 -0
- package/dist/workspace-paths.d.ts +64 -0
- package/dist/workspace-paths.d.ts.map +1 -0
- package/dist/workspace-paths.js +288 -0
- package/package.json +86 -0
package/dist/capture.js
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { mkdir } from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { createViteCaptureServerHost } from "./capture-server.js";
|
|
12
|
+
import { DEFAULT_CAPTURE_SETTLE_FRAMES, DEFAULT_CAPTURE_VIEWPORT, DEFAULT_MAX_UNIQUE_RECORDS_PER_STATE, DEFAULT_PER_FUNCTION_SHAPE_CAP, DEFAULT_TRACE_BYTES_LIMIT, } from "./constants.js";
|
|
13
|
+
import { toTraceDeduplicationPayload } from "./dedupe-contract.js";
|
|
14
|
+
import { parseTraceRecord, stampTraceRecord } from "./format-contract.js";
|
|
15
|
+
import { instrumentBundleSource } from "./instrument.js";
|
|
16
|
+
import { recordCharacterizeValidatorResult, writeCharacterizeReport } from "./ledger.js";
|
|
17
|
+
import { createPuppeteerHarness, safeEvaluate, } from "./puppeteer-harness.js";
|
|
18
|
+
import { StateDagDocumentSchema } from "./schema-types.js";
|
|
19
|
+
import { parseJsonLines, toJsonLines } from "./shared.js";
|
|
20
|
+
import { collectStateDagActions } from "./state-dag.js";
|
|
21
|
+
import { sleep } from "./time.js";
|
|
22
|
+
import { ensureWorkspaceTraceHarness, workspaceHarnessPaths } from "./trace-format.js";
|
|
23
|
+
import { buildStableHash } from "./trace-serializer.js";
|
|
24
|
+
import { buildTracerRuntimeSource } from "./tracer-runtime.js";
|
|
25
|
+
import { assertV4Workspace, pathExists, readVersionedDocument, resolveDeterminismSeed, resolveStateDagFile, resolveWorkspacePaths, } from "./workspace-paths.js";
|
|
26
|
+
const INITIAL_PORT = 0;
|
|
27
|
+
const createTraceState = (state) => ({
|
|
28
|
+
dedupeLimitReached: false,
|
|
29
|
+
droppedUniqueCalls: 0,
|
|
30
|
+
functionNames: new Set(),
|
|
31
|
+
mergedExistingRecords: 0,
|
|
32
|
+
rawCalls: 0,
|
|
33
|
+
recordBytesByKey: new Map(),
|
|
34
|
+
recordsByKey: new Map(),
|
|
35
|
+
skippedExistingRecords: 0,
|
|
36
|
+
state,
|
|
37
|
+
traceBytes: 0,
|
|
38
|
+
uniqueCalls: 0,
|
|
39
|
+
});
|
|
40
|
+
class CaptureCapacityError extends Error {
|
|
41
|
+
phase;
|
|
42
|
+
state;
|
|
43
|
+
constructor({ message, phase, state, }) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.phase = phase;
|
|
46
|
+
this.state = state;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const buildRecordKey = (record) => buildStableHash(JSON.stringify(toTraceDeduplicationPayload(record)));
|
|
50
|
+
const countRecordBytes = (record) => Buffer.byteLength(JSON.stringify({
|
|
51
|
+
...record,
|
|
52
|
+
args: record.args,
|
|
53
|
+
expected: record.expected,
|
|
54
|
+
sideEffects: record.sideEffects,
|
|
55
|
+
thisArg: record.thisArg,
|
|
56
|
+
threw: record.threw,
|
|
57
|
+
})) + 1;
|
|
58
|
+
const toStateSummary = (state) => ({
|
|
59
|
+
dedupeLimitReached: state.dedupeLimitReached,
|
|
60
|
+
droppedUniqueCalls: state.droppedUniqueCalls,
|
|
61
|
+
functionCount: state.functionNames.size,
|
|
62
|
+
mergedExistingRecords: state.mergedExistingRecords,
|
|
63
|
+
rawCalls: state.rawCalls,
|
|
64
|
+
skippedExistingRecords: state.skippedExistingRecords,
|
|
65
|
+
state: state.state,
|
|
66
|
+
traceBytes: state.traceBytes,
|
|
67
|
+
uniqueCalls: state.recordsByKey.size,
|
|
68
|
+
});
|
|
69
|
+
const updateRecord = ({ record, state, }) => {
|
|
70
|
+
const key = buildRecordKey(record);
|
|
71
|
+
const previous = state.recordsByKey.get(key);
|
|
72
|
+
const nextBytes = countRecordBytes(record);
|
|
73
|
+
const previousBytes = state.recordBytesByKey.get(key) ?? 0;
|
|
74
|
+
state.traceBytes += nextBytes - previousBytes;
|
|
75
|
+
state.recordBytesByKey.set(key, nextBytes);
|
|
76
|
+
state.recordsByKey.set(key, previous ? { ...previous, count: record.count } : record);
|
|
77
|
+
state.functionNames.add(record.functionName);
|
|
78
|
+
};
|
|
79
|
+
const readExistingTraceRecords = async ({ harnessPaths, stateId, }) => {
|
|
80
|
+
const traceFile = path.join(harnessPaths.tracesDir, `${stateId}.jsonl`);
|
|
81
|
+
if (!(await pathExists(traceFile))) {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
return parseJsonLines(await Bun.file(traceFile).text()).map((record, index) => parseTraceRecord({
|
|
85
|
+
context: `Trace record ${stateId}.jsonl:${index + 1}`,
|
|
86
|
+
value: record,
|
|
87
|
+
}));
|
|
88
|
+
};
|
|
89
|
+
const loadExistingStateRecords = async ({ harnessPaths, maxUniqueRecordsPerState, state, stateId, }) => {
|
|
90
|
+
for (const record of await readExistingTraceRecords({ harnessPaths, stateId })) {
|
|
91
|
+
const key = buildRecordKey(record);
|
|
92
|
+
if (state.recordsByKey.has(key)) {
|
|
93
|
+
state.skippedExistingRecords += 1;
|
|
94
|
+
updateRecord({
|
|
95
|
+
record,
|
|
96
|
+
state,
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (state.recordsByKey.size >= maxUniqueRecordsPerState) {
|
|
101
|
+
state.dedupeLimitReached = true;
|
|
102
|
+
throw new CaptureCapacityError({
|
|
103
|
+
message: `State ${stateId} already contains more than ${maxUniqueRecordsPerState} unique trace records. Increase maxUniqueRecordsPerState or prune the existing trace file before re-running capture.`,
|
|
104
|
+
phase: 'existing-traces',
|
|
105
|
+
state: stateId,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
updateRecord({
|
|
109
|
+
record,
|
|
110
|
+
state,
|
|
111
|
+
});
|
|
112
|
+
state.mergedExistingRecords += 1;
|
|
113
|
+
}
|
|
114
|
+
state.uniqueCalls = state.recordsByKey.size;
|
|
115
|
+
};
|
|
116
|
+
const mergeDrainPayload = async ({ disableTracer, maxUniqueRecordsPerState = Number.MAX_SAFE_INTEGER, maxTraceBytes, payload, state, }) => {
|
|
117
|
+
state.rawCalls = payload.stats.rawCalls;
|
|
118
|
+
state.droppedUniqueCalls = payload.stats.droppedUniqueCalls;
|
|
119
|
+
for (const [index, drainedRecord] of payload.records.entries()) {
|
|
120
|
+
const record = {
|
|
121
|
+
...drainedRecord,
|
|
122
|
+
state: state.state,
|
|
123
|
+
};
|
|
124
|
+
const key = buildRecordKey(record);
|
|
125
|
+
if (!state.recordsByKey.has(key) && state.recordsByKey.size >= maxUniqueRecordsPerState) {
|
|
126
|
+
state.dedupeLimitReached = true;
|
|
127
|
+
state.droppedUniqueCalls += payload.records.length - index;
|
|
128
|
+
await disableTracer();
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
const nextBytes = countRecordBytes(record);
|
|
132
|
+
const previousBytes = state.recordBytesByKey.get(key) ?? 0;
|
|
133
|
+
const projectedBytes = state.traceBytes - previousBytes + nextBytes;
|
|
134
|
+
if (projectedBytes > maxTraceBytes) {
|
|
135
|
+
state.traceBytes = Math.max(state.traceBytes, maxTraceBytes);
|
|
136
|
+
state.droppedUniqueCalls += payload.records.length - index;
|
|
137
|
+
await disableTracer();
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
updateRecord({
|
|
141
|
+
record,
|
|
142
|
+
state,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
state.uniqueCalls = state.recordsByKey.size;
|
|
146
|
+
};
|
|
147
|
+
const executeStateActions = async ({ dag, onAfterAction, pageHandle, stateId, }) => {
|
|
148
|
+
for (const action of collectStateDagActions(dag, stateId)) {
|
|
149
|
+
if (action.type === 'wait') {
|
|
150
|
+
await sleep(action.ms);
|
|
151
|
+
await onAfterAction();
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
await pageHandle.page.click(action.selector);
|
|
155
|
+
if (action.waitMs) {
|
|
156
|
+
await sleep(action.waitMs);
|
|
157
|
+
}
|
|
158
|
+
await onAfterAction();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
const resolveServedBundlePath = ({ bundlePath, workspaceRoot, }) => {
|
|
162
|
+
const { root, v4 } = resolveWorkspacePaths(workspaceRoot);
|
|
163
|
+
const resolvedBundlePath = path.resolve(bundlePath);
|
|
164
|
+
const candidates = [v4.candidatePublic, root];
|
|
165
|
+
for (const baseDir of candidates) {
|
|
166
|
+
const relative = path.relative(path.resolve(baseDir), resolvedBundlePath);
|
|
167
|
+
if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
168
|
+
return `/${relative.split(path.sep).join('/')}`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw new Error(`Bundle path ${bundlePath} must live under ${v4.candidatePublic} or ${root}. Re-run with a workspace-relative bundle path such as public/assets/index.js.`);
|
|
172
|
+
};
|
|
173
|
+
const loadStateDag = async (workspaceRoot) => {
|
|
174
|
+
const stateDagFile = await resolveStateDagFile(workspaceRoot);
|
|
175
|
+
const dag = await readVersionedDocument(stateDagFile, StateDagDocumentSchema);
|
|
176
|
+
if (!dag) {
|
|
177
|
+
throw new Error(`Missing state DAG at ${stateDagFile}.`);
|
|
178
|
+
}
|
|
179
|
+
return dag;
|
|
180
|
+
};
|
|
181
|
+
const maybeInstallInterception = async ({ bundleRequestPath, instrumentedSource, page, }) => {
|
|
182
|
+
if (!bundleRequestPath || !instrumentedSource) {
|
|
183
|
+
return async () => undefined;
|
|
184
|
+
}
|
|
185
|
+
const requestHandler = (request) => {
|
|
186
|
+
const url = new URL(request.url());
|
|
187
|
+
if (url.pathname === bundleRequestPath) {
|
|
188
|
+
void request.respond({
|
|
189
|
+
body: instrumentedSource,
|
|
190
|
+
contentType: 'application/javascript; charset=utf-8',
|
|
191
|
+
status: 200,
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
void request.continue();
|
|
196
|
+
};
|
|
197
|
+
await page.setRequestInterception(true);
|
|
198
|
+
page.on('request', requestHandler);
|
|
199
|
+
return async () => {
|
|
200
|
+
try {
|
|
201
|
+
page.off?.('request', requestHandler);
|
|
202
|
+
await page.setRequestInterception(false);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// page may already be closing
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
const drainTracer = async (page) => safeEvaluate(page, 'characterize:drain', () => {
|
|
210
|
+
const tracer = globalThis.__USHMAN_TRACER__;
|
|
211
|
+
return tracer?.drain?.() ?? { records: [], stats: { droppedUniqueCalls: 0, rawCalls: 0, uniqueCalls: 0 } };
|
|
212
|
+
});
|
|
213
|
+
const disableTracer = async (page) => {
|
|
214
|
+
await safeEvaluate(page, 'characterize:disable-tracer', () => {
|
|
215
|
+
const tracer = globalThis.__USHMAN_TRACER__;
|
|
216
|
+
tracer?.disable?.();
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
const loadDefaultSceneInspectorDriver = async () => {
|
|
220
|
+
try {
|
|
221
|
+
const specifier = '@ushman/threejs-tools/inspector';
|
|
222
|
+
const imported = (await import(__rewriteRelativeImportExtension(specifier)));
|
|
223
|
+
const captureSceneTree = imported.captureSceneGraph;
|
|
224
|
+
const installPreboot = imported.installSceneHeavyPreboot;
|
|
225
|
+
if (typeof captureSceneTree !== 'function') {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
captureSceneTree: captureSceneTree,
|
|
230
|
+
installPreboot: typeof installPreboot === 'function'
|
|
231
|
+
? installPreboot
|
|
232
|
+
: undefined,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const resolveSceneInspectorDriver = async ({ requested, sceneOnly, }) => {
|
|
240
|
+
if (requested) {
|
|
241
|
+
return requested;
|
|
242
|
+
}
|
|
243
|
+
if (!sceneOnly) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
const loaded = await loadDefaultSceneInspectorDriver();
|
|
247
|
+
if (loaded) {
|
|
248
|
+
return loaded;
|
|
249
|
+
}
|
|
250
|
+
throw new Error('scene-only capture requires a SceneInspectorDriver. Install @ushman/threejs-tools and expose its inspector entrypoint, or pass sceneInspectorDriver programmatically.');
|
|
251
|
+
};
|
|
252
|
+
const captureSceneOnly = async ({ driver, harnessPaths, page, stateId, }) => {
|
|
253
|
+
const sceneCapture = await driver.captureSceneTree(page);
|
|
254
|
+
await Bun.write(path.join(harnessPaths.sceneFixturesDir, `${stateId}.json`), `${JSON.stringify(sceneCapture, null, 2)}\n`);
|
|
255
|
+
};
|
|
256
|
+
const captureState = async ({ bundleRequestPath, captureSideEffects, driver, harnessPaths, instrumentedSource, maxUniqueRecordsPerState, maxTraceBytes, perFunctionCap, pageHandle, sceneOnly, serverUrl, stateId, dag, }) => {
|
|
257
|
+
const state = createTraceState(stateId);
|
|
258
|
+
await loadExistingStateRecords({
|
|
259
|
+
harnessPaths,
|
|
260
|
+
maxUniqueRecordsPerState,
|
|
261
|
+
state,
|
|
262
|
+
stateId,
|
|
263
|
+
});
|
|
264
|
+
const mergeLatestDrain = async () => {
|
|
265
|
+
const payload = await drainTracer(pageHandle.page);
|
|
266
|
+
await mergeDrainPayload({
|
|
267
|
+
disableTracer: async () => disableTracer(pageHandle.page),
|
|
268
|
+
maxTraceBytes,
|
|
269
|
+
maxUniqueRecordsPerState,
|
|
270
|
+
payload,
|
|
271
|
+
state,
|
|
272
|
+
});
|
|
273
|
+
};
|
|
274
|
+
await driver?.installPreboot?.(pageHandle.page);
|
|
275
|
+
await pageHandle.page.evaluateOnNewDocument(buildTracerRuntimeSource({
|
|
276
|
+
captureSideEffects,
|
|
277
|
+
perFunctionShapeCap: perFunctionCap,
|
|
278
|
+
}));
|
|
279
|
+
const restoreInterception = await maybeInstallInterception({
|
|
280
|
+
bundleRequestPath,
|
|
281
|
+
instrumentedSource,
|
|
282
|
+
page: pageHandle.page,
|
|
283
|
+
});
|
|
284
|
+
try {
|
|
285
|
+
await pageHandle.goto(serverUrl);
|
|
286
|
+
if (sceneOnly && driver) {
|
|
287
|
+
await executeStateActions({
|
|
288
|
+
dag,
|
|
289
|
+
onAfterAction: async () => { },
|
|
290
|
+
pageHandle,
|
|
291
|
+
stateId,
|
|
292
|
+
});
|
|
293
|
+
for (let index = 0; index < DEFAULT_CAPTURE_SETTLE_FRAMES; index += 1) {
|
|
294
|
+
await pageHandle.tickFrame();
|
|
295
|
+
}
|
|
296
|
+
await captureSceneOnly({
|
|
297
|
+
driver,
|
|
298
|
+
harnessPaths,
|
|
299
|
+
page: pageHandle.page,
|
|
300
|
+
stateId,
|
|
301
|
+
});
|
|
302
|
+
return state;
|
|
303
|
+
}
|
|
304
|
+
await executeStateActions({
|
|
305
|
+
dag,
|
|
306
|
+
onAfterAction: mergeLatestDrain,
|
|
307
|
+
pageHandle,
|
|
308
|
+
stateId,
|
|
309
|
+
});
|
|
310
|
+
for (let index = 0; index < DEFAULT_CAPTURE_SETTLE_FRAMES; index += 1) {
|
|
311
|
+
await pageHandle.tickFrame();
|
|
312
|
+
await mergeLatestDrain();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
await restoreInterception();
|
|
317
|
+
}
|
|
318
|
+
return state;
|
|
319
|
+
};
|
|
320
|
+
const validateCaptureOptions = async ({ bundlePath, mode, sceneOnly, workspaceRoot, }) => {
|
|
321
|
+
await assertV4Workspace(workspaceRoot);
|
|
322
|
+
if (mode !== 'dev' && mode !== 'preview') {
|
|
323
|
+
throw new Error(`Unsupported capture mode "${mode}". Use "preview" or "dev".`);
|
|
324
|
+
}
|
|
325
|
+
if (!sceneOnly && !bundlePath) {
|
|
326
|
+
throw new Error('capture requires --bundle=<path> unless --scene-only is set.');
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const buildInstrumentedSource = async ({ bundlePath, sceneOnly, }) => {
|
|
330
|
+
if (!bundlePath || sceneOnly) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
return instrumentBundleSource({
|
|
334
|
+
source: await Bun.file(bundlePath).text(),
|
|
335
|
+
sourceMapMode: 'inline',
|
|
336
|
+
sourcePath: bundlePath,
|
|
337
|
+
}).code;
|
|
338
|
+
};
|
|
339
|
+
const prepareCaptureRun = async ({ bundlePath, mode, sceneInspectorDriver, sceneOnly, serverHost, states, workspaceRoot, }) => {
|
|
340
|
+
const dag = await loadStateDag(workspaceRoot);
|
|
341
|
+
const stateIds = states && states.length > 0 ? [...states] : Object.keys(dag.states);
|
|
342
|
+
const bundleRequestPath = bundlePath ? resolveServedBundlePath({ bundlePath, workspaceRoot }) : null;
|
|
343
|
+
const driver = await resolveSceneInspectorDriver({
|
|
344
|
+
requested: sceneInspectorDriver,
|
|
345
|
+
sceneOnly,
|
|
346
|
+
});
|
|
347
|
+
const harnessPaths = await ensureWorkspaceTraceHarness(workspaceRoot);
|
|
348
|
+
await mkdir(harnessPaths.tracesDir, { recursive: true });
|
|
349
|
+
await mkdir(harnessPaths.sceneFixturesDir, { recursive: true });
|
|
350
|
+
return {
|
|
351
|
+
bundleRequestPath,
|
|
352
|
+
dag,
|
|
353
|
+
driver,
|
|
354
|
+
harnessPaths,
|
|
355
|
+
instrumentedSource: await buildInstrumentedSource({
|
|
356
|
+
bundlePath,
|
|
357
|
+
sceneOnly,
|
|
358
|
+
}),
|
|
359
|
+
seed: await resolveDeterminismSeed(workspaceRoot),
|
|
360
|
+
serverFactory: serverHost ?? createViteCaptureServerHost({ mode }),
|
|
361
|
+
stateIds,
|
|
362
|
+
};
|
|
363
|
+
};
|
|
364
|
+
const writeCapturedStateFile = async ({ harnessPaths, records, stateId, }) => {
|
|
365
|
+
const traceFile = path.join(harnessPaths.tracesDir, `${stateId}.jsonl`);
|
|
366
|
+
await Bun.write(traceFile, `${toJsonLines(records.map((record) => stampTraceRecord(record)))}\n`);
|
|
367
|
+
return traceFile;
|
|
368
|
+
};
|
|
369
|
+
const toWorkspaceRelativePath = ({ filePath, workspaceRoot, }) => path.relative(path.resolve(workspaceRoot), path.resolve(filePath)).split(path.sep).join('/');
|
|
370
|
+
const captureRequestedStates = async ({ bundleRequestPath, captureSideEffects, dag, driver, harness, harnessPaths, instrumentedSource, maxUniqueRecordsPerState, maxTraceBytes, perFunctionCap, sceneOnly, seed, serverUrl, stateIds, workspaceRoot, }) => {
|
|
371
|
+
const attemptedStates = [];
|
|
372
|
+
const summaries = [];
|
|
373
|
+
const failures = [];
|
|
374
|
+
const affectedFiles = [];
|
|
375
|
+
for (const stateId of stateIds) {
|
|
376
|
+
attemptedStates.push(stateId);
|
|
377
|
+
const pageHandle = (await harness.launch({
|
|
378
|
+
deterministicPreboot: true,
|
|
379
|
+
replaceDateConstructor: true,
|
|
380
|
+
seed,
|
|
381
|
+
viewport: DEFAULT_CAPTURE_VIEWPORT,
|
|
382
|
+
}));
|
|
383
|
+
try {
|
|
384
|
+
const stateCapture = await captureState({
|
|
385
|
+
bundleRequestPath,
|
|
386
|
+
captureSideEffects,
|
|
387
|
+
dag,
|
|
388
|
+
driver,
|
|
389
|
+
harnessPaths,
|
|
390
|
+
instrumentedSource,
|
|
391
|
+
maxTraceBytes,
|
|
392
|
+
maxUniqueRecordsPerState,
|
|
393
|
+
pageHandle,
|
|
394
|
+
perFunctionCap,
|
|
395
|
+
sceneOnly,
|
|
396
|
+
serverUrl,
|
|
397
|
+
stateId,
|
|
398
|
+
});
|
|
399
|
+
if (sceneOnly) {
|
|
400
|
+
affectedFiles.push(toWorkspaceRelativePath({
|
|
401
|
+
filePath: path.join(harnessPaths.sceneFixturesDir, `${stateId}.json`),
|
|
402
|
+
workspaceRoot,
|
|
403
|
+
}));
|
|
404
|
+
summaries.push({
|
|
405
|
+
dedupeLimitReached: false,
|
|
406
|
+
droppedUniqueCalls: 0,
|
|
407
|
+
functionCount: 0,
|
|
408
|
+
mergedExistingRecords: 0,
|
|
409
|
+
rawCalls: 0,
|
|
410
|
+
skippedExistingRecords: 0,
|
|
411
|
+
state: stateId,
|
|
412
|
+
traceBytes: 0,
|
|
413
|
+
uniqueCalls: 0,
|
|
414
|
+
});
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const records = [...stateCapture.recordsByKey.values()].sort((left, right) => left.functionName.localeCompare(right.functionName));
|
|
418
|
+
const traceFile = await writeCapturedStateFile({
|
|
419
|
+
harnessPaths,
|
|
420
|
+
records,
|
|
421
|
+
stateId,
|
|
422
|
+
});
|
|
423
|
+
affectedFiles.push(toWorkspaceRelativePath({
|
|
424
|
+
filePath: traceFile,
|
|
425
|
+
workspaceRoot,
|
|
426
|
+
}));
|
|
427
|
+
summaries.push(toStateSummary(stateCapture));
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
431
|
+
failures.push({
|
|
432
|
+
error: message,
|
|
433
|
+
phase: error instanceof CaptureCapacityError ? error.phase : 'capture',
|
|
434
|
+
state: stateId,
|
|
435
|
+
successfulStatesBeforeFailure: summaries.map((summary) => summary.state),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
finally {
|
|
439
|
+
await pageHandle.close();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
affectedFiles,
|
|
444
|
+
failures,
|
|
445
|
+
result: {
|
|
446
|
+
attemptedStates,
|
|
447
|
+
completedStates: summaries.map((summary) => summary.state),
|
|
448
|
+
failures,
|
|
449
|
+
requestedStates: stateIds,
|
|
450
|
+
skippedStates: stateIds.filter((stateId) => !attemptedStates.includes(stateId)),
|
|
451
|
+
states: summaries,
|
|
452
|
+
status: failures.length > 0 ? 'partial' : 'success',
|
|
453
|
+
traceDir: harnessPaths.tracesDir,
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
};
|
|
457
|
+
const recordSuccessfulCapture = async ({ affectedFiles, failures, mode, result, sceneOnly, stateIds, workspaceRoot, }) => {
|
|
458
|
+
const reportPath = await writeCharacterizeReport({
|
|
459
|
+
payload: {
|
|
460
|
+
affectedFiles,
|
|
461
|
+
failures,
|
|
462
|
+
mode,
|
|
463
|
+
result,
|
|
464
|
+
sceneOnly,
|
|
465
|
+
states: stateIds,
|
|
466
|
+
},
|
|
467
|
+
prefix: 'capture',
|
|
468
|
+
workspaceRoot,
|
|
469
|
+
});
|
|
470
|
+
await recordCharacterizeValidatorResult({
|
|
471
|
+
affectedFiles,
|
|
472
|
+
metrics: {
|
|
473
|
+
failures,
|
|
474
|
+
mode,
|
|
475
|
+
sceneOnly,
|
|
476
|
+
stateCount: result.states.length,
|
|
477
|
+
states: result.states,
|
|
478
|
+
},
|
|
479
|
+
resultPath: reportPath,
|
|
480
|
+
summary: failures.length > 0
|
|
481
|
+
? `capture partially succeeded: wrote ${affectedFiles.length} characterize artifact(s), ${failures.length} state(s) failed`
|
|
482
|
+
: `capture wrote ${affectedFiles.length} characterize artifact(s) across ${result.states.length} state(s)`,
|
|
483
|
+
verdict: failures.length > 0 ? 'yellow' : 'green',
|
|
484
|
+
workspaceRoot,
|
|
485
|
+
});
|
|
486
|
+
};
|
|
487
|
+
const recordFailedCapture = async ({ error, mode, requestedStates, sceneOnly, workspaceRoot, }) => {
|
|
488
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
489
|
+
const structuredFailure = error instanceof CaptureCapacityError
|
|
490
|
+
? {
|
|
491
|
+
phase: error.phase,
|
|
492
|
+
state: error.state,
|
|
493
|
+
}
|
|
494
|
+
: null;
|
|
495
|
+
const reportPath = await writeCharacterizeReport({
|
|
496
|
+
payload: {
|
|
497
|
+
error: message,
|
|
498
|
+
mode,
|
|
499
|
+
sceneOnly,
|
|
500
|
+
states: requestedStates,
|
|
501
|
+
structuredFailure,
|
|
502
|
+
},
|
|
503
|
+
prefix: 'capture-failed',
|
|
504
|
+
workspaceRoot,
|
|
505
|
+
}).catch((reportError) => {
|
|
506
|
+
console.error(`Failed to write characterize failure report: ${reportError instanceof Error ? reportError.message : String(reportError)}`);
|
|
507
|
+
return null;
|
|
508
|
+
});
|
|
509
|
+
if (!reportPath) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
await recordCharacterizeValidatorResult({
|
|
513
|
+
metrics: {
|
|
514
|
+
error: message,
|
|
515
|
+
mode,
|
|
516
|
+
requestedStates,
|
|
517
|
+
sceneOnly,
|
|
518
|
+
structuredFailure,
|
|
519
|
+
},
|
|
520
|
+
resultPath: reportPath,
|
|
521
|
+
summary: `capture failed: ${message}`,
|
|
522
|
+
verdict: 'red',
|
|
523
|
+
workspaceRoot,
|
|
524
|
+
}).catch((ledgerError) => {
|
|
525
|
+
console.error(`Failed to record characterize failure verdict: ${ledgerError instanceof Error ? ledgerError.message : String(ledgerError)}`);
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
const logCleanupFailure = (label, error) => {
|
|
529
|
+
console.error(`${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
530
|
+
};
|
|
531
|
+
/**
|
|
532
|
+
* Capture instrumented call traces for the requested workspace states.
|
|
533
|
+
*/
|
|
534
|
+
export const captureCharacterization = async ({ bundlePath = null, captureSideEffects = false, harnessFactory = createPuppeteerHarness, maxTraceBytes = DEFAULT_TRACE_BYTES_LIMIT, mode = 'preview', maxUniqueRecordsPerState = DEFAULT_MAX_UNIQUE_RECORDS_PER_STATE, perFunctionCap = DEFAULT_PER_FUNCTION_SHAPE_CAP, sceneInspectorDriver, sceneOnly = false, serverHost, states, workspaceRoot, }) => {
|
|
535
|
+
await validateCaptureOptions({
|
|
536
|
+
bundlePath,
|
|
537
|
+
mode,
|
|
538
|
+
sceneOnly,
|
|
539
|
+
workspaceRoot,
|
|
540
|
+
});
|
|
541
|
+
const prepared = await prepareCaptureRun({
|
|
542
|
+
bundlePath,
|
|
543
|
+
mode,
|
|
544
|
+
sceneInspectorDriver,
|
|
545
|
+
sceneOnly,
|
|
546
|
+
serverHost,
|
|
547
|
+
states,
|
|
548
|
+
workspaceRoot,
|
|
549
|
+
});
|
|
550
|
+
let server = null;
|
|
551
|
+
let harness = null;
|
|
552
|
+
try {
|
|
553
|
+
server = await prepared.serverFactory.serve(workspaceRoot, INITIAL_PORT);
|
|
554
|
+
harness = harnessFactory({ headless: true });
|
|
555
|
+
const captured = await captureRequestedStates({
|
|
556
|
+
bundleRequestPath: prepared.bundleRequestPath,
|
|
557
|
+
captureSideEffects,
|
|
558
|
+
dag: prepared.dag,
|
|
559
|
+
driver: prepared.driver,
|
|
560
|
+
harness,
|
|
561
|
+
harnessPaths: prepared.harnessPaths,
|
|
562
|
+
instrumentedSource: prepared.instrumentedSource,
|
|
563
|
+
maxTraceBytes,
|
|
564
|
+
maxUniqueRecordsPerState,
|
|
565
|
+
perFunctionCap,
|
|
566
|
+
sceneOnly,
|
|
567
|
+
seed: prepared.seed,
|
|
568
|
+
serverUrl: server.url,
|
|
569
|
+
stateIds: prepared.stateIds,
|
|
570
|
+
workspaceRoot,
|
|
571
|
+
});
|
|
572
|
+
if (captured.result.states.length === 0 && captured.failures.length > 0) {
|
|
573
|
+
throw new Error(captured.failures.map((failure) => `${failure.state}: ${failure.error}`).join('\n'));
|
|
574
|
+
}
|
|
575
|
+
await recordSuccessfulCapture({
|
|
576
|
+
affectedFiles: captured.affectedFiles,
|
|
577
|
+
failures: captured.failures,
|
|
578
|
+
mode,
|
|
579
|
+
result: captured.result,
|
|
580
|
+
sceneOnly,
|
|
581
|
+
stateIds: prepared.stateIds,
|
|
582
|
+
workspaceRoot,
|
|
583
|
+
});
|
|
584
|
+
return captured.result;
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
await recordFailedCapture({
|
|
588
|
+
error,
|
|
589
|
+
mode,
|
|
590
|
+
requestedStates: prepared.stateIds,
|
|
591
|
+
sceneOnly,
|
|
592
|
+
workspaceRoot,
|
|
593
|
+
});
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
596
|
+
finally {
|
|
597
|
+
const [harnessClose, serverClose] = await Promise.allSettled([harness?.close(), server?.close()]);
|
|
598
|
+
if (harnessClose.status === 'rejected') {
|
|
599
|
+
logCleanupFailure('Failed to close characterize harness', harnessClose.reason);
|
|
600
|
+
}
|
|
601
|
+
if (serverClose.status === 'rejected') {
|
|
602
|
+
logCleanupFailure('Failed to close characterize server', serverClose.reason);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
export const readCapturedTraceState = async ({ stateId, workspaceRoot, }) => {
|
|
607
|
+
const filePath = path.join(workspaceHarnessPaths(workspaceRoot).tracesDir, `${stateId}.jsonl`);
|
|
608
|
+
if (!(await pathExists(filePath))) {
|
|
609
|
+
return [];
|
|
610
|
+
}
|
|
611
|
+
return parseJsonLines(await Bun.file(filePath).text()).map((record, index) => parseTraceRecord({
|
|
612
|
+
context: `Trace record ${stateId}.jsonl:${index + 1}`,
|
|
613
|
+
value: record,
|
|
614
|
+
}));
|
|
615
|
+
};
|
|
616
|
+
export const __testOnly = {
|
|
617
|
+
buildRecordKey,
|
|
618
|
+
mergeDrainPayload,
|
|
619
|
+
resolveServedBundlePath,
|
|
620
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type Logger = {
|
|
2
|
+
info: (message: string, extra?: Record<string, unknown>) => void;
|
|
3
|
+
warn: (message: string, extra?: Record<string, unknown>) => void;
|
|
4
|
+
error: (message: string, extra?: Record<string, unknown>) => void;
|
|
5
|
+
};
|
|
6
|
+
export declare const createConsoleLogger: () => Logger;
|
|
7
|
+
//# sourceMappingURL=logger.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/cli/logger.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,MAAM,GAAG;IACjB,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACjE,IAAI,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACjE,KAAK,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;CACrE,CAAC;AAYF,eAAO,MAAM,mBAAmB,QAAO,MAIrC,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createConsola } from 'consola';
|
|
2
|
+
const log = createConsola({ formatOptions: { colors: true, date: false } });
|
|
3
|
+
const format = (extra) => {
|
|
4
|
+
if (!extra || Object.keys(extra).length === 0) {
|
|
5
|
+
return '';
|
|
6
|
+
}
|
|
7
|
+
const parts = Object.entries(extra).map(([key, value]) => `${key}=${JSON.stringify(value)}`);
|
|
8
|
+
return ` (${parts.join(' ')})`;
|
|
9
|
+
};
|
|
10
|
+
export const createConsoleLogger = () => ({
|
|
11
|
+
error: (message, extra) => log.error(`${message}${format(extra)}`),
|
|
12
|
+
info: (message, extra) => log.info(`${message}${format(extra)}`),
|
|
13
|
+
warn: (message, extra) => log.warn(`${message}${format(extra)}`),
|
|
14
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const parseKeyValueFlags: (args: readonly string[], options?: {
|
|
2
|
+
readonly booleanFlags?: readonly string[];
|
|
3
|
+
readonly stringFlags?: readonly string[];
|
|
4
|
+
}) => {
|
|
5
|
+
flags: Map<string, string | boolean>;
|
|
6
|
+
positionals: string[];
|
|
7
|
+
};
|
|
8
|
+
//# sourceMappingURL=parse-flags.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse-flags.d.ts","sourceRoot":"","sources":["../../src/cli/parse-flags.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,kBAAkB,GAC3B,MAAM,SAAS,MAAM,EAAE,EACvB,UAAS;IACL,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACvC;;;CAkET,CAAC"}
|