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
|
@@ -15,6 +15,13 @@ import { test } from "vitest";
|
|
|
15
15
|
|
|
16
16
|
// src/react/react-playthrough-core.ts
|
|
17
17
|
import { act } from "@testing-library/react";
|
|
18
|
+
function throwIfAborted(signal) {
|
|
19
|
+
if (!signal?.aborted) return;
|
|
20
|
+
if (signal.reason instanceof Error) throw signal.reason;
|
|
21
|
+
throw new Error("Playthrough advancement was cancelled.", {
|
|
22
|
+
cause: signal.reason
|
|
23
|
+
});
|
|
24
|
+
}
|
|
18
25
|
function formatDiagnostics(read) {
|
|
19
26
|
if (!read) return void 0;
|
|
20
27
|
try {
|
|
@@ -43,11 +50,13 @@ async function runBoundedUntil(condition, options = {}) {
|
|
|
43
50
|
);
|
|
44
51
|
}
|
|
45
52
|
for (let step = 0; step <= maxSteps; step += 1) {
|
|
53
|
+
throwIfAborted(options.signal);
|
|
46
54
|
if (condition()) return step;
|
|
47
55
|
if (step < maxSteps) {
|
|
48
56
|
await act(async () => {
|
|
49
57
|
await options.step?.(step + 1);
|
|
50
58
|
});
|
|
59
|
+
throwIfAborted(options.signal);
|
|
51
60
|
}
|
|
52
61
|
}
|
|
53
62
|
const diagnostics = formatDiagnostics(options.diagnostics);
|
|
@@ -68,13 +77,35 @@ var INPUT_EVENTS = [
|
|
|
68
77
|
"touchstart",
|
|
69
78
|
"touchend"
|
|
70
79
|
];
|
|
71
|
-
var
|
|
72
|
-
var
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
var MIN_STAGES = 5;
|
|
81
|
+
var MIN_MILESTONES = 3;
|
|
82
|
+
var MAX_TRACE_VALUE_LENGTH = 140;
|
|
83
|
+
var MAX_TRACE_LENGTH = 720;
|
|
84
|
+
var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
|
|
85
|
+
function truncateTraceValue(value, limit) {
|
|
86
|
+
const compact = value.replace(/\s+/g, " ").trim();
|
|
87
|
+
if (compact.length <= limit) return compact;
|
|
88
|
+
return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
89
|
+
}
|
|
90
|
+
function formatReactPlaythroughFailureTrace(trace) {
|
|
91
|
+
const stages = trace.stages.map(
|
|
92
|
+
(stage) => `${stage.kind}:${stage.name}=${truncateTraceValue(stage.state, MAX_TRACE_VALUE_LENGTH)}`
|
|
93
|
+
);
|
|
94
|
+
if (trace.current) {
|
|
95
|
+
stages.push(
|
|
96
|
+
`current:${trace.current.name}=${truncateTraceValue(trace.current.before, 70)}\u2192${truncateTraceValue(trace.current.last, 70)}`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const step = trace.step ? `stepUntil=${trace.step.completed ?? "failed"}/${trace.step.bound}` : void 0;
|
|
100
|
+
const formatted = `${stages.length > 0 ? stages.join(" -> ") : "stages=none"}${step ? `; ${step}` : ""}`;
|
|
101
|
+
return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
|
|
76
102
|
}
|
|
77
|
-
|
|
103
|
+
var MAX_FORMATTED_STATE_LENGTH = 500;
|
|
104
|
+
function formatState(fingerprint) {
|
|
105
|
+
if (fingerprint.length <= MAX_FORMATTED_STATE_LENGTH) return fingerprint;
|
|
106
|
+
return `${fingerprint.slice(0, MAX_FORMATTED_STATE_LENGTH)}\u2026 (${fingerprint.length} chars)`;
|
|
107
|
+
}
|
|
108
|
+
function sampleObservedState(observe, stage) {
|
|
78
109
|
let value;
|
|
79
110
|
try {
|
|
80
111
|
value = observe();
|
|
@@ -84,222 +115,272 @@ function sampleObservation(observe, stage) {
|
|
|
84
115
|
try {
|
|
85
116
|
const fingerprint = JSON.stringify(value);
|
|
86
117
|
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
87
|
-
return { fingerprint, formatted:
|
|
118
|
+
return { fingerprint, formatted: formatState(fingerprint) };
|
|
88
119
|
} catch {
|
|
89
120
|
throw new Error(
|
|
90
121
|
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
91
122
|
);
|
|
92
123
|
}
|
|
93
124
|
}
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
]
|
|
125
|
+
function sampleDomState(view) {
|
|
126
|
+
const fingerprint = view.container.innerHTML.replace(/\s+/g, " ").trim();
|
|
127
|
+
return { fingerprint, formatted: formatState(JSON.stringify(fingerprint)) };
|
|
128
|
+
}
|
|
129
|
+
function createEvidence() {
|
|
130
|
+
return { domInputEvents: 0, stages: [], verified: false };
|
|
131
|
+
}
|
|
132
|
+
function createMetadata(waiverReason) {
|
|
133
|
+
return { version: 4, waiverReason, evidence: createEvidence() };
|
|
134
|
+
}
|
|
135
|
+
function stageLabel(kind, name) {
|
|
136
|
+
return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
|
|
100
137
|
}
|
|
101
138
|
function describeMissingEvidence(evidence) {
|
|
102
|
-
if (!evidence || evidence.
|
|
103
|
-
if (evidence.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
139
|
+
if (!evidence || evidence.stages.length === 0) return "an enter stage";
|
|
140
|
+
if (evidence.stages[0]?.kind !== "entered") return "the entered stage";
|
|
141
|
+
const milestones = evidence.stages.filter(
|
|
142
|
+
(stage) => stage.kind === "milestone"
|
|
143
|
+
);
|
|
144
|
+
if (milestones.length < MIN_MILESTONES) {
|
|
145
|
+
return `at least ${MIN_MILESTONES} gameplay milestones`;
|
|
146
|
+
}
|
|
147
|
+
const finalStage = evidence.stages.at(-1);
|
|
148
|
+
if (finalStage?.kind !== "progress" && finalStage?.kind !== "terminal") {
|
|
149
|
+
return "a progress or terminal finish stage";
|
|
150
|
+
}
|
|
151
|
+
if (evidence.stages.length < MIN_STAGES) {
|
|
152
|
+
return `at least ${MIN_STAGES} evidenced stages`;
|
|
114
153
|
}
|
|
115
154
|
return "a complete playthrough verification marker";
|
|
116
155
|
}
|
|
117
|
-
function createMetadata(waiverReason) {
|
|
118
|
-
return {
|
|
119
|
-
version: 3,
|
|
120
|
-
waiverReason,
|
|
121
|
-
evidence: {
|
|
122
|
-
domInputEvents: 0,
|
|
123
|
-
entryInputs: 0,
|
|
124
|
-
primaryInputs: 0,
|
|
125
|
-
boundedRuns: 0,
|
|
126
|
-
assertionsAfterOutcome: 0,
|
|
127
|
-
checkpoints: [],
|
|
128
|
-
verified: false
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
156
|
function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
133
157
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
134
158
|
const metadata = createMetadata(reason);
|
|
135
|
-
test(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
test(
|
|
160
|
+
"production game completes a bounded playthrough",
|
|
161
|
+
{
|
|
162
|
+
concurrent: false,
|
|
163
|
+
skip: Boolean(reason),
|
|
164
|
+
meta: { reactPlaythrough: metadata }
|
|
165
|
+
},
|
|
166
|
+
async ({ annotate, expect, onTestFailed, signal }) => {
|
|
167
|
+
metadata.evidence = createEvidence();
|
|
168
|
+
metadata.trace = void 0;
|
|
169
|
+
const evidence = metadata.evidence;
|
|
170
|
+
let entered = false;
|
|
171
|
+
let finished = false;
|
|
172
|
+
let activeStage;
|
|
173
|
+
let lastSample;
|
|
174
|
+
let stepTrace;
|
|
175
|
+
let failureTraceFactory;
|
|
176
|
+
let acceptingStageInput = false;
|
|
177
|
+
let inputCaptureAttached = false;
|
|
178
|
+
const recordInput = () => {
|
|
179
|
+
if (acceptingStageInput) evidence.domInputEvents += 1;
|
|
180
|
+
};
|
|
181
|
+
const stopInputCapture = () => {
|
|
182
|
+
acceptingStageInput = false;
|
|
183
|
+
if (!inputCaptureAttached) return;
|
|
184
|
+
inputCaptureAttached = false;
|
|
185
|
+
for (const event of INPUT_EVENTS) {
|
|
186
|
+
document.removeEventListener(event, recordInput, true);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const captureFailureTrace = () => {
|
|
190
|
+
try {
|
|
191
|
+
return failureTraceFactory?.() ?? "stages=none";
|
|
192
|
+
} catch {
|
|
193
|
+
return "stages=none";
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
onTestFailed(() => {
|
|
197
|
+
metadata.trace ??= captureFailureTrace();
|
|
198
|
+
});
|
|
199
|
+
for (const event of INPUT_EVENTS) {
|
|
200
|
+
document.addEventListener(event, recordInput, true);
|
|
157
201
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
202
|
+
inputCaptureAttached = true;
|
|
203
|
+
signal.addEventListener("abort", stopInputCapture, { once: true });
|
|
204
|
+
try {
|
|
205
|
+
const view = render(element);
|
|
206
|
+
if (view.container.childNodes.length === 0) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
"playthroughTest must render the production game entry."
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const user = userEvent.setup();
|
|
212
|
+
const sampleState = (label) => playthroughOptions?.observe ? sampleObservedState(playthroughOptions.observe, label) : sampleDomState(view);
|
|
213
|
+
lastSample = sampleState("initial render");
|
|
214
|
+
failureTraceFactory = () => formatReactPlaythroughFailureTrace({
|
|
215
|
+
stages: evidence.stages.map((stage) => ({
|
|
216
|
+
name: stage.name,
|
|
217
|
+
kind: stage.kind,
|
|
218
|
+
state: stage.after
|
|
219
|
+
})),
|
|
220
|
+
current: activeStage ? {
|
|
221
|
+
name: stageLabel(activeStage.kind, activeStage.name),
|
|
222
|
+
before: activeStage.before.formatted,
|
|
223
|
+
last: (() => {
|
|
224
|
+
try {
|
|
225
|
+
return sampleState("failure").formatted;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return `<state unavailable: ${String(error)}>`;
|
|
228
|
+
}
|
|
229
|
+
})()
|
|
230
|
+
} : void 0,
|
|
231
|
+
step: stepTrace
|
|
232
|
+
});
|
|
233
|
+
const executeStage = async (name, kind, stage) => {
|
|
234
|
+
const normalizedName = name.trim();
|
|
235
|
+
if (normalizedName.length === 0) {
|
|
236
|
+
throw new Error("playthrough stage names must be non-empty strings.");
|
|
237
|
+
}
|
|
238
|
+
if (evidence.stages.some(
|
|
239
|
+
(completed) => completed.name === normalizedName
|
|
240
|
+
)) {
|
|
165
241
|
throw new Error(
|
|
166
|
-
|
|
242
|
+
`playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
|
|
167
243
|
);
|
|
168
244
|
}
|
|
169
|
-
if (kind === "
|
|
245
|
+
if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
|
|
170
246
|
throw new Error(
|
|
171
|
-
|
|
247
|
+
`milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
|
|
172
248
|
);
|
|
173
249
|
}
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
250
|
+
const before = lastSample ?? sampleState(`before ${normalizedName}`);
|
|
251
|
+
activeStage = { name: normalizedName, kind, before };
|
|
252
|
+
stepTrace = { bound: stage.maxSteps ?? 120 };
|
|
253
|
+
if (stage.until()) {
|
|
177
254
|
throw new Error(
|
|
178
|
-
|
|
255
|
+
`${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.`
|
|
179
256
|
);
|
|
180
257
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
},
|
|
192
|
-
checkpoint(kind) {
|
|
193
|
-
if (kind === "entered") {
|
|
194
|
-
if (enteredRecorded) {
|
|
195
|
-
throw new Error(
|
|
196
|
-
'checkpoint("entered") may only be recorded once, before the primary input.'
|
|
197
|
-
);
|
|
258
|
+
const inputsBefore = evidence.domInputEvents;
|
|
259
|
+
if (stage.act) {
|
|
260
|
+
acceptingStageInput = true;
|
|
261
|
+
try {
|
|
262
|
+
await stage.act();
|
|
263
|
+
} finally {
|
|
264
|
+
acceptingStageInput = false;
|
|
198
265
|
}
|
|
199
|
-
if (evidence.
|
|
266
|
+
if (evidence.domInputEvents === inputsBefore) {
|
|
200
267
|
throw new Error(
|
|
201
|
-
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
enteredRecorded = true;
|
|
205
|
-
if (playthroughOptions?.observe) {
|
|
206
|
-
enteredObservation = sampleObservation(
|
|
207
|
-
playthroughOptions.observe,
|
|
208
|
-
"entered"
|
|
268
|
+
`${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.`
|
|
209
269
|
);
|
|
210
|
-
} else {
|
|
211
|
-
domTextAtEntered = document.body.textContent ?? "";
|
|
212
270
|
}
|
|
213
|
-
evidence.checkpoints.push(kind);
|
|
214
|
-
return;
|
|
215
271
|
}
|
|
216
|
-
|
|
272
|
+
let advancedSteps = 0;
|
|
273
|
+
const stepBound = stage.maxSteps ?? 120;
|
|
274
|
+
stepTrace = { bound: stepBound };
|
|
275
|
+
const steps = await runBoundedUntil(stage.until, {
|
|
276
|
+
maxSteps: stage.maxSteps,
|
|
277
|
+
signal,
|
|
278
|
+
diagnostics: stage.diagnostics ?? playthroughOptions?.observe,
|
|
279
|
+
step: stage.step ? async (step) => {
|
|
280
|
+
advancedSteps += 1;
|
|
281
|
+
await stage.step?.(step);
|
|
282
|
+
} : void 0
|
|
283
|
+
});
|
|
284
|
+
stepTrace = { bound: stepBound, completed: steps };
|
|
285
|
+
if (!stage.act && advancedSteps === 0) {
|
|
217
286
|
throw new Error(
|
|
218
|
-
|
|
287
|
+
`${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
|
|
219
288
|
);
|
|
220
289
|
}
|
|
221
|
-
|
|
290
|
+
const assertionsBefore = expect.getState().assertionCalls;
|
|
291
|
+
await stage.assert({ expect, user, view });
|
|
292
|
+
const assertions = expect.getState().assertionCalls - assertionsBefore;
|
|
293
|
+
if (assertions === 0) {
|
|
222
294
|
throw new Error(
|
|
223
|
-
|
|
295
|
+
`${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
|
|
224
296
|
);
|
|
225
297
|
}
|
|
226
|
-
|
|
298
|
+
const after = sampleState(`after ${normalizedName}`);
|
|
299
|
+
if (after.fingerprint === before.fingerprint) {
|
|
300
|
+
const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
|
|
227
301
|
throw new Error(
|
|
228
|
-
|
|
302
|
+
`${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
|
|
229
303
|
);
|
|
230
304
|
}
|
|
231
|
-
evidence.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
305
|
+
evidence.stages.push({
|
|
306
|
+
name: normalizedName,
|
|
307
|
+
kind,
|
|
308
|
+
domInputEvents: evidence.domInputEvents - inputsBefore,
|
|
309
|
+
advancedSteps,
|
|
310
|
+
assertions,
|
|
311
|
+
stateChanged: true,
|
|
312
|
+
before: before.formatted,
|
|
313
|
+
after: after.formatted
|
|
314
|
+
});
|
|
315
|
+
lastSample = after;
|
|
316
|
+
activeStage = void 0;
|
|
317
|
+
};
|
|
318
|
+
await run({
|
|
319
|
+
view,
|
|
320
|
+
user,
|
|
321
|
+
async enter(stage) {
|
|
322
|
+
if (entered) {
|
|
323
|
+
throw new Error("enter may only be called once.");
|
|
249
324
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
"outcome"
|
|
253
|
-
);
|
|
254
|
-
if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
|
|
255
|
-
throw new Error(
|
|
256
|
-
`The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
|
|
257
|
-
);
|
|
325
|
+
if (evidence.stages.length > 0) {
|
|
326
|
+
throw new Error("enter must be the first playthrough stage.");
|
|
258
327
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
328
|
+
await executeStage("entered", "entered", stage);
|
|
329
|
+
entered = true;
|
|
330
|
+
},
|
|
331
|
+
async milestone(name, stage) {
|
|
332
|
+
if (!entered) {
|
|
333
|
+
throw new Error("milestone must follow enter.");
|
|
334
|
+
}
|
|
335
|
+
if (finished) {
|
|
336
|
+
throw new Error("milestone cannot run after finish.");
|
|
337
|
+
}
|
|
338
|
+
await executeStage(name, "milestone", stage);
|
|
339
|
+
},
|
|
340
|
+
async finish(name, stage) {
|
|
341
|
+
if (!entered) throw new Error("finish must follow enter.");
|
|
342
|
+
if (finished) {
|
|
343
|
+
throw new Error("finish may only be called once.");
|
|
344
|
+
}
|
|
345
|
+
await executeStage(name, stage.kind, stage);
|
|
346
|
+
finished = true;
|
|
263
347
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
const assertionCalls = expect.getState().assertionCalls;
|
|
270
|
-
evidence.assertionsAfterOutcome = assertionsAtOutcome === void 0 ? 0 : assertionCalls - assertionsAtOutcome;
|
|
271
|
-
if (evidence.entryInputs === 0) {
|
|
272
|
-
throw new Error(
|
|
273
|
-
'playthroughTest must perform an entry input with performInput("entry", ...).'
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
if (evidence.primaryInputs === 0) {
|
|
277
|
-
throw new Error(
|
|
278
|
-
'playthroughTest must perform a core game action with performInput("primary", ...).'
|
|
348
|
+
});
|
|
349
|
+
const milestones = evidence.stages.filter(
|
|
350
|
+
(stage) => stage.kind === "milestone"
|
|
279
351
|
);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
352
|
+
if (!entered) throw new Error("playthroughTest must call enter once.");
|
|
353
|
+
if (milestones.length < MIN_MILESTONES) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (!finished) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
'playthroughTest must call finish with kind "progress" or "terminal".'
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
if (evidence.stages.length < MIN_STAGES) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
evidence.verified = true;
|
|
369
|
+
} catch (error) {
|
|
370
|
+
const trace = captureFailureTrace();
|
|
371
|
+
metadata.trace = trace;
|
|
372
|
+
try {
|
|
373
|
+
await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
|
|
374
|
+
} catch {
|
|
375
|
+
}
|
|
376
|
+
throw error;
|
|
377
|
+
} finally {
|
|
378
|
+
metadata.trace ??= failureTraceFactory?.();
|
|
379
|
+
signal.removeEventListener("abort", stopInputCapture);
|
|
380
|
+
stopInputCapture();
|
|
300
381
|
}
|
|
301
382
|
}
|
|
302
|
-
|
|
383
|
+
);
|
|
303
384
|
}
|
|
304
385
|
var playthroughTest = Object.assign(
|
|
305
386
|
(element, optionsOrRun, maybeRun) => {
|
|
@@ -307,19 +388,29 @@ var playthroughTest = Object.assign(
|
|
|
307
388
|
definePlaythrough(element, optionsOrRun);
|
|
308
389
|
return;
|
|
309
390
|
}
|
|
310
|
-
if (!maybeRun)
|
|
391
|
+
if (!maybeRun) {
|
|
392
|
+
throw new TypeError("playthroughTest requires a run callback.");
|
|
393
|
+
}
|
|
311
394
|
definePlaythrough(element, maybeRun, optionsOrRun);
|
|
312
395
|
},
|
|
313
396
|
{
|
|
314
|
-
skip: (reason, element
|
|
397
|
+
skip: (reason, element) => definePlaythrough(element, async () => {
|
|
398
|
+
}, void 0, reason)
|
|
315
399
|
}
|
|
316
400
|
);
|
|
317
401
|
function auditReactPlaythroughRun(tests) {
|
|
318
402
|
const declared = tests.filter((candidate) => candidate.metadata);
|
|
319
403
|
const valid = declared.filter(({ state, metadata }) => {
|
|
320
404
|
const evidence = metadata?.evidence;
|
|
321
|
-
|
|
322
|
-
|
|
405
|
+
if (!evidence || state !== "passed" || evidence.verified !== true) {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
const milestones = evidence.stages.filter(
|
|
409
|
+
(stage) => stage.kind === "milestone"
|
|
410
|
+
);
|
|
411
|
+
const finalStage = evidence.stages.at(-1);
|
|
412
|
+
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(
|
|
413
|
+
(stage) => stage.assertions > 0 && (stage.domInputEvents > 0 || stage.advancedSteps > 0) && stage.stateChanged === true
|
|
323
414
|
);
|
|
324
415
|
});
|
|
325
416
|
const waivers = declared.filter(
|
|
@@ -328,13 +419,11 @@ function auditReactPlaythroughRun(tests) {
|
|
|
328
419
|
const issues = [];
|
|
329
420
|
if (declared.length === 0) {
|
|
330
421
|
issues.push(
|
|
331
|
-
|
|
422
|
+
"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."
|
|
332
423
|
);
|
|
333
424
|
} else {
|
|
334
425
|
for (const candidate of declared) {
|
|
335
|
-
|
|
336
|
-
const isWaived = waivers.includes(candidate);
|
|
337
|
-
if (isValid || isWaived) continue;
|
|
426
|
+
if (valid.includes(candidate) || waivers.includes(candidate)) continue;
|
|
338
427
|
if (candidate.state === "skipped") {
|
|
339
428
|
issues.push(
|
|
340
429
|
`Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
|
|
@@ -344,9 +433,8 @@ function auditReactPlaythroughRun(tests) {
|
|
|
344
433
|
`Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
|
|
345
434
|
);
|
|
346
435
|
} else {
|
|
347
|
-
const missing = describeMissingEvidence(candidate.metadata?.evidence);
|
|
348
436
|
issues.push(
|
|
349
|
-
`Playthrough ${JSON.stringify(candidate.name)} is missing ${
|
|
437
|
+
`Playthrough ${JSON.stringify(candidate.name)} is missing ${describeMissingEvidence(candidate.metadata?.evidence)}.`
|
|
350
438
|
);
|
|
351
439
|
}
|
|
352
440
|
}
|
|
@@ -362,7 +450,12 @@ var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
|
362
450
|
function isMetadata(value) {
|
|
363
451
|
if (!value || typeof value !== "object") return false;
|
|
364
452
|
const metadata = value;
|
|
365
|
-
|
|
453
|
+
if (metadata.version !== 4) return false;
|
|
454
|
+
const evidence = metadata.evidence;
|
|
455
|
+
if (!evidence || typeof evidence !== "object") return false;
|
|
456
|
+
return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
|
|
457
|
+
(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"
|
|
458
|
+
);
|
|
366
459
|
}
|
|
367
460
|
function toAuditInput(test2) {
|
|
368
461
|
const metadata = test2.meta().reactPlaythrough;
|
|
@@ -384,24 +477,46 @@ function failureHint(value) {
|
|
|
384
477
|
return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
|
|
385
478
|
}
|
|
386
479
|
function errorLocation(value) {
|
|
387
|
-
const match = value.match(
|
|
480
|
+
const match = value.match(
|
|
481
|
+
/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/
|
|
482
|
+
);
|
|
388
483
|
return match?.[1];
|
|
389
484
|
}
|
|
485
|
+
function failureTrace(test2) {
|
|
486
|
+
const annotations = test2.annotations();
|
|
487
|
+
let annotationTrace;
|
|
488
|
+
for (let index = annotations.length - 1; index >= 0; index -= 1) {
|
|
489
|
+
if (annotations[index].type === REACT_PLAYTHROUGH_TRACE_ANNOTATION) {
|
|
490
|
+
annotationTrace = annotations[index].message;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const metadata = test2.meta().reactPlaythrough;
|
|
495
|
+
const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
|
|
496
|
+
return trace ? truncateReporterLine(trace, 720) : void 0;
|
|
497
|
+
}
|
|
498
|
+
function truncateReporterLine(value, limit) {
|
|
499
|
+
const compact = stripVTControlCharacters(value).replace(/\s+/g, " ").trim();
|
|
500
|
+
if (compact.length <= limit) return compact;
|
|
501
|
+
return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
502
|
+
}
|
|
390
503
|
function toModuleResult(module, projectRoot) {
|
|
391
504
|
const tests = [...module.children.allTests()];
|
|
505
|
+
const moduleErrors = module.errors().map((error) => firstLine(error.message)).filter((message) => Boolean(message));
|
|
392
506
|
const errors = [
|
|
393
|
-
...
|
|
507
|
+
...moduleErrors,
|
|
394
508
|
...tests.flatMap(
|
|
395
509
|
(test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
|
|
396
510
|
)
|
|
397
511
|
].filter((message) => Boolean(message));
|
|
398
512
|
const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
|
|
399
|
-
const raw = test2.result().errors?.
|
|
513
|
+
const raw = test2.result().errors?.at(-1)?.message ?? module.errors()[0]?.message ?? "Unknown failure";
|
|
400
514
|
return {
|
|
401
515
|
test: test2.fullName,
|
|
402
516
|
cause: firstLine(raw) ?? "Unknown failure",
|
|
403
517
|
location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
|
|
404
|
-
hint: failureHint(raw)
|
|
518
|
+
hint: failureHint(raw),
|
|
519
|
+
trace: failureTrace(test2)
|
|
405
520
|
};
|
|
406
521
|
});
|
|
407
522
|
if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
|
|
@@ -417,13 +532,17 @@ function toModuleResult(module, projectRoot) {
|
|
|
417
532
|
file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
|
|
418
533
|
state: module.state(),
|
|
419
534
|
errors,
|
|
535
|
+
primaryError: moduleErrors[0] ?? failures[0]?.cause,
|
|
420
536
|
tests: tests.map(toAuditInput),
|
|
421
537
|
failures
|
|
422
538
|
};
|
|
423
539
|
}
|
|
424
|
-
function
|
|
540
|
+
function formatReactFailureSummary(modules) {
|
|
425
541
|
const failures = modules.flatMap(
|
|
426
|
-
(module) => (module.failures ?? []).map((failure) => ({
|
|
542
|
+
(module) => (module.failures ?? []).map((failure) => ({
|
|
543
|
+
...failure,
|
|
544
|
+
file: module.file
|
|
545
|
+
}))
|
|
427
546
|
);
|
|
428
547
|
if (failures.length === 0) return ["TEST_RESULT: PASS"];
|
|
429
548
|
const lines = [`FAILED_TESTS: ${failures.length}`];
|
|
@@ -431,6 +550,7 @@ function formatFailureSummary(modules) {
|
|
|
431
550
|
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
432
551
|
lines.push(`TEST: ${failure.test}`);
|
|
433
552
|
lines.push(`CAUSE: ${failure.cause}`);
|
|
553
|
+
if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
|
|
434
554
|
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
435
555
|
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
436
556
|
}
|
|
@@ -441,24 +561,22 @@ function repairGuidance(cause) {
|
|
|
441
561
|
if (/snapshot\(\) returned the same reference/i.test(cause)) {
|
|
442
562
|
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.";
|
|
443
563
|
}
|
|
444
|
-
if (/
|
|
445
|
-
|
|
446
|
-
)) {
|
|
447
|
-
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.';
|
|
564
|
+
if (/until condition must be false before its driver runs/i.test(cause)) {
|
|
565
|
+
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.";
|
|
448
566
|
}
|
|
449
|
-
if (/
|
|
450
|
-
return "The
|
|
567
|
+
if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
|
|
568
|
+
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.";
|
|
451
569
|
}
|
|
452
570
|
if (/No step callback was provided/i.test(cause)) {
|
|
453
|
-
return "This
|
|
571
|
+
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.";
|
|
454
572
|
}
|
|
455
573
|
if (/outcome was not reached within \d+ steps/i.test(cause)) {
|
|
456
|
-
return "The
|
|
574
|
+
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.";
|
|
457
575
|
}
|
|
458
|
-
if (/
|
|
459
|
-
return
|
|
576
|
+
if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
|
|
577
|
+
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.";
|
|
460
578
|
}
|
|
461
|
-
return "Start from the production entry
|
|
579
|
+
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.";
|
|
462
580
|
}
|
|
463
581
|
function assessReactPlaythroughReport(input) {
|
|
464
582
|
const base = { file: input.expectedFile };
|
|
@@ -475,7 +593,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
475
593
|
...base,
|
|
476
594
|
status: "FAILED",
|
|
477
595
|
cause: "The required production playthrough test file does not exist.",
|
|
478
|
-
next:
|
|
596
|
+
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.",
|
|
479
597
|
failsRun: true
|
|
480
598
|
};
|
|
481
599
|
}
|
|
@@ -502,10 +620,10 @@ function assessReactPlaythroughReport(input) {
|
|
|
502
620
|
failsRun: true
|
|
503
621
|
};
|
|
504
622
|
}
|
|
505
|
-
const tests =
|
|
623
|
+
const tests = productionModule.tests;
|
|
506
624
|
const audit = auditReactPlaythroughRun(tests);
|
|
507
625
|
if (!audit.passed) {
|
|
508
|
-
const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
|
|
626
|
+
const cause = productionModule.primaryError ?? productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
|
|
509
627
|
return {
|
|
510
628
|
...base,
|
|
511
629
|
status: "FAILED",
|
|
@@ -575,7 +693,7 @@ var ReactPlaythroughReporter = class {
|
|
|
575
693
|
} else {
|
|
576
694
|
console.log(output);
|
|
577
695
|
}
|
|
578
|
-
const summary =
|
|
696
|
+
const summary = formatReactFailureSummary(
|
|
579
697
|
testModules.map((module) => toModuleResult(module, this.projectRoot))
|
|
580
698
|
);
|
|
581
699
|
if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
|