miaoda-game-devkit 0.4.0 → 0.6.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/README.md +22 -10
- package/dist/react/testing.d.mts +50 -65
- package/dist/react/testing.d.ts +50 -65
- package/dist/react/testing.js +276 -188
- package/dist/react/testing.mjs +276 -188
- package/dist/react/vitest-config.js +328 -210
- package/dist/react/vitest-config.mjs +328 -210
- package/package.json +1 -1
|
@@ -49,6 +49,13 @@ var import_vitest = require("vitest");
|
|
|
49
49
|
|
|
50
50
|
// src/react/react-playthrough-core.ts
|
|
51
51
|
var import_react = require("@testing-library/react");
|
|
52
|
+
function throwIfAborted(signal) {
|
|
53
|
+
if (!signal?.aborted) return;
|
|
54
|
+
if (signal.reason instanceof Error) throw signal.reason;
|
|
55
|
+
throw new Error("Playthrough advancement was cancelled.", {
|
|
56
|
+
cause: signal.reason
|
|
57
|
+
});
|
|
58
|
+
}
|
|
52
59
|
function formatDiagnostics(read) {
|
|
53
60
|
if (!read) return void 0;
|
|
54
61
|
try {
|
|
@@ -77,11 +84,13 @@ async function runBoundedUntil(condition, options = {}) {
|
|
|
77
84
|
);
|
|
78
85
|
}
|
|
79
86
|
for (let step = 0; step <= maxSteps; step += 1) {
|
|
87
|
+
throwIfAborted(options.signal);
|
|
80
88
|
if (condition()) return step;
|
|
81
89
|
if (step < maxSteps) {
|
|
82
90
|
await (0, import_react.act)(async () => {
|
|
83
91
|
await options.step?.(step + 1);
|
|
84
92
|
});
|
|
93
|
+
throwIfAborted(options.signal);
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
const diagnostics = formatDiagnostics(options.diagnostics);
|
|
@@ -102,13 +111,35 @@ var INPUT_EVENTS = [
|
|
|
102
111
|
"touchstart",
|
|
103
112
|
"touchend"
|
|
104
113
|
];
|
|
105
|
-
var
|
|
106
|
-
var
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
var MIN_STAGES = 5;
|
|
115
|
+
var MIN_MILESTONES = 3;
|
|
116
|
+
var MAX_TRACE_VALUE_LENGTH = 140;
|
|
117
|
+
var MAX_TRACE_LENGTH = 720;
|
|
118
|
+
var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
|
|
119
|
+
function truncateTraceValue(value, limit) {
|
|
120
|
+
const compact = value.replace(/\s+/g, " ").trim();
|
|
121
|
+
if (compact.length <= limit) return compact;
|
|
122
|
+
return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
123
|
+
}
|
|
124
|
+
function formatReactPlaythroughFailureTrace(trace) {
|
|
125
|
+
const stages = trace.stages.map(
|
|
126
|
+
(stage) => `${stage.kind}:${stage.name}=${truncateTraceValue(stage.state, MAX_TRACE_VALUE_LENGTH)}`
|
|
127
|
+
);
|
|
128
|
+
if (trace.current) {
|
|
129
|
+
stages.push(
|
|
130
|
+
`current:${trace.current.name}=${truncateTraceValue(trace.current.before, 70)}\u2192${truncateTraceValue(trace.current.last, 70)}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const step = trace.step ? `stepUntil=${trace.step.completed ?? "failed"}/${trace.step.bound}` : void 0;
|
|
134
|
+
const formatted = `${stages.length > 0 ? stages.join(" -> ") : "stages=none"}${step ? `; ${step}` : ""}`;
|
|
135
|
+
return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
|
|
110
136
|
}
|
|
111
|
-
|
|
137
|
+
var MAX_FORMATTED_STATE_LENGTH = 500;
|
|
138
|
+
function formatState(fingerprint) {
|
|
139
|
+
if (fingerprint.length <= MAX_FORMATTED_STATE_LENGTH) return fingerprint;
|
|
140
|
+
return `${fingerprint.slice(0, MAX_FORMATTED_STATE_LENGTH)}\u2026 (${fingerprint.length} chars)`;
|
|
141
|
+
}
|
|
142
|
+
function sampleObservedState(observe, stage) {
|
|
112
143
|
let value;
|
|
113
144
|
try {
|
|
114
145
|
value = observe();
|
|
@@ -118,222 +149,272 @@ function sampleObservation(observe, stage) {
|
|
|
118
149
|
try {
|
|
119
150
|
const fingerprint = JSON.stringify(value);
|
|
120
151
|
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
121
|
-
return { fingerprint, formatted:
|
|
152
|
+
return { fingerprint, formatted: formatState(fingerprint) };
|
|
122
153
|
} catch {
|
|
123
154
|
throw new Error(
|
|
124
155
|
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
125
156
|
);
|
|
126
157
|
}
|
|
127
158
|
}
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
]
|
|
159
|
+
function sampleDomState(view) {
|
|
160
|
+
const fingerprint = view.container.innerHTML.replace(/\s+/g, " ").trim();
|
|
161
|
+
return { fingerprint, formatted: formatState(JSON.stringify(fingerprint)) };
|
|
162
|
+
}
|
|
163
|
+
function createEvidence() {
|
|
164
|
+
return { domInputEvents: 0, stages: [], verified: false };
|
|
165
|
+
}
|
|
166
|
+
function createMetadata(waiverReason) {
|
|
167
|
+
return { version: 4, waiverReason, evidence: createEvidence() };
|
|
168
|
+
}
|
|
169
|
+
function stageLabel(kind, name) {
|
|
170
|
+
return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
|
|
134
171
|
}
|
|
135
172
|
function describeMissingEvidence(evidence) {
|
|
136
|
-
if (!evidence || evidence.
|
|
137
|
-
if (evidence.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
173
|
+
if (!evidence || evidence.stages.length === 0) return "an enter stage";
|
|
174
|
+
if (evidence.stages[0]?.kind !== "entered") return "the entered stage";
|
|
175
|
+
const milestones = evidence.stages.filter(
|
|
176
|
+
(stage) => stage.kind === "milestone"
|
|
177
|
+
);
|
|
178
|
+
if (milestones.length < MIN_MILESTONES) {
|
|
179
|
+
return `at least ${MIN_MILESTONES} gameplay milestones`;
|
|
180
|
+
}
|
|
181
|
+
const finalStage = evidence.stages.at(-1);
|
|
182
|
+
if (finalStage?.kind !== "progress" && finalStage?.kind !== "terminal") {
|
|
183
|
+
return "a progress or terminal finish stage";
|
|
184
|
+
}
|
|
185
|
+
if (evidence.stages.length < MIN_STAGES) {
|
|
186
|
+
return `at least ${MIN_STAGES} evidenced stages`;
|
|
148
187
|
}
|
|
149
188
|
return "a complete playthrough verification marker";
|
|
150
189
|
}
|
|
151
|
-
function createMetadata(waiverReason) {
|
|
152
|
-
return {
|
|
153
|
-
version: 3,
|
|
154
|
-
waiverReason,
|
|
155
|
-
evidence: {
|
|
156
|
-
domInputEvents: 0,
|
|
157
|
-
entryInputs: 0,
|
|
158
|
-
primaryInputs: 0,
|
|
159
|
-
boundedRuns: 0,
|
|
160
|
-
assertionsAfterOutcome: 0,
|
|
161
|
-
checkpoints: [],
|
|
162
|
-
verified: false
|
|
163
|
-
}
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
190
|
function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
167
191
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
168
192
|
const metadata = createMetadata(reason);
|
|
169
|
-
(0, import_vitest.test)(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
193
|
+
(0, import_vitest.test)(
|
|
194
|
+
"production game completes a bounded playthrough",
|
|
195
|
+
{
|
|
196
|
+
concurrent: false,
|
|
197
|
+
skip: Boolean(reason),
|
|
198
|
+
meta: { reactPlaythrough: metadata }
|
|
199
|
+
},
|
|
200
|
+
async ({ annotate, expect, onTestFailed, signal }) => {
|
|
201
|
+
metadata.evidence = createEvidence();
|
|
202
|
+
metadata.trace = void 0;
|
|
203
|
+
const evidence = metadata.evidence;
|
|
204
|
+
let entered = false;
|
|
205
|
+
let finished = false;
|
|
206
|
+
let activeStage;
|
|
207
|
+
let lastSample;
|
|
208
|
+
let stepTrace;
|
|
209
|
+
let failureTraceFactory;
|
|
210
|
+
let acceptingStageInput = false;
|
|
211
|
+
let inputCaptureAttached = false;
|
|
212
|
+
const recordInput = () => {
|
|
213
|
+
if (acceptingStageInput) evidence.domInputEvents += 1;
|
|
214
|
+
};
|
|
215
|
+
const stopInputCapture = () => {
|
|
216
|
+
acceptingStageInput = false;
|
|
217
|
+
if (!inputCaptureAttached) return;
|
|
218
|
+
inputCaptureAttached = false;
|
|
219
|
+
for (const event of INPUT_EVENTS) {
|
|
220
|
+
document.removeEventListener(event, recordInput, true);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const captureFailureTrace = () => {
|
|
224
|
+
try {
|
|
225
|
+
return failureTraceFactory?.() ?? "stages=none";
|
|
226
|
+
} catch {
|
|
227
|
+
return "stages=none";
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
onTestFailed(() => {
|
|
231
|
+
metadata.trace ??= captureFailureTrace();
|
|
232
|
+
});
|
|
233
|
+
for (const event of INPUT_EVENTS) {
|
|
234
|
+
document.addEventListener(event, recordInput, true);
|
|
191
235
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
236
|
+
inputCaptureAttached = true;
|
|
237
|
+
signal.addEventListener("abort", stopInputCapture, { once: true });
|
|
238
|
+
try {
|
|
239
|
+
const view = (0, import_react2.render)(element);
|
|
240
|
+
if (view.container.childNodes.length === 0) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
"playthroughTest must render the production game entry."
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const user = import_user_event.default.setup();
|
|
246
|
+
const sampleState = (label) => playthroughOptions?.observe ? sampleObservedState(playthroughOptions.observe, label) : sampleDomState(view);
|
|
247
|
+
lastSample = sampleState("initial render");
|
|
248
|
+
failureTraceFactory = () => formatReactPlaythroughFailureTrace({
|
|
249
|
+
stages: evidence.stages.map((stage) => ({
|
|
250
|
+
name: stage.name,
|
|
251
|
+
kind: stage.kind,
|
|
252
|
+
state: stage.after
|
|
253
|
+
})),
|
|
254
|
+
current: activeStage ? {
|
|
255
|
+
name: stageLabel(activeStage.kind, activeStage.name),
|
|
256
|
+
before: activeStage.before.formatted,
|
|
257
|
+
last: (() => {
|
|
258
|
+
try {
|
|
259
|
+
return sampleState("failure").formatted;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return `<state unavailable: ${String(error)}>`;
|
|
262
|
+
}
|
|
263
|
+
})()
|
|
264
|
+
} : void 0,
|
|
265
|
+
step: stepTrace
|
|
266
|
+
});
|
|
267
|
+
const executeStage = async (name, kind, stage) => {
|
|
268
|
+
const normalizedName = name.trim();
|
|
269
|
+
if (normalizedName.length === 0) {
|
|
270
|
+
throw new Error("playthrough stage names must be non-empty strings.");
|
|
271
|
+
}
|
|
272
|
+
if (evidence.stages.some(
|
|
273
|
+
(completed) => completed.name === normalizedName
|
|
274
|
+
)) {
|
|
199
275
|
throw new Error(
|
|
200
|
-
|
|
276
|
+
`playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
|
|
201
277
|
);
|
|
202
278
|
}
|
|
203
|
-
if (kind === "
|
|
279
|
+
if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
|
|
204
280
|
throw new Error(
|
|
205
|
-
|
|
281
|
+
`milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
|
|
206
282
|
);
|
|
207
283
|
}
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
284
|
+
const before = lastSample ?? sampleState(`before ${normalizedName}`);
|
|
285
|
+
activeStage = { name: normalizedName, kind, before };
|
|
286
|
+
stepTrace = { bound: stage.maxSteps ?? 120 };
|
|
287
|
+
if (stage.until()) {
|
|
211
288
|
throw new Error(
|
|
212
|
-
|
|
289
|
+
`${stageLabel(kind, normalizedName)} until condition must be false before its driver runs. Wait for a result caused by this stage, not state left by an earlier stage.`
|
|
213
290
|
);
|
|
214
291
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
},
|
|
226
|
-
checkpoint(kind) {
|
|
227
|
-
if (kind === "entered") {
|
|
228
|
-
if (enteredRecorded) {
|
|
229
|
-
throw new Error(
|
|
230
|
-
'checkpoint("entered") may only be recorded once, before the primary input.'
|
|
231
|
-
);
|
|
292
|
+
const inputsBefore = evidence.domInputEvents;
|
|
293
|
+
if (stage.act) {
|
|
294
|
+
acceptingStageInput = true;
|
|
295
|
+
try {
|
|
296
|
+
await stage.act();
|
|
297
|
+
} finally {
|
|
298
|
+
acceptingStageInput = false;
|
|
232
299
|
}
|
|
233
|
-
if (evidence.
|
|
300
|
+
if (evidence.domInputEvents === inputsBefore) {
|
|
234
301
|
throw new Error(
|
|
235
|
-
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
enteredRecorded = true;
|
|
239
|
-
if (playthroughOptions?.observe) {
|
|
240
|
-
enteredObservation = sampleObservation(
|
|
241
|
-
playthroughOptions.observe,
|
|
242
|
-
"entered"
|
|
302
|
+
`${stageLabel(kind, normalizedName)} act did not dispatch a supported production DOM input. Use the provided user to click, type, press, point, or touch the production target; do not call Controller commands directly.`
|
|
243
303
|
);
|
|
244
|
-
} else {
|
|
245
|
-
domTextAtEntered = document.body.textContent ?? "";
|
|
246
304
|
}
|
|
247
|
-
evidence.checkpoints.push(kind);
|
|
248
|
-
return;
|
|
249
305
|
}
|
|
250
|
-
|
|
306
|
+
let advancedSteps = 0;
|
|
307
|
+
const stepBound = stage.maxSteps ?? 120;
|
|
308
|
+
stepTrace = { bound: stepBound };
|
|
309
|
+
const steps = await runBoundedUntil(stage.until, {
|
|
310
|
+
maxSteps: stage.maxSteps,
|
|
311
|
+
signal,
|
|
312
|
+
diagnostics: stage.diagnostics ?? playthroughOptions?.observe,
|
|
313
|
+
step: stage.step ? async (step) => {
|
|
314
|
+
advancedSteps += 1;
|
|
315
|
+
await stage.step?.(step);
|
|
316
|
+
} : void 0
|
|
317
|
+
});
|
|
318
|
+
stepTrace = { bound: stepBound, completed: steps };
|
|
319
|
+
if (!stage.act && advancedSteps === 0) {
|
|
251
320
|
throw new Error(
|
|
252
|
-
|
|
321
|
+
`${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
|
|
253
322
|
);
|
|
254
323
|
}
|
|
255
|
-
|
|
324
|
+
const assertionsBefore = expect.getState().assertionCalls;
|
|
325
|
+
await stage.assert({ expect, user, view });
|
|
326
|
+
const assertions = expect.getState().assertionCalls - assertionsBefore;
|
|
327
|
+
if (assertions === 0) {
|
|
256
328
|
throw new Error(
|
|
257
|
-
|
|
329
|
+
`${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
|
|
258
330
|
);
|
|
259
331
|
}
|
|
260
|
-
|
|
332
|
+
const after = sampleState(`after ${normalizedName}`);
|
|
333
|
+
if (after.fingerprint === before.fingerprint) {
|
|
334
|
+
const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
|
|
261
335
|
throw new Error(
|
|
262
|
-
|
|
336
|
+
`${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
|
|
263
337
|
);
|
|
264
338
|
}
|
|
265
|
-
evidence.
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
339
|
+
evidence.stages.push({
|
|
340
|
+
name: normalizedName,
|
|
341
|
+
kind,
|
|
342
|
+
domInputEvents: evidence.domInputEvents - inputsBefore,
|
|
343
|
+
advancedSteps,
|
|
344
|
+
assertions,
|
|
345
|
+
stateChanged: true,
|
|
346
|
+
before: before.formatted,
|
|
347
|
+
after: after.formatted
|
|
348
|
+
});
|
|
349
|
+
lastSample = after;
|
|
350
|
+
activeStage = void 0;
|
|
351
|
+
};
|
|
352
|
+
await run({
|
|
353
|
+
view,
|
|
354
|
+
user,
|
|
355
|
+
async enter(stage) {
|
|
356
|
+
if (entered) {
|
|
357
|
+
throw new Error("enter may only be called once.");
|
|
283
358
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
"outcome"
|
|
287
|
-
);
|
|
288
|
-
if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
|
|
289
|
-
throw new Error(
|
|
290
|
-
`The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
|
|
291
|
-
);
|
|
359
|
+
if (evidence.stages.length > 0) {
|
|
360
|
+
throw new Error("enter must be the first playthrough stage.");
|
|
292
361
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
362
|
+
await executeStage("entered", "entered", stage);
|
|
363
|
+
entered = true;
|
|
364
|
+
},
|
|
365
|
+
async milestone(name, stage) {
|
|
366
|
+
if (!entered) {
|
|
367
|
+
throw new Error("milestone must follow enter.");
|
|
368
|
+
}
|
|
369
|
+
if (finished) {
|
|
370
|
+
throw new Error("milestone cannot run after finish.");
|
|
371
|
+
}
|
|
372
|
+
await executeStage(name, "milestone", stage);
|
|
373
|
+
},
|
|
374
|
+
async finish(name, stage) {
|
|
375
|
+
if (!entered) throw new Error("finish must follow enter.");
|
|
376
|
+
if (finished) {
|
|
377
|
+
throw new Error("finish may only be called once.");
|
|
378
|
+
}
|
|
379
|
+
await executeStage(name, stage.kind, stage);
|
|
380
|
+
finished = true;
|
|
297
381
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
});
|
|
303
|
-
const assertionCalls = expect.getState().assertionCalls;
|
|
304
|
-
evidence.assertionsAfterOutcome = assertionsAtOutcome === void 0 ? 0 : assertionCalls - assertionsAtOutcome;
|
|
305
|
-
if (evidence.entryInputs === 0) {
|
|
306
|
-
throw new Error(
|
|
307
|
-
'playthroughTest must perform an entry input with performInput("entry", ...).'
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
if (evidence.primaryInputs === 0) {
|
|
311
|
-
throw new Error(
|
|
312
|
-
'playthroughTest must perform a core game action with performInput("primary", ...).'
|
|
382
|
+
});
|
|
383
|
+
const milestones = evidence.stages.filter(
|
|
384
|
+
(stage) => stage.kind === "milestone"
|
|
313
385
|
);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
386
|
+
if (!entered) throw new Error("playthroughTest must call enter once.");
|
|
387
|
+
if (milestones.length < MIN_MILESTONES) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (!finished) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
'playthroughTest must call finish with kind "progress" or "terminal".'
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (evidence.stages.length < MIN_STAGES) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
evidence.verified = true;
|
|
403
|
+
} catch (error) {
|
|
404
|
+
const trace = captureFailureTrace();
|
|
405
|
+
metadata.trace = trace;
|
|
406
|
+
try {
|
|
407
|
+
await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
|
|
408
|
+
} catch {
|
|
409
|
+
}
|
|
410
|
+
throw error;
|
|
411
|
+
} finally {
|
|
412
|
+
metadata.trace ??= failureTraceFactory?.();
|
|
413
|
+
signal.removeEventListener("abort", stopInputCapture);
|
|
414
|
+
stopInputCapture();
|
|
334
415
|
}
|
|
335
416
|
}
|
|
336
|
-
|
|
417
|
+
);
|
|
337
418
|
}
|
|
338
419
|
var playthroughTest = Object.assign(
|
|
339
420
|
(element, optionsOrRun, maybeRun) => {
|
|
@@ -341,19 +422,29 @@ var playthroughTest = Object.assign(
|
|
|
341
422
|
definePlaythrough(element, optionsOrRun);
|
|
342
423
|
return;
|
|
343
424
|
}
|
|
344
|
-
if (!maybeRun)
|
|
425
|
+
if (!maybeRun) {
|
|
426
|
+
throw new TypeError("playthroughTest requires a run callback.");
|
|
427
|
+
}
|
|
345
428
|
definePlaythrough(element, maybeRun, optionsOrRun);
|
|
346
429
|
},
|
|
347
430
|
{
|
|
348
|
-
skip: (reason, element
|
|
431
|
+
skip: (reason, element) => definePlaythrough(element, async () => {
|
|
432
|
+
}, void 0, reason)
|
|
349
433
|
}
|
|
350
434
|
);
|
|
351
435
|
function auditReactPlaythroughRun(tests) {
|
|
352
436
|
const declared = tests.filter((candidate) => candidate.metadata);
|
|
353
437
|
const valid = declared.filter(({ state, metadata }) => {
|
|
354
438
|
const evidence = metadata?.evidence;
|
|
355
|
-
|
|
356
|
-
|
|
439
|
+
if (!evidence || state !== "passed" || evidence.verified !== true) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
const milestones = evidence.stages.filter(
|
|
443
|
+
(stage) => stage.kind === "milestone"
|
|
444
|
+
);
|
|
445
|
+
const finalStage = evidence.stages.at(-1);
|
|
446
|
+
return evidence.domInputEvents > 0 && evidence.stages.length >= MIN_STAGES && evidence.stages[0]?.kind === "entered" && milestones.length >= MIN_MILESTONES && (finalStage?.kind === "progress" || finalStage?.kind === "terminal") && evidence.stages.every(
|
|
447
|
+
(stage) => stage.assertions > 0 && (stage.domInputEvents > 0 || stage.advancedSteps > 0) && stage.stateChanged === true
|
|
357
448
|
);
|
|
358
449
|
});
|
|
359
450
|
const waivers = declared.filter(
|
|
@@ -362,13 +453,11 @@ function auditReactPlaythroughRun(tests) {
|
|
|
362
453
|
const issues = [];
|
|
363
454
|
if (declared.length === 0) {
|
|
364
455
|
issues.push(
|
|
365
|
-
|
|
456
|
+
"No production gameplay verification was declared. Use playthroughTest to render <App />, then compose one real-input enter stage, at least three named milestones, and finish. Later stages may drive production input or deterministic advancement; every stage must reach and assert a bounded new result."
|
|
366
457
|
);
|
|
367
458
|
} else {
|
|
368
459
|
for (const candidate of declared) {
|
|
369
|
-
|
|
370
|
-
const isWaived = waivers.includes(candidate);
|
|
371
|
-
if (isValid || isWaived) continue;
|
|
460
|
+
if (valid.includes(candidate) || waivers.includes(candidate)) continue;
|
|
372
461
|
if (candidate.state === "skipped") {
|
|
373
462
|
issues.push(
|
|
374
463
|
`Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
|
|
@@ -378,9 +467,8 @@ function auditReactPlaythroughRun(tests) {
|
|
|
378
467
|
`Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
|
|
379
468
|
);
|
|
380
469
|
} else {
|
|
381
|
-
const missing = describeMissingEvidence(candidate.metadata?.evidence);
|
|
382
470
|
issues.push(
|
|
383
|
-
`Playthrough ${JSON.stringify(candidate.name)} is missing ${
|
|
471
|
+
`Playthrough ${JSON.stringify(candidate.name)} is missing ${describeMissingEvidence(candidate.metadata?.evidence)}.`
|
|
384
472
|
);
|
|
385
473
|
}
|
|
386
474
|
}
|
|
@@ -396,7 +484,12 @@ var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
|
396
484
|
function isMetadata(value) {
|
|
397
485
|
if (!value || typeof value !== "object") return false;
|
|
398
486
|
const metadata = value;
|
|
399
|
-
|
|
487
|
+
if (metadata.version !== 4) return false;
|
|
488
|
+
const evidence = metadata.evidence;
|
|
489
|
+
if (!evidence || typeof evidence !== "object") return false;
|
|
490
|
+
return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
|
|
491
|
+
(stage) => Boolean(stage) && typeof stage === "object" && typeof stage.name === "string" && typeof stage.kind === "string" && typeof stage.domInputEvents === "number" && typeof stage.advancedSteps === "number" && typeof stage.assertions === "number" && typeof stage.stateChanged === "boolean" && typeof stage.before === "string" && typeof stage.after === "string"
|
|
492
|
+
);
|
|
400
493
|
}
|
|
401
494
|
function toAuditInput(test2) {
|
|
402
495
|
const metadata = test2.meta().reactPlaythrough;
|
|
@@ -418,24 +511,46 @@ function failureHint(value) {
|
|
|
418
511
|
return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
|
|
419
512
|
}
|
|
420
513
|
function errorLocation(value) {
|
|
421
|
-
const match = value.match(
|
|
514
|
+
const match = value.match(
|
|
515
|
+
/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/
|
|
516
|
+
);
|
|
422
517
|
return match?.[1];
|
|
423
518
|
}
|
|
519
|
+
function failureTrace(test2) {
|
|
520
|
+
const annotations = test2.annotations();
|
|
521
|
+
let annotationTrace;
|
|
522
|
+
for (let index = annotations.length - 1; index >= 0; index -= 1) {
|
|
523
|
+
if (annotations[index].type === REACT_PLAYTHROUGH_TRACE_ANNOTATION) {
|
|
524
|
+
annotationTrace = annotations[index].message;
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const metadata = test2.meta().reactPlaythrough;
|
|
529
|
+
const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
|
|
530
|
+
return trace ? truncateReporterLine(trace, 720) : void 0;
|
|
531
|
+
}
|
|
532
|
+
function truncateReporterLine(value, limit) {
|
|
533
|
+
const compact = (0, import_node_util.stripVTControlCharacters)(value).replace(/\s+/g, " ").trim();
|
|
534
|
+
if (compact.length <= limit) return compact;
|
|
535
|
+
return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
536
|
+
}
|
|
424
537
|
function toModuleResult(module2, projectRoot) {
|
|
425
538
|
const tests = [...module2.children.allTests()];
|
|
539
|
+
const moduleErrors = module2.errors().map((error) => firstLine(error.message)).filter((message) => Boolean(message));
|
|
426
540
|
const errors = [
|
|
427
|
-
...
|
|
541
|
+
...moduleErrors,
|
|
428
542
|
...tests.flatMap(
|
|
429
543
|
(test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
|
|
430
544
|
)
|
|
431
545
|
].filter((message) => Boolean(message));
|
|
432
546
|
const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
|
|
433
|
-
const raw = test2.result().errors?.
|
|
547
|
+
const raw = test2.result().errors?.at(-1)?.message ?? module2.errors()[0]?.message ?? "Unknown failure";
|
|
434
548
|
return {
|
|
435
549
|
test: test2.fullName,
|
|
436
550
|
cause: firstLine(raw) ?? "Unknown failure",
|
|
437
551
|
location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
|
|
438
|
-
hint: failureHint(raw)
|
|
552
|
+
hint: failureHint(raw),
|
|
553
|
+
trace: failureTrace(test2)
|
|
439
554
|
};
|
|
440
555
|
});
|
|
441
556
|
if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
|
|
@@ -451,13 +566,17 @@ function toModuleResult(module2, projectRoot) {
|
|
|
451
566
|
file: (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
|
|
452
567
|
state: module2.state(),
|
|
453
568
|
errors,
|
|
569
|
+
primaryError: moduleErrors[0] ?? failures[0]?.cause,
|
|
454
570
|
tests: tests.map(toAuditInput),
|
|
455
571
|
failures
|
|
456
572
|
};
|
|
457
573
|
}
|
|
458
|
-
function
|
|
574
|
+
function formatReactFailureSummary(modules) {
|
|
459
575
|
const failures = modules.flatMap(
|
|
460
|
-
(module2) => (module2.failures ?? []).map((failure) => ({
|
|
576
|
+
(module2) => (module2.failures ?? []).map((failure) => ({
|
|
577
|
+
...failure,
|
|
578
|
+
file: module2.file
|
|
579
|
+
}))
|
|
461
580
|
);
|
|
462
581
|
if (failures.length === 0) return ["TEST_RESULT: PASS"];
|
|
463
582
|
const lines = [`FAILED_TESTS: ${failures.length}`];
|
|
@@ -465,6 +584,7 @@ function formatFailureSummary(modules) {
|
|
|
465
584
|
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
466
585
|
lines.push(`TEST: ${failure.test}`);
|
|
467
586
|
lines.push(`CAUSE: ${failure.cause}`);
|
|
587
|
+
if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
|
|
468
588
|
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
469
589
|
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
470
590
|
}
|
|
@@ -475,24 +595,22 @@ function repairGuidance(cause) {
|
|
|
475
595
|
if (/snapshot\(\) returned the same reference/i.test(cause)) {
|
|
476
596
|
return "The game mutated state without publishing a new snapshot reference, so React skipped the render after an Object.is comparison. Publish a new top-level object before notifying subscribers, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot.";
|
|
477
597
|
}
|
|
478
|
-
if (/
|
|
479
|
-
|
|
480
|
-
)) {
|
|
481
|
-
return 'Make the stepUntil condition false at checkpoint("entered"). Verify that the primary input reaches the production control, then wait for a post-input outcome such as a changed score, a removed entry overlay, a completed turn, or a result screen. For Canvas or Controller state outside the DOM, declare observe once on playthroughTest and return the read-only production Telemetry snapshot.';
|
|
598
|
+
if (/until condition must be false before its driver runs/i.test(cause)) {
|
|
599
|
+
return "Make this stage's until condition describe a new result that does not exist before act or step runs. Do not reuse state completed by an earlier milestone.";
|
|
482
600
|
}
|
|
483
|
-
if (/
|
|
484
|
-
return "The
|
|
601
|
+
if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
|
|
602
|
+
return "The stage ran and asserted, but its observable state matched the previous milestone. Wait for a genuinely new gameplay result. Canvas or Controller games should make observe read the same production Controller that React renders.";
|
|
485
603
|
}
|
|
486
604
|
if (/No step callback was provided/i.test(cause)) {
|
|
487
|
-
return "This
|
|
605
|
+
return "This stage is driven by time or frames, but it did not advance the game clock. Inject the devkit GameClock into the production game and pass step: () => clock.stepFrame(). Do not replace deterministic advancement with a real setTimeout.";
|
|
488
606
|
}
|
|
489
607
|
if (/outcome was not reached within \d+ steps/i.test(cause)) {
|
|
490
|
-
return "The
|
|
608
|
+
return "The stage driver ran, but gameplay did not reach the outcome within the bound. Confirm that its production input or deterministic step changed the intended rule state, then inspect Last diagnostics to locate the stalled stage.";
|
|
491
609
|
}
|
|
492
|
-
if (/
|
|
493
|
-
return
|
|
610
|
+
if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
|
|
611
|
+
return "Compose one enter stage, at least three named gameplay milestones, and one finish stage. Enter must drive real DOM input; each later stage must drive input or deterministic steps. Every stage waits for a bounded new result and asserts it with the provided expect.";
|
|
494
612
|
}
|
|
495
|
-
return "Start from the production entry
|
|
613
|
+
return "Start from the production entry and describe the real game as enter, named milestones, and finish. Enter must act through production input; later stages may act or step. Every stage waits for and asserts a new player-visible or authoritative result. Do not jump to an internal level or mutate gameplay state.";
|
|
496
614
|
}
|
|
497
615
|
function assessReactPlaythroughReport(input) {
|
|
498
616
|
const base = { file: input.expectedFile };
|
|
@@ -509,7 +627,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
509
627
|
...base,
|
|
510
628
|
status: "FAILED",
|
|
511
629
|
cause: "The required production playthrough test file does not exist.",
|
|
512
|
-
next:
|
|
630
|
+
next: "Create the file and render <App />. Compose one real-input enter stage, at least three named gameplay milestones, and one finish stage. Later stages may drive production input or deterministic advancement; every stage must reach and assert a bounded new result.",
|
|
513
631
|
failsRun: true
|
|
514
632
|
};
|
|
515
633
|
}
|
|
@@ -536,10 +654,10 @@ function assessReactPlaythroughReport(input) {
|
|
|
536
654
|
failsRun: true
|
|
537
655
|
};
|
|
538
656
|
}
|
|
539
|
-
const tests =
|
|
657
|
+
const tests = productionModule.tests;
|
|
540
658
|
const audit = auditReactPlaythroughRun(tests);
|
|
541
659
|
if (!audit.passed) {
|
|
542
|
-
const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
|
|
660
|
+
const cause = productionModule.primaryError ?? productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
|
|
543
661
|
return {
|
|
544
662
|
...base,
|
|
545
663
|
status: "FAILED",
|
|
@@ -609,7 +727,7 @@ var ReactPlaythroughReporter = class {
|
|
|
609
727
|
} else {
|
|
610
728
|
console.log(output);
|
|
611
729
|
}
|
|
612
|
-
const summary =
|
|
730
|
+
const summary = formatReactFailureSummary(
|
|
613
731
|
testModules.map((module2) => toModuleResult(module2, this.projectRoot))
|
|
614
732
|
);
|
|
615
733
|
if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
|