miaoda-game-devkit 0.5.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 +16 -12
- package/dist/react/testing.d.mts +49 -66
- package/dist/react/testing.d.ts +49 -66
- package/dist/react/testing.js +265 -241
- package/dist/react/testing.mjs +265 -241
- package/dist/react/vitest-config.js +289 -257
- package/dist/react/vitest-config.mjs +289 -257
- 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,36 +111,35 @@ var INPUT_EVENTS = [
|
|
|
102
111
|
"touchstart",
|
|
103
112
|
"touchend"
|
|
104
113
|
];
|
|
105
|
-
var
|
|
106
|
-
var
|
|
107
|
-
var MAX_TRACE_VALUE_LENGTH =
|
|
114
|
+
var MIN_STAGES = 5;
|
|
115
|
+
var MIN_MILESTONES = 3;
|
|
116
|
+
var MAX_TRACE_VALUE_LENGTH = 140;
|
|
108
117
|
var MAX_TRACE_LENGTH = 720;
|
|
118
|
+
var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
|
|
109
119
|
function truncateTraceValue(value, limit) {
|
|
110
120
|
const compact = value.replace(/\s+/g, " ").trim();
|
|
111
121
|
if (compact.length <= limit) return compact;
|
|
112
122
|
return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
113
123
|
}
|
|
114
124
|
function formatReactPlaythroughFailureTrace(trace) {
|
|
115
|
-
const stages =
|
|
116
|
-
|
|
117
|
-
["after-primary", trace.afterPrimary],
|
|
118
|
-
["last", trace.last]
|
|
119
|
-
].filter((stage) => stage[1] !== void 0).map(
|
|
120
|
-
([stage, value]) => `${stage}=${truncateTraceValue(value, MAX_TRACE_VALUE_LENGTH)}`
|
|
125
|
+
const stages = trace.stages.map(
|
|
126
|
+
(stage) => `${stage.kind}:${stage.name}=${truncateTraceValue(stage.state, MAX_TRACE_VALUE_LENGTH)}`
|
|
121
127
|
);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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}` : ""}`;
|
|
127
135
|
return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
|
|
128
136
|
}
|
|
129
|
-
var
|
|
130
|
-
function
|
|
131
|
-
if (fingerprint.length <=
|
|
132
|
-
return `${fingerprint.slice(0,
|
|
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)`;
|
|
133
141
|
}
|
|
134
|
-
function
|
|
142
|
+
function sampleObservedState(observe, stage) {
|
|
135
143
|
let value;
|
|
136
144
|
try {
|
|
137
145
|
value = observe();
|
|
@@ -141,263 +149,272 @@ function sampleObservation(observe, stage) {
|
|
|
141
149
|
try {
|
|
142
150
|
const fingerprint = JSON.stringify(value);
|
|
143
151
|
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
144
|
-
return { fingerprint, formatted:
|
|
152
|
+
return { fingerprint, formatted: formatState(fingerprint) };
|
|
145
153
|
} catch {
|
|
146
154
|
throw new Error(
|
|
147
155
|
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
148
156
|
);
|
|
149
157
|
}
|
|
150
158
|
}
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
`after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
|
|
155
|
-
`outcome=${outcome.formatted}`
|
|
156
|
-
].join(", ");
|
|
157
|
-
}
|
|
158
|
-
function describeMissingEvidence(evidence) {
|
|
159
|
-
if (!evidence || evidence.entryInputs === 0) return "an entry input";
|
|
160
|
-
if (evidence.primaryInputs === 0) return "a primary gameplay input";
|
|
161
|
-
if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
|
|
162
|
-
if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
|
|
163
|
-
if (evidence.assertionsAfterOutcome === 0)
|
|
164
|
-
return "an outcome assertion after stepUntil";
|
|
165
|
-
if (evidence.checkpoints.length < MIN_CHECKPOINTS)
|
|
166
|
-
return `at least ${MIN_CHECKPOINTS} checkpoints`;
|
|
167
|
-
if (!evidence.checkpoints.some(
|
|
168
|
-
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
169
|
-
)) {
|
|
170
|
-
return "progress/terminal checkpoint";
|
|
171
|
-
}
|
|
172
|
-
return "a complete playthrough verification marker";
|
|
159
|
+
function sampleDomState(view) {
|
|
160
|
+
const fingerprint = view.container.innerHTML.replace(/\s+/g, " ").trim();
|
|
161
|
+
return { fingerprint, formatted: formatState(JSON.stringify(fingerprint)) };
|
|
173
162
|
}
|
|
174
163
|
function createEvidence() {
|
|
175
|
-
return {
|
|
176
|
-
domInputEvents: 0,
|
|
177
|
-
entryInputs: 0,
|
|
178
|
-
primaryInputs: 0,
|
|
179
|
-
boundedRuns: 0,
|
|
180
|
-
assertionsAfterOutcome: 0,
|
|
181
|
-
checkpoints: [],
|
|
182
|
-
verified: false
|
|
183
|
-
};
|
|
164
|
+
return { domInputEvents: 0, stages: [], verified: false };
|
|
184
165
|
}
|
|
185
166
|
function createMetadata(waiverReason) {
|
|
186
|
-
return { version:
|
|
167
|
+
return { version: 4, waiverReason, evidence: createEvidence() };
|
|
168
|
+
}
|
|
169
|
+
function stageLabel(kind, name) {
|
|
170
|
+
return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
|
|
171
|
+
}
|
|
172
|
+
function describeMissingEvidence(evidence) {
|
|
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`;
|
|
187
|
+
}
|
|
188
|
+
return "a complete playthrough verification marker";
|
|
187
189
|
}
|
|
188
190
|
function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
189
191
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
190
192
|
const metadata = createMetadata(reason);
|
|
191
|
-
(0, import_vitest.test)(
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
"playthroughTest must render the production game entry."
|
|
234
|
-
);
|
|
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);
|
|
235
235
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
)) {
|
|
243
275
|
throw new Error(
|
|
244
|
-
|
|
276
|
+
`playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
|
|
245
277
|
);
|
|
246
278
|
}
|
|
247
|
-
if (kind === "
|
|
279
|
+
if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
|
|
248
280
|
throw new Error(
|
|
249
|
-
|
|
281
|
+
`milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
|
|
250
282
|
);
|
|
251
283
|
}
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
284
|
+
const before = lastSample ?? sampleState(`before ${normalizedName}`);
|
|
285
|
+
activeStage = { name: normalizedName, kind, before };
|
|
286
|
+
stepTrace = { bound: stage.maxSteps ?? 120 };
|
|
287
|
+
if (stage.until()) {
|
|
255
288
|
throw new Error(
|
|
256
|
-
|
|
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.`
|
|
257
290
|
);
|
|
258
291
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
);
|
|
267
|
-
afterPrimaryTrace = afterPrimaryObservation.formatted;
|
|
268
|
-
} else {
|
|
269
|
-
afterPrimaryTrace = sampleDomTrace();
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
},
|
|
273
|
-
checkpoint(kind) {
|
|
274
|
-
if (kind === "entered") {
|
|
275
|
-
if (enteredRecorded) {
|
|
276
|
-
throw new Error(
|
|
277
|
-
'checkpoint("entered") may only be recorded once, before the primary input.'
|
|
278
|
-
);
|
|
292
|
+
const inputsBefore = evidence.domInputEvents;
|
|
293
|
+
if (stage.act) {
|
|
294
|
+
acceptingStageInput = true;
|
|
295
|
+
try {
|
|
296
|
+
await stage.act();
|
|
297
|
+
} finally {
|
|
298
|
+
acceptingStageInput = false;
|
|
279
299
|
}
|
|
280
|
-
if (evidence.
|
|
300
|
+
if (evidence.domInputEvents === inputsBefore) {
|
|
281
301
|
throw new Error(
|
|
282
|
-
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
enteredRecorded = true;
|
|
286
|
-
if (playthroughOptions?.observe) {
|
|
287
|
-
enteredObservation = sampleObservation(
|
|
288
|
-
playthroughOptions.observe,
|
|
289
|
-
"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.`
|
|
290
303
|
);
|
|
291
|
-
enteredTrace = enteredObservation.formatted;
|
|
292
|
-
} else {
|
|
293
|
-
domTextAtEntered = document.body.textContent ?? "";
|
|
294
|
-
enteredTrace = sampleDomTrace();
|
|
295
304
|
}
|
|
296
|
-
evidence.checkpoints.push(kind);
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
if (evidence.primaryInputs === 0) {
|
|
300
|
-
throw new Error(
|
|
301
|
-
`checkpoint("${kind}") must follow performInput("primary", ...).`
|
|
302
|
-
);
|
|
303
305
|
}
|
|
304
|
-
|
|
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) {
|
|
305
320
|
throw new Error(
|
|
306
|
-
|
|
321
|
+
`${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
|
|
307
322
|
);
|
|
308
323
|
}
|
|
309
|
-
|
|
324
|
+
const assertionsBefore = expect.getState().assertionCalls;
|
|
325
|
+
await stage.assert({ expect, user, view });
|
|
326
|
+
const assertions = expect.getState().assertionCalls - assertionsBefore;
|
|
327
|
+
if (assertions === 0) {
|
|
310
328
|
throw new Error(
|
|
311
|
-
|
|
329
|
+
`${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
|
|
312
330
|
);
|
|
313
331
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const stepBound = stepOptions.maxSteps ?? 120;
|
|
318
|
-
stepTrace = { bound: stepBound };
|
|
319
|
-
const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
|
|
320
|
-
...stepOptions,
|
|
321
|
-
diagnostics: playthroughOptions.observe
|
|
322
|
-
};
|
|
323
|
-
const steps = await runBoundedUntil(condition, boundedOptions);
|
|
324
|
-
stepTrace = { bound: stepBound, completed: steps };
|
|
325
|
-
if (evidence.primaryInputs === 0) {
|
|
332
|
+
const after = sampleState(`after ${normalizedName}`);
|
|
333
|
+
if (after.fingerprint === before.fingerprint) {
|
|
334
|
+
const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
|
|
326
335
|
throw new Error(
|
|
327
|
-
|
|
336
|
+
`${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
|
|
328
337
|
);
|
|
329
338
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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.");
|
|
335
358
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
"outcome"
|
|
339
|
-
);
|
|
340
|
-
outcomeTrace = outcomeObservation.formatted;
|
|
341
|
-
if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
|
|
342
|
-
throw new Error(
|
|
343
|
-
`The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
|
|
344
|
-
);
|
|
359
|
+
if (evidence.stages.length > 0) {
|
|
360
|
+
throw new Error("enter must be the first playthrough stage.");
|
|
345
361
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
);
|
|
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.");
|
|
352
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;
|
|
353
381
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
const assertionCalls = expect.getState().assertionCalls;
|
|
360
|
-
evidence.assertionsAfterOutcome = assertionsAtOutcome === void 0 ? 0 : assertionCalls - assertionsAtOutcome;
|
|
361
|
-
if (evidence.entryInputs === 0) {
|
|
362
|
-
throw new Error(
|
|
363
|
-
'playthroughTest must perform an entry input with performInput("entry", ...).'
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
if (evidence.primaryInputs === 0) {
|
|
367
|
-
throw new Error(
|
|
368
|
-
'playthroughTest must perform a core game action with performInput("primary", ...).'
|
|
369
|
-
);
|
|
370
|
-
}
|
|
371
|
-
if (evidence.boundedRuns === 0) {
|
|
372
|
-
throw new Error("playthroughTest must complete one bounded stepUntil.");
|
|
373
|
-
}
|
|
374
|
-
if (evidence.assertionsAfterOutcome === 0) {
|
|
375
|
-
throw new Error(
|
|
376
|
-
"Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
|
|
377
|
-
);
|
|
378
|
-
}
|
|
379
|
-
if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
|
|
380
|
-
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
381
|
-
)) {
|
|
382
|
-
throw new Error(
|
|
383
|
-
`playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
|
|
382
|
+
});
|
|
383
|
+
const milestones = evidence.stages.filter(
|
|
384
|
+
(stage) => stage.kind === "milestone"
|
|
384
385
|
);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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();
|
|
398
415
|
}
|
|
399
416
|
}
|
|
400
|
-
|
|
417
|
+
);
|
|
401
418
|
}
|
|
402
419
|
var playthroughTest = Object.assign(
|
|
403
420
|
(element, optionsOrRun, maybeRun) => {
|
|
@@ -405,19 +422,29 @@ var playthroughTest = Object.assign(
|
|
|
405
422
|
definePlaythrough(element, optionsOrRun);
|
|
406
423
|
return;
|
|
407
424
|
}
|
|
408
|
-
if (!maybeRun)
|
|
425
|
+
if (!maybeRun) {
|
|
426
|
+
throw new TypeError("playthroughTest requires a run callback.");
|
|
427
|
+
}
|
|
409
428
|
definePlaythrough(element, maybeRun, optionsOrRun);
|
|
410
429
|
},
|
|
411
430
|
{
|
|
412
|
-
skip: (reason, element
|
|
431
|
+
skip: (reason, element) => definePlaythrough(element, async () => {
|
|
432
|
+
}, void 0, reason)
|
|
413
433
|
}
|
|
414
434
|
);
|
|
415
435
|
function auditReactPlaythroughRun(tests) {
|
|
416
436
|
const declared = tests.filter((candidate) => candidate.metadata);
|
|
417
437
|
const valid = declared.filter(({ state, metadata }) => {
|
|
418
438
|
const evidence = metadata?.evidence;
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
421
448
|
);
|
|
422
449
|
});
|
|
423
450
|
const waivers = declared.filter(
|
|
@@ -426,13 +453,11 @@ function auditReactPlaythroughRun(tests) {
|
|
|
426
453
|
const issues = [];
|
|
427
454
|
if (declared.length === 0) {
|
|
428
455
|
issues.push(
|
|
429
|
-
|
|
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."
|
|
430
457
|
);
|
|
431
458
|
} else {
|
|
432
459
|
for (const candidate of declared) {
|
|
433
|
-
|
|
434
|
-
const isWaived = waivers.includes(candidate);
|
|
435
|
-
if (isValid || isWaived) continue;
|
|
460
|
+
if (valid.includes(candidate) || waivers.includes(candidate)) continue;
|
|
436
461
|
if (candidate.state === "skipped") {
|
|
437
462
|
issues.push(
|
|
438
463
|
`Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
|
|
@@ -442,9 +467,8 @@ function auditReactPlaythroughRun(tests) {
|
|
|
442
467
|
`Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
|
|
443
468
|
);
|
|
444
469
|
} else {
|
|
445
|
-
const missing = describeMissingEvidence(candidate.metadata?.evidence);
|
|
446
470
|
issues.push(
|
|
447
|
-
`Playthrough ${JSON.stringify(candidate.name)} is missing ${
|
|
471
|
+
`Playthrough ${JSON.stringify(candidate.name)} is missing ${describeMissingEvidence(candidate.metadata?.evidence)}.`
|
|
448
472
|
);
|
|
449
473
|
}
|
|
450
474
|
}
|
|
@@ -460,7 +484,12 @@ var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
|
460
484
|
function isMetadata(value) {
|
|
461
485
|
if (!value || typeof value !== "object") return false;
|
|
462
486
|
const metadata = value;
|
|
463
|
-
|
|
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
|
+
);
|
|
464
493
|
}
|
|
465
494
|
function toAuditInput(test2) {
|
|
466
495
|
const metadata = test2.meta().reactPlaythrough;
|
|
@@ -482,7 +511,9 @@ function failureHint(value) {
|
|
|
482
511
|
return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
|
|
483
512
|
}
|
|
484
513
|
function errorLocation(value) {
|
|
485
|
-
const match = value.match(
|
|
514
|
+
const match = value.match(
|
|
515
|
+
/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/
|
|
516
|
+
);
|
|
486
517
|
return match?.[1];
|
|
487
518
|
}
|
|
488
519
|
function failureTrace(test2) {
|
|
@@ -542,7 +573,10 @@ function toModuleResult(module2, projectRoot) {
|
|
|
542
573
|
}
|
|
543
574
|
function formatReactFailureSummary(modules) {
|
|
544
575
|
const failures = modules.flatMap(
|
|
545
|
-
(module2) => (module2.failures ?? []).map((failure) => ({
|
|
576
|
+
(module2) => (module2.failures ?? []).map((failure) => ({
|
|
577
|
+
...failure,
|
|
578
|
+
file: module2.file
|
|
579
|
+
}))
|
|
546
580
|
);
|
|
547
581
|
if (failures.length === 0) return ["TEST_RESULT: PASS"];
|
|
548
582
|
const lines = [`FAILED_TESTS: ${failures.length}`];
|
|
@@ -561,24 +595,22 @@ function repairGuidance(cause) {
|
|
|
561
595
|
if (/snapshot\(\) returned the same reference/i.test(cause)) {
|
|
562
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.";
|
|
563
597
|
}
|
|
564
|
-
if (/
|
|
565
|
-
|
|
566
|
-
)) {
|
|
567
|
-
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.";
|
|
568
600
|
}
|
|
569
|
-
if (/
|
|
570
|
-
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.";
|
|
571
603
|
}
|
|
572
604
|
if (/No step callback was provided/i.test(cause)) {
|
|
573
|
-
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.";
|
|
574
606
|
}
|
|
575
607
|
if (/outcome was not reached within \d+ steps/i.test(cause)) {
|
|
576
|
-
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.";
|
|
577
609
|
}
|
|
578
|
-
if (/
|
|
579
|
-
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.";
|
|
580
612
|
}
|
|
581
|
-
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.";
|
|
582
614
|
}
|
|
583
615
|
function assessReactPlaythroughReport(input) {
|
|
584
616
|
const base = { file: input.expectedFile };
|
|
@@ -595,7 +627,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
595
627
|
...base,
|
|
596
628
|
status: "FAILED",
|
|
597
629
|
cause: "The required production playthrough test file does not exist.",
|
|
598
|
-
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.",
|
|
599
631
|
failsRun: true
|
|
600
632
|
};
|
|
601
633
|
}
|
|
@@ -622,7 +654,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
622
654
|
failsRun: true
|
|
623
655
|
};
|
|
624
656
|
}
|
|
625
|
-
const tests =
|
|
657
|
+
const tests = productionModule.tests;
|
|
626
658
|
const audit = auditReactPlaythroughRun(tests);
|
|
627
659
|
if (!audit.passed) {
|
|
628
660
|
const cause = productionModule.primaryError ?? productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
|