@mpgd/cli 0.9.0 → 0.10.1

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.
@@ -0,0 +1,684 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, existsSync, lstatSync, mkdirSync, opendirSync, openSync, readFileSync, readlinkSync, readSync, rmSync, writeFileSync, } from 'node:fs';
3
+ import path from 'node:path';
4
+ export const defaultGameplayE2EReportFile = 'artifacts/gameplay-e2e/gameplay-e2e-report.json';
5
+ const gameplayStateIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
6
+ const gameplayKeyControlCharacterPattern = /[\u0000-\u001f\u007f]/u;
7
+ export const maximumGameplayE2EStates = 50;
8
+ const maximumActionsPerState = 100;
9
+ const maximumWaitMs = 60000;
10
+ const maximumBackgroundMs = 5 * 60000;
11
+ const maximumHashReadChunkBytes = 64 * 1024;
12
+ const defaultGameplayE2EHashLimits = {
13
+ maximumDepth: 128,
14
+ maximumEntries: 100000,
15
+ maximumTotalFileBytes: 4 * 1024 * 1024 * 1024,
16
+ };
17
+ class GameplayE2EAggregatedError extends Error {
18
+ errors;
19
+ constructor(message, errors) {
20
+ super(message);
21
+ this.name = 'GameplayE2EAggregatedError';
22
+ this.errors = errors;
23
+ }
24
+ }
25
+ export function resolveGameplayE2EReportFile(gameRoot, env = process.env) {
26
+ const configuredFile = env.MPGD_GAMEPLAY_E2E_REPORT_FILE;
27
+ const resolved = resolveGameplayE2EPathInsideGameRoot(gameRoot, configuredFile === undefined || configuredFile.length === 0
28
+ ? defaultGameplayE2EReportFile
29
+ : configuredFile, 'Gameplay E2E report');
30
+ assertGameplayE2EJsonReportFile(resolved);
31
+ return resolved;
32
+ }
33
+ export function readGameplayE2EPlan(gameRoot, configFile = 'mpgd.game.json') {
34
+ const resolvedRoot = path.resolve(gameRoot);
35
+ const resolvedFile = path.resolve(resolvedRoot, configFile);
36
+ if (!existsSync(resolvedFile)) {
37
+ throw new Error(`Missing gameplay config: ${resolvedFile}`);
38
+ }
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(readFileSync(resolvedFile, 'utf8'));
42
+ }
43
+ catch (error) {
44
+ throw new Error(`Invalid gameplay config JSON at ${resolvedFile}: ${formatError(error)}`);
45
+ }
46
+ assertRecord(parsed, resolvedFile);
47
+ const acceptance = parsed.acceptance;
48
+ if (acceptance === undefined) {
49
+ return null;
50
+ }
51
+ assertRecord(acceptance, `${resolvedFile} acceptance`);
52
+ if (acceptance.gameplay === undefined) {
53
+ return null;
54
+ }
55
+ return {
56
+ file: resolvedFile,
57
+ plan: parseGameplayE2EPlan(acceptance.gameplay, `${resolvedFile} acceptance.gameplay`),
58
+ };
59
+ }
60
+ export function parseGameplayE2EPlan(value, source = 'gameplay E2E plan') {
61
+ assertRecord(value, source);
62
+ assertOnlyKeys(value, ['schemaVersion', 'states'], source);
63
+ if (value.schemaVersion !== 1) {
64
+ throw new Error(`${source}.schemaVersion must be 1.`);
65
+ }
66
+ if (!Array.isArray(value.states)) {
67
+ throw new Error(`${source}.states must be an array.`);
68
+ }
69
+ if (value.states.length === 0 || value.states.length > maximumGameplayE2EStates) {
70
+ throw new Error(`${source}.states must contain between 1 and ${maximumGameplayE2EStates} states.`);
71
+ }
72
+ const stateIds = new Set();
73
+ const states = value.states.map((state, index) => parseGameplayE2EState(state, `${source}.states[${index}]`, stateIds));
74
+ return { schemaVersion: 1, states };
75
+ }
76
+ export async function runGameplayE2E(input) {
77
+ const now = input.now ?? Date.now;
78
+ const log = input.log ?? console.log;
79
+ const gameRoot = path.resolve(input.gameRoot);
80
+ const requestedReportFile = input.reportFile === undefined
81
+ ? path.join(input.reportDir, 'gameplay-e2e-report.json')
82
+ : input.reportFile;
83
+ const jsonFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, requestedReportFile, 'Gameplay E2E report');
84
+ assertGameplayE2EJsonReportFile(jsonFile);
85
+ const markdownFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, replaceFileExtension(jsonFile, '.md'), 'Gameplay E2E Markdown report');
86
+ const screenshotsDir = resolveGameplayE2EPathInsideGameRoot(gameRoot, path.join(path.dirname(jsonFile), 'screenshots'), 'Gameplay E2E screenshots directory');
87
+ const startedAtMs = now();
88
+ const plan = parseGameplayE2EPlan(input.plan);
89
+ const planLabel = 'gameplay E2E plan';
90
+ const planEvidence = collectGameplayE2EPathEvidence(gameRoot, input.planFile, planLabel);
91
+ assertPathEvidenceKind(planEvidence, ['file'], planLabel);
92
+ const configuredPlan = readGameplayE2EPlan(gameRoot, planEvidence.file);
93
+ if (configuredPlan === null) {
94
+ throw new Error('Gameplay E2E plan file must define acceptance.gameplay.');
95
+ }
96
+ if (JSON.stringify(configuredPlan.plan) !== JSON.stringify(plan)) {
97
+ throw new Error('Gameplay E2E plan must match the linked manifest plan file.');
98
+ }
99
+ const artifact = collectGameplayE2EPathEvidence(gameRoot, input.artifactFile, 'target artifact');
100
+ const releaseManifest = input.releaseManifestFile === undefined
101
+ ? null
102
+ : collectGameplayE2EPathEvidence(gameRoot, input.releaseManifestFile, 'release manifest');
103
+ const states = [];
104
+ let failed = false;
105
+ assertPathEvidenceKind(artifact, ['directory', 'file'], 'target artifact');
106
+ if (releaseManifest !== null) {
107
+ assertPathEvidenceKind(releaseManifest, ['file'], 'release manifest');
108
+ }
109
+ mkdirSync(screenshotsDir, { recursive: true });
110
+ for (const state of plan.states) {
111
+ if (failed) {
112
+ states.push(skippedStateResult(state, now()));
113
+ continue;
114
+ }
115
+ log(`[mpgd:gameplay-e2e] ${state.label}`);
116
+ const result = await runGameplayState({
117
+ state,
118
+ driver: input.driver,
119
+ gameRoot,
120
+ screenshotsDir,
121
+ now,
122
+ });
123
+ states.push(result);
124
+ failed = result.status === 'failed';
125
+ }
126
+ const finishedAtMs = now();
127
+ const report = {
128
+ schemaVersion: 1,
129
+ generatedAt: new Date(finishedAtMs).toISOString(),
130
+ status: failed ? 'failed' : 'passed',
131
+ gameRoot,
132
+ target: readNonEmptyString(input.target, 'target'),
133
+ profile: readNonEmptyString(input.profile, 'profile'),
134
+ durationMs: Math.max(0, finishedAtMs - startedAtMs),
135
+ plan: planEvidence,
136
+ artifact,
137
+ releaseManifest,
138
+ states,
139
+ };
140
+ // Driver callbacks are untrusted and may have changed output paths while the run was active.
141
+ const verifiedJsonFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, jsonFile, 'Gameplay E2E report');
142
+ const verifiedMarkdownFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, markdownFile, 'Gameplay E2E Markdown report');
143
+ writeFileSync(verifiedJsonFile, `${JSON.stringify(report, null, 2)}\n`);
144
+ writeFileSync(verifiedMarkdownFile, renderGameplayE2EMarkdown(report));
145
+ return { report, jsonFile: verifiedJsonFile, markdownFile: verifiedMarkdownFile };
146
+ }
147
+ export function renderGameplayE2EMarkdown(report) {
148
+ const lines = [
149
+ '# Gameplay E2E Report',
150
+ '',
151
+ `Status: ${report.status}`,
152
+ `Generated: ${report.generatedAt}`,
153
+ `Target: ${escapeMarkdownInline(report.target)}`,
154
+ `Profile: ${escapeMarkdownInline(report.profile)}`,
155
+ `Artifact: ${escapeMarkdownInline(report.artifact.file)} (${report.artifact.sha256})`,
156
+ report.releaseManifest === null
157
+ ? 'Release manifest: not linked'
158
+ : `Release manifest: ${escapeMarkdownInline(report.releaseManifest.file)} (${report.releaseManifest.sha256})`,
159
+ `Duration: ${formatDuration(report.durationMs)}`,
160
+ '',
161
+ '## States',
162
+ '',
163
+ '| State | Status | Duration | Screenshot | Detail |',
164
+ '| --- | --- | ---: | --- | --- |',
165
+ ...report.states.map((state) => [
166
+ escapeMarkdownTable(state.label),
167
+ state.status,
168
+ formatDuration(state.durationMs),
169
+ escapeMarkdownTable(state.screenshot?.file ?? ''),
170
+ escapeMarkdownTable(gameplayStateDetail(state)),
171
+ ].join(' | ')).map((row) => `| ${row} |`),
172
+ '',
173
+ ];
174
+ return `${lines.join('\n')}\n`;
175
+ }
176
+ function gameplayStateDetail(state) {
177
+ return [
178
+ state.detail,
179
+ ...state.actions.map((action) => action.detail),
180
+ state.observation?.detail,
181
+ ].filter((detail) => detail !== null && detail !== undefined && detail.length > 0).join('; ');
182
+ }
183
+ async function runGameplayState(input) {
184
+ const startedAtMs = input.now();
185
+ const startedAt = new Date(startedAtMs).toISOString();
186
+ const actions = [];
187
+ let detail = null;
188
+ let observation = null;
189
+ let screenshot = null;
190
+ try {
191
+ const before = validateObservation(await input.driver.inspect({ state: input.state, phase: 'before' }), `${input.state.id} before observation`);
192
+ if (!before.passed) {
193
+ throw new Error(before.detail ?? `State ${input.state.id} was not ready.`);
194
+ }
195
+ for (const action of input.state.actions) {
196
+ const actionStartedAtMs = input.now();
197
+ const actionStartedAt = new Date(actionStartedAtMs).toISOString();
198
+ try {
199
+ const actionDetail = action.type === 'pause-resume'
200
+ ? await runPauseResumeAction(input.driver, input.state, action)
201
+ : await runInputAction(input.driver, action);
202
+ actions.push({
203
+ action,
204
+ status: 'passed',
205
+ startedAt: actionStartedAt,
206
+ durationMs: Math.max(0, input.now() - actionStartedAtMs),
207
+ detail: actionDetail,
208
+ });
209
+ }
210
+ catch (error) {
211
+ detail = formatError(error);
212
+ actions.push({
213
+ action,
214
+ status: 'failed',
215
+ startedAt: actionStartedAt,
216
+ durationMs: Math.max(0, input.now() - actionStartedAtMs),
217
+ detail,
218
+ });
219
+ throw error;
220
+ }
221
+ }
222
+ observation = validateObservation(await input.driver.inspect({ state: input.state, phase: 'after' }), `${input.state.id} after observation`);
223
+ if (!observation.passed) {
224
+ throw new Error(observation.detail ?? `State ${input.state.id} expectation failed.`);
225
+ }
226
+ }
227
+ catch (error) {
228
+ detail ??= formatError(error);
229
+ }
230
+ try {
231
+ const screenshotFile = resolveGameplayE2EPathInsideGameRoot(input.gameRoot, path.join(input.screenshotsDir, `${input.state.id}.png`), 'Gameplay screenshot');
232
+ rmSync(screenshotFile, { force: true });
233
+ await input.driver.captureScreenshot({ state: input.state, file: screenshotFile });
234
+ screenshot = collectGameplayE2EPathEvidence(input.gameRoot, screenshotFile, 'gameplay screenshot');
235
+ assertPathEvidenceKind(screenshot, ['file'], 'gameplay screenshot');
236
+ }
237
+ catch (error) {
238
+ const screenshotError = `Screenshot failed: ${formatError(error)}`;
239
+ detail = detail === null ? screenshotError : `${detail} ${screenshotError}`;
240
+ }
241
+ const status = detail === null ? 'passed' : 'failed';
242
+ return {
243
+ id: input.state.id,
244
+ label: input.state.label,
245
+ expectation: input.state.expectation ?? null,
246
+ status,
247
+ startedAt,
248
+ durationMs: Math.max(0, input.now() - startedAtMs),
249
+ detail,
250
+ actions,
251
+ observation,
252
+ screenshot,
253
+ };
254
+ }
255
+ async function runInputAction(driver, action) {
256
+ await driver.perform(action);
257
+ return null;
258
+ }
259
+ async function runPauseResumeAction(driver, state, action) {
260
+ const expectSameSession = action.expectSameSession !== false;
261
+ const before = validateObservation(await driver.inspect({ state, phase: 'before' }), `${state.id} pre-pause observation`);
262
+ if (!before.passed) {
263
+ throw new Error(before.detail ?? `State ${state.id} was unhealthy before pause.`);
264
+ }
265
+ await driver.pause();
266
+ let primaryError;
267
+ try {
268
+ if (action.backgroundMs > 0) {
269
+ await driver.perform({ type: 'wait', durationMs: action.backgroundMs });
270
+ }
271
+ }
272
+ catch (error) {
273
+ primaryError = error;
274
+ }
275
+ try {
276
+ await driver.resume();
277
+ }
278
+ catch (error) {
279
+ if (primaryError === undefined) {
280
+ primaryError = error;
281
+ }
282
+ else {
283
+ primaryError = new GameplayE2EAggregatedError('Gameplay background wait and resume both failed.', [primaryError, error]);
284
+ }
285
+ }
286
+ if (primaryError !== undefined) {
287
+ throw primaryError;
288
+ }
289
+ const resumed = validateObservation(await driver.inspect({ state, phase: 'resumed' }), `${state.id} resumed observation`);
290
+ if (!resumed.passed) {
291
+ throw new Error(resumed.detail ?? `State ${state.id} was unhealthy after resume.`);
292
+ }
293
+ if (expectSameSession
294
+ && (before.sessionId === null || resumed.sessionId === null || before.sessionId !== resumed.sessionId)) {
295
+ throw new Error(`State ${state.id} did not preserve its session across pause and resume.`);
296
+ }
297
+ return expectSameSession
298
+ ? `Session preserved across pause and resume: ${before.sessionId}`
299
+ : null;
300
+ }
301
+ function skippedStateResult(state, nowMs) {
302
+ return {
303
+ id: state.id,
304
+ label: state.label,
305
+ expectation: state.expectation ?? null,
306
+ status: 'skipped',
307
+ startedAt: new Date(nowMs).toISOString(),
308
+ durationMs: 0,
309
+ detail: 'A previous gameplay state failed.',
310
+ actions: state.actions.map((action) => ({
311
+ action,
312
+ status: 'skipped',
313
+ startedAt: new Date(nowMs).toISOString(),
314
+ durationMs: 0,
315
+ detail: 'A previous gameplay state failed.',
316
+ })),
317
+ observation: null,
318
+ screenshot: null,
319
+ };
320
+ }
321
+ function parseGameplayE2EState(value, source, stateIds) {
322
+ assertRecord(value, source);
323
+ assertOnlyKeys(value, ['id', 'label', 'expectation', 'actions'], source);
324
+ const id = readNonEmptyString(value.id, `${source}.id`);
325
+ if (!gameplayStateIdPattern.test(id)) {
326
+ throw new Error(`${source}.id must use lowercase kebab-case.`);
327
+ }
328
+ if (stateIds.has(id)) {
329
+ throw new Error(`${source}.id is duplicated: ${id}`);
330
+ }
331
+ stateIds.add(id);
332
+ const label = readNonEmptyString(value.label, `${source}.label`);
333
+ const expectation = value.expectation === undefined
334
+ ? undefined
335
+ : readNonEmptyString(value.expectation, `${source}.expectation`);
336
+ if (!Array.isArray(value.actions)) {
337
+ throw new Error(`${source}.actions must be an array.`);
338
+ }
339
+ if (value.actions.length > maximumActionsPerState) {
340
+ throw new Error(`${source}.actions cannot contain more than ${maximumActionsPerState} actions.`);
341
+ }
342
+ const actions = value.actions.map((action, index) => parseGameplayE2EAction(action, `${source}.actions[${index}]`));
343
+ return {
344
+ id,
345
+ label,
346
+ ...(expectation === undefined ? {} : { expectation }),
347
+ actions,
348
+ };
349
+ }
350
+ function parseGameplayE2EAction(value, source) {
351
+ assertRecord(value, source);
352
+ const type = readNonEmptyString(value.type, `${source}.type`);
353
+ switch (type) {
354
+ case 'tap': {
355
+ assertOnlyKeys(value, ['type', 'x', 'y'], source);
356
+ return {
357
+ type,
358
+ x: readBoundedNumber(value.x, `${source}.x`, 0, 1),
359
+ y: readBoundedNumber(value.y, `${source}.y`, 0, 1),
360
+ };
361
+ }
362
+ case 'key': {
363
+ assertOnlyKeys(value, ['type', 'key'], source);
364
+ const key = readNonEmptyString(value.key, `${source}.key`);
365
+ if (key.length > 64) {
366
+ throw new Error(`${source}.key cannot exceed 64 characters.`);
367
+ }
368
+ if (gameplayKeyControlCharacterPattern.test(key)) {
369
+ throw new Error(`${source}.key cannot contain control characters.`);
370
+ }
371
+ return { type, key };
372
+ }
373
+ case 'wait': {
374
+ assertOnlyKeys(value, ['type', 'durationMs'], source);
375
+ return {
376
+ type,
377
+ durationMs: readBoundedInteger(value.durationMs, `${source}.durationMs`, 0, maximumWaitMs),
378
+ };
379
+ }
380
+ case 'pause-resume': {
381
+ assertOnlyKeys(value, ['type', 'backgroundMs', 'expectSameSession'], source);
382
+ const expectSameSession = value.expectSameSession ?? true;
383
+ if (typeof expectSameSession !== 'boolean') {
384
+ throw new Error(`${source}.expectSameSession must be a boolean.`);
385
+ }
386
+ return {
387
+ type,
388
+ backgroundMs: readBoundedInteger(value.backgroundMs, `${source}.backgroundMs`, 0, maximumBackgroundMs),
389
+ expectSameSession,
390
+ };
391
+ }
392
+ default:
393
+ throw new Error(`${source}.type is unsupported: ${type}`);
394
+ }
395
+ }
396
+ function validateObservation(value, source) {
397
+ assertRecord(value, source);
398
+ assertOnlyKeys(value, ['passed', 'sessionId', 'detail', 'metadata'], source);
399
+ if (typeof value.passed !== 'boolean') {
400
+ throw new Error(`${source}.passed must be a boolean.`);
401
+ }
402
+ if (value.sessionId !== null && typeof value.sessionId !== 'string') {
403
+ throw new Error(`${source}.sessionId must be a string or null.`);
404
+ }
405
+ if (typeof value.sessionId === 'string' && value.sessionId.length === 0) {
406
+ throw new Error(`${source}.sessionId cannot be empty.`);
407
+ }
408
+ const detail = value.detail === undefined
409
+ ? undefined
410
+ : readNonEmptyString(value.detail, `${source}.detail`);
411
+ const metadata = value.metadata === undefined
412
+ ? undefined
413
+ : validateObservationMetadata(value.metadata, `${source}.metadata`);
414
+ return {
415
+ passed: value.passed,
416
+ sessionId: value.sessionId,
417
+ ...(detail === undefined ? {} : { detail }),
418
+ ...(metadata === undefined ? {} : { metadata }),
419
+ };
420
+ }
421
+ function validateObservationMetadata(value, source) {
422
+ assertRecord(value, source);
423
+ const result = {};
424
+ for (const [key, entry] of Object.entries(value)) {
425
+ if (entry !== null
426
+ && typeof entry !== 'string'
427
+ && typeof entry !== 'number'
428
+ && typeof entry !== 'boolean') {
429
+ throw new Error(`${source}.${key} must be a JSON scalar.`);
430
+ }
431
+ if (typeof entry === 'number' && !Number.isFinite(entry)) {
432
+ throw new Error(`${source}.${key} must be finite.`);
433
+ }
434
+ result[key] = entry;
435
+ }
436
+ return result;
437
+ }
438
+ export function collectGameplayE2EPathEvidence(gameRoot, file, label, limits = defaultGameplayE2EHashLimits) {
439
+ const resolved = resolveGameplayE2EPathInsideGameRoot(gameRoot, file, label, true);
440
+ let stats;
441
+ try {
442
+ stats = lstatSync(resolved);
443
+ }
444
+ catch (error) {
445
+ throw new Error(`Unable to inspect ${label} at ${resolved}: ${formatError(error)}`);
446
+ }
447
+ const kind = stats.isSymbolicLink()
448
+ ? 'symbolic-link'
449
+ : stats.isDirectory()
450
+ ? 'directory'
451
+ : stats.isFile()
452
+ ? 'file'
453
+ : undefined;
454
+ if (kind === undefined) {
455
+ throw new Error(`Unsupported ${label} path type: ${resolved}`);
456
+ }
457
+ return {
458
+ file: relativeOrAbsolute(gameRoot, resolved),
459
+ kind,
460
+ sha256: hashPath(resolved, validateHashLimits(limits)),
461
+ };
462
+ }
463
+ export function resolveGameplayE2EPathInsideGameRoot(gameRoot, file, label, allowSymbolicLinkLeaf = false) {
464
+ const resolvedRoot = path.resolve(gameRoot);
465
+ const resolved = path.resolve(resolvedRoot, file);
466
+ const relative = path.relative(resolvedRoot, resolved);
467
+ if (pathEscapesRoot(relative)) {
468
+ // Keep host paths out of validation errors when untrusted input escapes the game root.
469
+ throw new Error(`${label} path must stay inside the game root.`);
470
+ }
471
+ if (resolved === resolvedRoot) {
472
+ return resolved;
473
+ }
474
+ const segments = relative.split(path.sep).filter((entry) => entry.length > 0);
475
+ let current = resolvedRoot;
476
+ for (const [index, segment] of segments.entries()) {
477
+ current = path.join(current, segment);
478
+ let stats;
479
+ try {
480
+ stats = lstatSync(current);
481
+ }
482
+ catch (error) {
483
+ if (hasErrorCode(error, 'ENOENT')) {
484
+ break;
485
+ }
486
+ const detail = `Unable to inspect ${label} path at ${current}: ${formatError(error)}`;
487
+ throw new Error(detail);
488
+ }
489
+ const isLeaf = index === segments.length - 1;
490
+ if (stats.isSymbolicLink() && (!isLeaf || !allowSymbolicLinkLeaf)) {
491
+ const displayPath = path.relative(resolvedRoot, current) || '.';
492
+ throw new Error(isLeaf
493
+ ? `${label} path must not be a symbolic link: ${displayPath}`
494
+ : `${label} path must not cross symbolic-link ancestors: ${displayPath}`);
495
+ }
496
+ if (!isLeaf && !stats.isDirectory()) {
497
+ throw new Error(`${label} parent path must be a directory.`);
498
+ }
499
+ }
500
+ return resolved;
501
+ }
502
+ function hasErrorCode(error, code) {
503
+ return typeof error === 'object'
504
+ && error !== null
505
+ && 'code' in error
506
+ && error.code === code;
507
+ }
508
+ function pathEscapesRoot(relative) {
509
+ return relative === '..'
510
+ || relative.startsWith(`..${path.sep}`)
511
+ || path.isAbsolute(relative);
512
+ }
513
+ function assertPathEvidenceKind(evidence, allowedKinds, label) {
514
+ if (!allowedKinds.includes(evidence.kind)) {
515
+ throw new Error(`Unsupported ${label} path kind: ${evidence.kind}`);
516
+ }
517
+ }
518
+ function hashPath(file, limits) {
519
+ const hash = createHash('sha256');
520
+ const root = path.dirname(file);
521
+ const state = {
522
+ entryCount: 0,
523
+ totalFileBytes: 0,
524
+ limits,
525
+ };
526
+ appendPathToHash(hash, root, file, state, 0);
527
+ return hash.digest('hex');
528
+ }
529
+ function appendPathToHash(hash, root, file, state, depth) {
530
+ if (depth > state.limits.maximumDepth) {
531
+ throw new Error(`Gameplay evidence path exceeds maximum hash depth ${state.limits.maximumDepth}: ${file}`);
532
+ }
533
+ state.entryCount += 1;
534
+ if (state.entryCount > state.limits.maximumEntries) {
535
+ throwMaximumHashEntries(state.limits.maximumEntries);
536
+ }
537
+ const stats = lstatSync(file);
538
+ const relative = path.relative(root, file).replaceAll(path.sep, '/');
539
+ if (stats.isSymbolicLink()) {
540
+ hash.update(`L\0${relative}\0${readlinkSync(file)}\0`);
541
+ return;
542
+ }
543
+ if (stats.isDirectory()) {
544
+ hash.update(`D\0${relative}\0`);
545
+ for (const entry of readBoundedDirectoryEntries(file, state)) {
546
+ appendPathToHash(hash, root, path.join(file, entry), state, depth + 1);
547
+ }
548
+ return;
549
+ }
550
+ if (stats.isFile()) {
551
+ hash.update(`F\0${relative}\0${stats.size}\0`);
552
+ appendFileContentsToHash(hash, file, stats.size, state);
553
+ return;
554
+ }
555
+ throw new Error(`Unsupported artifact path type: ${file}`);
556
+ }
557
+ function readBoundedDirectoryEntries(file, state) {
558
+ const remainingEntries = state.limits.maximumEntries - state.entryCount;
559
+ const directory = opendirSync(file);
560
+ try {
561
+ const entries = [];
562
+ while (true) {
563
+ const entry = directory.readSync();
564
+ if (entry === null) {
565
+ break;
566
+ }
567
+ if (entries.length >= remainingEntries) {
568
+ throwMaximumHashEntries(state.limits.maximumEntries);
569
+ }
570
+ entries.push(entry.name);
571
+ }
572
+ return entries.sort();
573
+ }
574
+ finally {
575
+ directory.closeSync();
576
+ }
577
+ }
578
+ function throwMaximumHashEntries(maximumEntries) {
579
+ throw new Error(`Gameplay evidence exceeds maximum hash entries ${maximumEntries}.`);
580
+ }
581
+ function appendFileContentsToHash(hash, file, expectedSize, state) {
582
+ const descriptor = openSync(file, 'r');
583
+ const buffer = Buffer.allocUnsafe(maximumHashReadChunkBytes);
584
+ let fileBytes = 0;
585
+ try {
586
+ while (true) {
587
+ const bytesRead = readSync(descriptor, buffer, 0, buffer.byteLength, null);
588
+ if (bytesRead === 0) {
589
+ break;
590
+ }
591
+ fileBytes += bytesRead;
592
+ if (state.totalFileBytes + fileBytes > state.limits.maximumTotalFileBytes) {
593
+ throw new Error(`Gameplay evidence exceeds maximum hashed bytes ${state.limits.maximumTotalFileBytes}.`);
594
+ }
595
+ hash.update(buffer.subarray(0, bytesRead));
596
+ }
597
+ }
598
+ finally {
599
+ closeSync(descriptor);
600
+ }
601
+ if (fileBytes !== expectedSize) {
602
+ throw new Error(`Gameplay evidence file changed while hashing: ${file}`);
603
+ }
604
+ state.totalFileBytes += fileBytes;
605
+ }
606
+ function validateHashLimits(limits) {
607
+ const entries = [
608
+ ['maximumDepth', limits.maximumDepth],
609
+ ['maximumEntries', limits.maximumEntries],
610
+ ['maximumTotalFileBytes', limits.maximumTotalFileBytes],
611
+ ];
612
+ for (const [name, value] of entries) {
613
+ if (!Number.isSafeInteger(value) || value <= 0) {
614
+ throw new Error(`Gameplay evidence hash limit ${name} must be a positive safe integer.`);
615
+ }
616
+ }
617
+ return limits;
618
+ }
619
+ function assertRecord(value, source) {
620
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
621
+ throw new Error(`${source} must be an object.`);
622
+ }
623
+ }
624
+ function assertOnlyKeys(value, allowed, source) {
625
+ const allowedKeys = new Set(allowed);
626
+ const unexpected = Object.keys(value).filter((key) => !allowedKeys.has(key));
627
+ if (unexpected.length > 0) {
628
+ throw new Error(`${source} contains unsupported fields: ${unexpected.join(', ')}`);
629
+ }
630
+ }
631
+ function readNonEmptyString(value, source) {
632
+ if (typeof value !== 'string' || value.trim().length === 0) {
633
+ throw new Error(`${source} must be a non-empty string.`);
634
+ }
635
+ return value;
636
+ }
637
+ function readBoundedNumber(value, source, minimum, maximum) {
638
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < minimum || value > maximum) {
639
+ throw new Error(`${source} must be between ${minimum} and ${maximum}.`);
640
+ }
641
+ return value;
642
+ }
643
+ function readBoundedInteger(value, source, minimum, maximum) {
644
+ const result = readBoundedNumber(value, source, minimum, maximum);
645
+ if (!Number.isInteger(result)) {
646
+ throw new Error(`${source} must be an integer.`);
647
+ }
648
+ return result;
649
+ }
650
+ function relativeOrAbsolute(root, file) {
651
+ const relative = path.relative(root, file);
652
+ return relative.startsWith('..') || path.isAbsolute(relative) ? file : relative || '.';
653
+ }
654
+ function replaceFileExtension(file, extension) {
655
+ const currentExtension = path.extname(file);
656
+ return currentExtension.length === 0
657
+ ? `${file}${extension}`
658
+ : `${file.slice(0, -currentExtension.length)}${extension}`;
659
+ }
660
+ function assertGameplayE2EJsonReportFile(file) {
661
+ if (path.extname(file).toLowerCase() === '.md') {
662
+ throw new Error('Gameplay E2E JSON report file must not use a Markdown extension.');
663
+ }
664
+ }
665
+ function escapeMarkdownTable(value) {
666
+ return escapeMarkdownInline(value).replaceAll('|', '\\|');
667
+ }
668
+ function escapeMarkdownInline(value) {
669
+ return value
670
+ .replaceAll('&', '&amp;')
671
+ .replaceAll('<', '&lt;')
672
+ .replaceAll('>', '&gt;')
673
+ .replace(/([\\`*_[\]])/gu, '\\$1')
674
+ .replaceAll('\n', ' ');
675
+ }
676
+ function formatDuration(durationMs) {
677
+ return durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
678
+ }
679
+ function formatError(error) {
680
+ if (error instanceof GameplayE2EAggregatedError) {
681
+ return `${error.message} ${error.errors.map(formatError).join(' ')}`;
682
+ }
683
+ return error instanceof Error ? error.message : String(error);
684
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, type GameAcceptanceCommandResult, type GameAcceptanceCommandRunner, type GameAcceptanceReport, type GameAcceptanceStatus, type GameAcceptanceStep, type GameAcceptanceStepResult, type GameAcceptanceStepStatus, type RunGameAcceptanceInput, type RunGameAcceptanceResult, } from './game-acceptance.js';
1
+ export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, type GameAcceptanceCommandResult, type GameAcceptanceCommandRunner, type GameAcceptanceReport, type GameAcceptanceStatus, type GameAcceptanceStep, type GameAcceptanceStepResult, type GameAcceptanceStepStatus, type RunGameAcceptanceInput, type RunGameAcceptanceResult, } from './game-acceptance.js';
2
+ export { collectGameplayE2EPathEvidence, defaultGameplayE2EReportFile, maximumGameplayE2EStates, parseGameplayE2EPlan, readGameplayE2EPlan, renderGameplayE2EMarkdown, resolveGameplayE2EReportFile, runGameplayE2E, type GameplayE2EAction, type GameplayE2EActionResult, type GameplayE2EDriver, type GameplayE2EHashLimits, type GameplayE2EInputAction, type GameplayE2EObservation, type GameplayE2EPauseResumeAction, type GameplayE2EPathEvidence, type GameplayE2EPlan, type GameplayE2EReport, type GameplayE2EResultStatus, type GameplayE2EState, type GameplayE2EStateResult, type GameplayE2EStatus, type RunGameplayE2EInput, type RunGameplayE2EResult, } from './gameplay-e2e.js';
2
3
  export { assertProductionTargetReadiness, type ProductionTargetReadinessInput, } from './production-target-readiness.js';
3
4
  export declare function runMpgdCli(args: readonly string[]): Promise<void>;
4
5
  export declare function runCreateGameCli(args: readonly string[]): Promise<void>;