@mpgd/cli 0.40.0 → 0.41.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/dist/game-acceptance.d.ts +4 -0
- package/dist/game-acceptance.js +49 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +44 -3
- package/dist/native-build-info.json +2 -2
- package/dist/performance-acceptance.d.ts +56 -0
- package/dist/performance-acceptance.js +269 -0
- package/package.json +7 -7
- package/templates/phaser-game/agent/acceptance.md +7 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PerformanceAcceptanceEvidence, type PerformanceAcceptanceInput } from './performance-acceptance.js';
|
|
1
2
|
export declare const defaultGameAcceptanceCommandTimeoutMs: number;
|
|
2
3
|
export declare const maximumGameplayE2EReportBytes: number;
|
|
3
4
|
export declare function resolveGameAcceptanceReleaseManifestFile(gameRoot: string, env?: NodeJS.ProcessEnv): string;
|
|
@@ -32,6 +33,7 @@ export interface GameAcceptanceReport {
|
|
|
32
33
|
readonly options: Readonly<Record<string, string | boolean | null>>;
|
|
33
34
|
readonly steps: readonly GameAcceptanceStepResult[];
|
|
34
35
|
readonly evidence: {
|
|
36
|
+
readonly performance?: PerformanceAcceptanceEvidence;
|
|
35
37
|
readonly releaseManifest: {
|
|
36
38
|
readonly file: string;
|
|
37
39
|
readonly found: boolean;
|
|
@@ -57,6 +59,8 @@ export interface RunGameAcceptanceInput {
|
|
|
57
59
|
readonly reportDir: string;
|
|
58
60
|
readonly releaseManifestFile?: string;
|
|
59
61
|
readonly gameplayE2EReportFile?: string;
|
|
62
|
+
/** Optional fresh performance collection and consumer-authored budget. */
|
|
63
|
+
readonly performance?: PerformanceAcceptanceInput;
|
|
60
64
|
readonly requireGameplayE2EReport?: boolean;
|
|
61
65
|
/** Step that must replace any previous gameplay report before evidence is accepted. */
|
|
62
66
|
readonly gameplayE2EStepId?: string;
|
package/dist/game-acceptance.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { escapeMarkdownInline, escapeMarkdownTable, formatDuration, formatError, readBoundedUtf8File, relativeOrAbsolute, writeEvidenceReportFiles, } from './evidence-io.js';
|
|
5
5
|
import { collectGameplayE2EPathEvidence, maximumGameplayE2EStates, readGameplayE2EPlan, resolveGameplayE2EPathInsideGameRoot, } from './gameplay-e2e.js';
|
|
6
|
+
import { preparePerformanceAcceptance, readPerformanceAcceptanceEvidence, } from './performance-acceptance.js';
|
|
6
7
|
export const defaultGameAcceptanceCommandTimeoutMs = 30 * 60 * 1_000;
|
|
7
8
|
const defaultGameAcceptanceReleaseManifestFile = 'artifacts/release-manifest.json';
|
|
8
9
|
export const maximumGameplayE2EReportBytes = 1_024 * 1_024;
|
|
@@ -22,10 +23,25 @@ export function runGameAcceptance(input) {
|
|
|
22
23
|
const commandRunner = input.commandRunner
|
|
23
24
|
?? ((step) => runAcceptanceCommand(step, input.env ?? process.env, commandTimeoutMs));
|
|
24
25
|
const gameRoot = path.resolve(input.gameRoot);
|
|
26
|
+
const reportDir = path.resolve(input.reportDir);
|
|
27
|
+
const jsonFile = path.join(reportDir, 'acceptance-report.json');
|
|
28
|
+
const markdownFile = path.join(reportDir, 'acceptance-report.md');
|
|
25
29
|
const startedAtMs = now();
|
|
26
30
|
const results = [];
|
|
27
31
|
let failed = false;
|
|
28
32
|
let gameplayE2EReportFileToReplace;
|
|
33
|
+
const performance = input.performance === undefined
|
|
34
|
+
? undefined
|
|
35
|
+
: preparePerformanceAcceptance(gameRoot, input.performance);
|
|
36
|
+
if (performance !== undefined && [jsonFile, markdownFile].some((file) => file === performance.reportFile || file === path.resolve(gameRoot, performance.budgetEvidence.file))) {
|
|
37
|
+
throw new Error('Performance budget and report must not overwrite acceptance handoff files.');
|
|
38
|
+
}
|
|
39
|
+
let performanceStartedAtMs;
|
|
40
|
+
if (input.performance !== undefined
|
|
41
|
+
&& (input.steps.filter((step) => step.id === input.performance?.stepId).length !== 1
|
|
42
|
+
|| input.steps.some((step) => step.id === input.performance?.stepId && step.skipReason !== undefined))) {
|
|
43
|
+
throw new Error('Performance evidence needs exactly one runnable collection step.');
|
|
44
|
+
}
|
|
29
45
|
if (input.gameplayE2EStepId !== undefined) {
|
|
30
46
|
if (input.gameplayE2EReportFile === undefined) {
|
|
31
47
|
throw new Error('A gameplay E2E evidence step needs a report file.');
|
|
@@ -50,6 +66,16 @@ export function runGameAcceptance(input) {
|
|
|
50
66
|
continue;
|
|
51
67
|
}
|
|
52
68
|
}
|
|
69
|
+
if (!failed && step.id === input.performance?.stepId && performance !== undefined) {
|
|
70
|
+
try {
|
|
71
|
+
rmSync(resolveGameplayE2EPathInsideGameRoot(gameRoot, performance.reportFile, 'Performance report'), { force: true });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
results.push(failedStepResult(step, now(), formatError(error)));
|
|
75
|
+
failed = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
53
79
|
if (failed) {
|
|
54
80
|
results.push(skippedStepResult(step, now(), 'A previous acceptance step failed.'));
|
|
55
81
|
continue;
|
|
@@ -70,6 +96,9 @@ export function runGameAcceptance(input) {
|
|
|
70
96
|
const cwd = path.resolve(runnableStep.cwd);
|
|
71
97
|
const stepStartedAtMs = now();
|
|
72
98
|
const startedAt = new Date(stepStartedAtMs).toISOString();
|
|
99
|
+
if (step.id === input.performance?.stepId) {
|
|
100
|
+
performanceStartedAtMs = stepStartedAtMs;
|
|
101
|
+
}
|
|
73
102
|
log(`[mpgd:accept] ${step.label}`);
|
|
74
103
|
let commandResult;
|
|
75
104
|
try {
|
|
@@ -103,11 +132,23 @@ export function runGameAcceptance(input) {
|
|
|
103
132
|
|| !gameplayE2E.found
|
|
104
133
|
|| gameplayE2E.parseError !== null
|
|
105
134
|
|| gameplayE2E.validationError !== null);
|
|
135
|
+
const performanceEvidence = performance === undefined
|
|
136
|
+
? undefined
|
|
137
|
+
: readPerformanceAcceptanceEvidence({
|
|
138
|
+
gameRoot,
|
|
139
|
+
prepared: performance,
|
|
140
|
+
startedAtMs: performanceStartedAtMs,
|
|
141
|
+
finishedAtMs: now(),
|
|
142
|
+
profile: typeof input.options.profile === 'string' ? input.options.profile : undefined,
|
|
143
|
+
releaseManifestFile: input.releaseManifestFile,
|
|
144
|
+
});
|
|
145
|
+
const performanceFailed = performanceEvidence?.validationError !== undefined
|
|
146
|
+
&& performanceEvidence.validationError !== null;
|
|
106
147
|
const finishedAtMs = now();
|
|
107
148
|
const report = {
|
|
108
149
|
schemaVersion: 1,
|
|
109
150
|
generatedAt: new Date(finishedAtMs).toISOString(),
|
|
110
|
-
status: failed || evidenceFailed ? 'failed' : 'passed',
|
|
151
|
+
status: failed || evidenceFailed || performanceFailed ? 'failed' : 'passed',
|
|
111
152
|
gameRoot,
|
|
112
153
|
durationMs: Math.max(0, finishedAtMs - startedAtMs),
|
|
113
154
|
options: input.options,
|
|
@@ -115,11 +156,9 @@ export function runGameAcceptance(input) {
|
|
|
115
156
|
evidence: {
|
|
116
157
|
releaseManifest,
|
|
117
158
|
gameplayE2E,
|
|
159
|
+
...(performanceEvidence === undefined ? {} : { performance: performanceEvidence }),
|
|
118
160
|
},
|
|
119
161
|
};
|
|
120
|
-
const reportDir = path.resolve(input.reportDir);
|
|
121
|
-
const jsonFile = path.join(reportDir, 'acceptance-report.json');
|
|
122
|
-
const markdownFile = path.join(reportDir, 'acceptance-report.md');
|
|
123
162
|
mkdirSync(reportDir, { recursive: true });
|
|
124
163
|
writeEvidenceReportFiles({
|
|
125
164
|
jsonFile,
|
|
@@ -180,6 +219,12 @@ export function renderGameAcceptanceMarkdown(report) {
|
|
|
180
219
|
else {
|
|
181
220
|
lines.push(`- Gameplay E2E report: ${escapeMarkdownInline(report.evidence.gameplayE2E.file)}`);
|
|
182
221
|
}
|
|
222
|
+
if (report.evidence.performance !== undefined) {
|
|
223
|
+
const performance = report.evidence.performance;
|
|
224
|
+
lines.push('', '## Performance Evidence', '', `- Report: ${escapeMarkdownInline(performance.file)}`, `- Budget: ${escapeMarkdownInline(performance.budget.file)} (${performance.budget.sha256})`, performance.validationError === null
|
|
225
|
+
? '- Budget validation passed.'
|
|
226
|
+
: `- Validation failed: ${escapeMarkdownInline(performance.validationError)}`);
|
|
227
|
+
}
|
|
183
228
|
return `${lines.join('\n')}\n`;
|
|
184
229
|
}
|
|
185
230
|
function runAcceptanceCommand(step, env, timeoutMs) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
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 { maximumPerformanceEvidenceBytes, preparePerformanceAcceptance, readPerformanceAcceptanceEvidence, validatePerformanceBudget, type PerformanceAcceptanceEvidence, type PerformanceAcceptanceInput, type PerformanceBudget, type PerformanceBudgetProfile, type PerformanceMetricBudget, type PreparedPerformanceAcceptance, } from './performance-acceptance.js';
|
|
2
3
|
export { applyCapacitorShellStarter, materializeCapacitorShellStarter, planCapacitorShellStarter, type CapacitorShellCommandRunner, type CapacitorShellStarterInput, type CapacitorShellStarterPlan, } from './capacitor-shell-starter.js';
|
|
3
4
|
export { ReleaseProcessError, runReleaseProcess, type ReleaseProcessInput, type ReleaseProcessResult, } from './deploy-process.js';
|
|
4
5
|
export { installPinnedReleaseDependencies, pinNativeDeploymentPlan, preparePinnedReleaseWorkspace, runPinnedNativeBuild, withPinnedReleaseWorkspace, type PinnedNativeBuildResult, type PinnedReleaseInput, type PinnedReleaseWorkspace, } from './release-workspace.js';
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { initializeNativeDeployWorkflow } from './native-deploy-workflow.js';
|
|
|
18
18
|
import { normalizeConfiguredBuildTargets, normalizeBuildTarget as normalizeConfiguredTargetName, supportedBuildTargets, } from './build-targets.js';
|
|
19
19
|
import { escapeMarkdownInline as escapeEvidenceMarkdownInline, writeEvidenceReportFiles, } from './evidence-io.js';
|
|
20
20
|
import { defaultGameAcceptanceCommandTimeoutMs, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, } from './game-acceptance.js';
|
|
21
|
-
import { resolveGameplayE2EReportFile } from './gameplay-e2e.js';
|
|
21
|
+
import { resolveGameplayE2EPathInsideGameRoot, resolveGameplayE2EReportFile, } from './gameplay-e2e.js';
|
|
22
22
|
import { cloudflarePagesDeploymentProfiles, hostedPwaDeploymentHosts, verifyHostedPwaDeployment, } from './hosted-pwa-deployment.js';
|
|
23
23
|
import { createMicrosoftStorePackageAcceptanceRuntime, runMicrosoftStorePackageAcceptance, } from './microsoft-store-package-acceptance.js';
|
|
24
24
|
import { runMicrosoftStorePackageGeneration } from './microsoft-store-package-generation.js';
|
|
@@ -31,6 +31,7 @@ import { readStoreCredential, runNativeDeployment } from './native-deploy-run.js
|
|
|
31
31
|
import { submitRecordedNativeTarget } from './native-deploy-submission.js';
|
|
32
32
|
import { readNativeReleaseStatus as readNativeReleaseStatusForCli } from './release-state.js';
|
|
33
33
|
export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, } from './game-acceptance.js';
|
|
34
|
+
export { maximumPerformanceEvidenceBytes, preparePerformanceAcceptance, readPerformanceAcceptanceEvidence, validatePerformanceBudget, } from './performance-acceptance.js';
|
|
34
35
|
export { applyCapacitorShellStarter, materializeCapacitorShellStarter, planCapacitorShellStarter, } from './capacitor-shell-starter.js';
|
|
35
36
|
export { ReleaseProcessError, runReleaseProcess, } from './deploy-process.js';
|
|
36
37
|
export { installPinnedReleaseDependencies, pinNativeDeploymentPlan, preparePinnedReleaseWorkspace, runPinnedNativeBuild, withPinnedReleaseWorkspace, } from './release-workspace.js';
|
|
@@ -111,6 +112,7 @@ const acceptanceStepIds = {
|
|
|
111
112
|
targetBuild: 'target-build',
|
|
112
113
|
targetSmoke: 'target-smoke',
|
|
113
114
|
gameplayE2E: 'gameplay-e2e',
|
|
115
|
+
performance: 'performance',
|
|
114
116
|
};
|
|
115
117
|
/** Whether the last run reported a deadline failure that leaves
|
|
116
118
|
* uncancellable filesystem work pending: the standalone binary forces its
|
|
@@ -725,6 +727,18 @@ const gameCommand = defineI18n({
|
|
|
725
727
|
required: false,
|
|
726
728
|
description: 'Skip the game-owned playtest package script.',
|
|
727
729
|
},
|
|
730
|
+
performance: {
|
|
731
|
+
type: 'boolean', required: false,
|
|
732
|
+
description: 'Collect and validate fresh foreground performance evidence.',
|
|
733
|
+
},
|
|
734
|
+
'performance-script': {
|
|
735
|
+
type: 'string', required: false, default: 'performance:e2e',
|
|
736
|
+
description: 'Game-owned performance collection package script.',
|
|
737
|
+
},
|
|
738
|
+
'performance-budget': {
|
|
739
|
+
type: 'string', required: false, default: 'agent/performance.budget.json',
|
|
740
|
+
description: 'Consumer-authored performance budget JSON inside the game root.',
|
|
741
|
+
},
|
|
728
742
|
'skip-gameplay-e2e': {
|
|
729
743
|
type: 'boolean',
|
|
730
744
|
required: false,
|
|
@@ -764,6 +778,9 @@ const gameCommand = defineI18n({
|
|
|
764
778
|
skipGraph: ctx.values['skip-graph'] === true,
|
|
765
779
|
skipPlaytest: ctx.values['skip-playtest'] === true,
|
|
766
780
|
skipGameplayE2E: ctx.values['skip-gameplay-e2e'] === true,
|
|
781
|
+
performance: ctx.values.performance === true,
|
|
782
|
+
performanceScript: readOptionalString(ctx.values['performance-script']) ?? 'performance:e2e',
|
|
783
|
+
performanceBudget: readOptionalString(ctx.values['performance-budget']) ?? 'agent/performance.budget.json',
|
|
767
784
|
skipTargetBuild: ctx.values['skip-target-build'] === true,
|
|
768
785
|
skipTargetSmoke: ctx.values['skip-target-smoke'] === true,
|
|
769
786
|
});
|
|
@@ -923,7 +940,7 @@ function acceptGame(input) {
|
|
|
923
940
|
const packageManager = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
|
924
941
|
const gameplayE2EEnabled = !input.skipGameplayE2E
|
|
925
942
|
&& gamePackage.scripts?.[input.gameplayScript] !== undefined;
|
|
926
|
-
const gameplayE2ETargets = gameplayE2EEnabled
|
|
943
|
+
const gameplayE2ETargets = gameplayE2EEnabled || input.performance
|
|
927
944
|
? parseTargetList(input.targets, {
|
|
928
945
|
...process.env,
|
|
929
946
|
MPGD_PLATFORM_TARGETS_FILE: targetsFile,
|
|
@@ -1014,12 +1031,30 @@ function acceptGame(input) {
|
|
|
1014
1031
|
skipped: input.skipGameplayE2E,
|
|
1015
1032
|
}),
|
|
1016
1033
|
];
|
|
1034
|
+
const performanceReportFile = input.performance
|
|
1035
|
+
? resolveGameplayE2EPathInsideGameRoot(gameRoot, readOptionalString(process.env.MPGD_PERFORMANCE_REPORT_FILE) ?? 'artifacts/performance/report.json', 'Performance report')
|
|
1036
|
+
: undefined;
|
|
1037
|
+
if (input.performance) {
|
|
1038
|
+
steps.push(packageScriptAcceptanceStep({
|
|
1039
|
+
id: acceptanceStepIds.performance,
|
|
1040
|
+
label: 'Foreground performance collection',
|
|
1041
|
+
script: input.performanceScript,
|
|
1042
|
+
gameRoot,
|
|
1043
|
+
packageManager,
|
|
1044
|
+
scripts: gamePackage.scripts,
|
|
1045
|
+
required: true,
|
|
1046
|
+
}));
|
|
1047
|
+
}
|
|
1017
1048
|
const reportDir = path.resolve(gameRoot, input.reportDir ?? 'artifacts/acceptance');
|
|
1018
1049
|
const releaseManifestFile = resolveGameAcceptanceReleaseManifestFile(gameRoot);
|
|
1019
1050
|
const gameplayE2EReportFile = resolveGameplayE2EReportFile(gameRoot);
|
|
1020
1051
|
const result = runGameAcceptance({
|
|
1021
1052
|
gameRoot,
|
|
1022
1053
|
reportDir,
|
|
1054
|
+
...(performanceReportFile === undefined ? {} : { performance: {
|
|
1055
|
+
budgetFile: input.performanceBudget, reportFile: performanceReportFile,
|
|
1056
|
+
stepId: acceptanceStepIds.performance, targets: gameplayE2ETargets,
|
|
1057
|
+
} }),
|
|
1023
1058
|
...(input.skipTargetBuild
|
|
1024
1059
|
? {}
|
|
1025
1060
|
: { releaseManifestFile }),
|
|
@@ -1038,6 +1073,8 @@ function acceptGame(input) {
|
|
|
1038
1073
|
iosVariant: input.iosVariant ?? null,
|
|
1039
1074
|
playtestScript: input.playtestScript,
|
|
1040
1075
|
gameplayScript: input.gameplayScript,
|
|
1076
|
+
...(input.performance ? { performance: true, performanceScript: input.performanceScript,
|
|
1077
|
+
performanceBudget: input.performanceBudget } : {}),
|
|
1041
1078
|
skipTest: input.skipTest,
|
|
1042
1079
|
skipGraph: input.skipGraph,
|
|
1043
1080
|
skipPlaytest: input.skipPlaytest,
|
|
@@ -1051,12 +1088,16 @@ function acceptGame(input) {
|
|
|
1051
1088
|
env: {
|
|
1052
1089
|
...process.env,
|
|
1053
1090
|
MPGD_KIT_PATH: kitPath,
|
|
1054
|
-
MPGD_ACCEPTANCE_TARGETS: gameplayE2EEnabled
|
|
1091
|
+
MPGD_ACCEPTANCE_TARGETS: gameplayE2EEnabled || input.performance
|
|
1055
1092
|
? gameplayE2ETargets.join(',')
|
|
1056
1093
|
: input.targets,
|
|
1057
1094
|
MPGD_ACCEPTANCE_PROFILE: input.profile,
|
|
1058
1095
|
MPGD_RELEASE_MANIFEST_FILE: releaseManifestFile,
|
|
1059
1096
|
MPGD_GAMEPLAY_E2E_REPORT_FILE: gameplayE2EReportFile,
|
|
1097
|
+
...(performanceReportFile === undefined ? {} : {
|
|
1098
|
+
MPGD_PERFORMANCE_REPORT_FILE: performanceReportFile,
|
|
1099
|
+
MPGD_PERFORMANCE_BUDGET_FILE: path.resolve(gameRoot, input.performanceBudget),
|
|
1100
|
+
}),
|
|
1060
1101
|
},
|
|
1061
1102
|
});
|
|
1062
1103
|
console.info(`[mpgd:accept] JSON report: ${result.jsonFile}`);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type GameplayE2EPathEvidence } from './gameplay-e2e.js';
|
|
2
|
+
export declare const maximumPerformanceEvidenceBytes: number;
|
|
3
|
+
export interface PerformanceMetricBudget {
|
|
4
|
+
readonly p95?: number;
|
|
5
|
+
readonly p99?: number;
|
|
6
|
+
readonly max?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface PerformanceBudgetProfile {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly target: string;
|
|
11
|
+
readonly scenario: string;
|
|
12
|
+
readonly renderer: string;
|
|
13
|
+
readonly device: string;
|
|
14
|
+
readonly minSamples: number;
|
|
15
|
+
readonly metrics: Readonly<Record<string, PerformanceMetricBudget>>;
|
|
16
|
+
readonly maxExcludedFrames?: number;
|
|
17
|
+
readonly maxHeapGrowthBytes?: number;
|
|
18
|
+
readonly hitchThresholdMs?: number;
|
|
19
|
+
readonly maxHitches?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface PerformanceBudget {
|
|
22
|
+
readonly schemaVersion: 1;
|
|
23
|
+
readonly profiles: readonly PerformanceBudgetProfile[];
|
|
24
|
+
}
|
|
25
|
+
export interface PerformanceAcceptanceInput {
|
|
26
|
+
readonly budgetFile: string;
|
|
27
|
+
readonly reportFile: string;
|
|
28
|
+
readonly stepId: string;
|
|
29
|
+
readonly targets: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
export interface PreparedPerformanceAcceptance {
|
|
32
|
+
readonly budget: PerformanceBudget;
|
|
33
|
+
readonly budgetEvidence: GameplayE2EPathEvidence;
|
|
34
|
+
readonly reportFile: string;
|
|
35
|
+
}
|
|
36
|
+
export interface PerformanceAcceptanceEvidence {
|
|
37
|
+
readonly file: string;
|
|
38
|
+
readonly found: boolean;
|
|
39
|
+
readonly validationError: string | null;
|
|
40
|
+
readonly budget: GameplayE2EPathEvidence;
|
|
41
|
+
readonly report: GameplayE2EPathEvidence | null;
|
|
42
|
+
readonly value: unknown;
|
|
43
|
+
}
|
|
44
|
+
/** Consumer-authored budgets: no universal device/FPS thresholds are supplied. */
|
|
45
|
+
export declare function validatePerformanceBudget(value: unknown): PerformanceBudget;
|
|
46
|
+
export declare function preparePerformanceAcceptance(gameRoot: string, input: PerformanceAcceptanceInput): PreparedPerformanceAcceptance;
|
|
47
|
+
/** Accept only fresh untraced foreground measurements with matching authored
|
|
48
|
+
* context, adequate sample counts and current artifact/budget provenance. */
|
|
49
|
+
export declare function readPerformanceAcceptanceEvidence(input: {
|
|
50
|
+
readonly gameRoot: string;
|
|
51
|
+
readonly prepared: PreparedPerformanceAcceptance;
|
|
52
|
+
readonly startedAtMs: number | undefined;
|
|
53
|
+
readonly finishedAtMs: number;
|
|
54
|
+
readonly profile: string | undefined;
|
|
55
|
+
readonly releaseManifestFile?: string | undefined;
|
|
56
|
+
}): PerformanceAcceptanceEvidence;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { formatError, readBoundedUtf8File, relativeOrAbsolute } from './evidence-io.js';
|
|
4
|
+
import { collectGameplayE2EPathEvidence, resolveGameplayE2EPathInsideGameRoot, } from './gameplay-e2e.js';
|
|
5
|
+
export const maximumPerformanceEvidenceBytes = 1_024 * 1_024;
|
|
6
|
+
function record(value, label) {
|
|
7
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
8
|
+
throw new Error(`${label} must be an object.`);
|
|
9
|
+
}
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function text(value, label) {
|
|
13
|
+
if (typeof value !== 'string' || value.trim().length === 0 || value.length > 256) {
|
|
14
|
+
throw new Error(`${label} must be a bounded non-empty string.`);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function number(value, label, signed = false) {
|
|
19
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || (!signed && value < 0)) {
|
|
20
|
+
throw new Error(`${label} must be a finite ${signed ? '' : 'non-negative '}number.`);
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function count(value, label, min = 0) {
|
|
25
|
+
const n = number(value, label);
|
|
26
|
+
if (!Number.isSafeInteger(n) || n < min || n > 1_000_000_000) {
|
|
27
|
+
throw new Error(`${label} must be an integer between ${min} and 1000000000.`);
|
|
28
|
+
}
|
|
29
|
+
return n;
|
|
30
|
+
}
|
|
31
|
+
function keys(value, allowed, label) {
|
|
32
|
+
if (Object.keys(value).some((key) => !allowed.includes(key))) {
|
|
33
|
+
throw new Error(`${label} contains an unknown field.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Consumer-authored budgets: no universal device/FPS thresholds are supplied. */
|
|
37
|
+
export function validatePerformanceBudget(value) {
|
|
38
|
+
const input = record(value, 'Performance budget');
|
|
39
|
+
keys(input, ['schemaVersion', 'profiles'], 'Performance budget');
|
|
40
|
+
if (input.schemaVersion !== 1 || !Array.isArray(input.profiles)
|
|
41
|
+
|| input.profiles.length === 0 || input.profiles.length > 64) {
|
|
42
|
+
throw new Error('Performance budget needs schemaVersion 1 and 1–64 profiles.');
|
|
43
|
+
}
|
|
44
|
+
const ids = new Set();
|
|
45
|
+
const profiles = input.profiles.map((item) => {
|
|
46
|
+
const profile = record(item, 'Performance profile');
|
|
47
|
+
keys(profile, ['id', 'target', 'scenario', 'renderer', 'device', 'minSamples', 'metrics',
|
|
48
|
+
'maxExcludedFrames', 'maxHeapGrowthBytes', 'hitchThresholdMs', 'maxHitches'], 'Performance profile');
|
|
49
|
+
for (const name of ['id', 'target', 'scenario', 'renderer', 'device']) {
|
|
50
|
+
text(profile[name], `Performance profile ${name}`);
|
|
51
|
+
}
|
|
52
|
+
const id = text(profile.id, 'Performance profile id');
|
|
53
|
+
if (ids.has(id)) {
|
|
54
|
+
throw new Error(`Duplicate performance profile: ${id}`);
|
|
55
|
+
}
|
|
56
|
+
ids.add(id);
|
|
57
|
+
count(profile.minSamples, `${id} minSamples`, 2);
|
|
58
|
+
const metrics = record(profile.metrics, `${id} metrics`);
|
|
59
|
+
const metricNames = Object.keys(metrics);
|
|
60
|
+
const builtInMetrics = ['intervalMs', 'totalCpuMs'];
|
|
61
|
+
const customNames = metricNames.filter((field) => !builtInMetrics.includes(field));
|
|
62
|
+
if (metricNames.length === 0 || customNames.length > 64) {
|
|
63
|
+
throw new Error(`${id} needs 1–66 metric budgets.`);
|
|
64
|
+
}
|
|
65
|
+
if (customNames.some((field) => field.length > 64 || !/^[a-z][a-zA-Z0-9]*$/.test(field)
|
|
66
|
+
|| ['constructor', 'prototype'].includes(field))) {
|
|
67
|
+
throw new Error(`${id} has an invalid profiler metric name.`);
|
|
68
|
+
}
|
|
69
|
+
for (const [field, itemBudget] of Object.entries(metrics)) {
|
|
70
|
+
text(field, 'Performance metric name');
|
|
71
|
+
const budget = record(itemBudget, `${id}/${field} budget`);
|
|
72
|
+
keys(budget, ['p95', 'p99', 'max'], `${id}/${field} budget`);
|
|
73
|
+
if (Object.keys(budget).length === 0) {
|
|
74
|
+
throw new Error(`${id}/${field} needs a threshold.`);
|
|
75
|
+
}
|
|
76
|
+
for (const [quantile, threshold] of Object.entries(budget)) {
|
|
77
|
+
number(threshold, `${id}/${field}/${quantile}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const name of ['maxHeapGrowthBytes', 'hitchThresholdMs']) {
|
|
81
|
+
if (profile[name] !== undefined) {
|
|
82
|
+
number(profile[name], `${id} ${name}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const name of ['maxExcludedFrames', 'maxHitches']) {
|
|
86
|
+
if (profile[name] !== undefined) {
|
|
87
|
+
count(profile[name], `${id} ${name}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if ((profile.maxHitches === undefined) !== (profile.hitchThresholdMs === undefined)
|
|
91
|
+
|| profile.hitchThresholdMs === 0) {
|
|
92
|
+
throw new Error(`${id} hitch budgets need maxHitches and a positive hitchThresholdMs.`);
|
|
93
|
+
}
|
|
94
|
+
const snapshot = structuredClone(profile);
|
|
95
|
+
for (const metric of Object.values(snapshot.metrics)) {
|
|
96
|
+
Object.freeze(metric);
|
|
97
|
+
}
|
|
98
|
+
Object.freeze(snapshot.metrics);
|
|
99
|
+
return Object.freeze(snapshot);
|
|
100
|
+
});
|
|
101
|
+
return Object.freeze({ schemaVersion: 1, profiles: Object.freeze(profiles) });
|
|
102
|
+
}
|
|
103
|
+
function readJson(file, label) {
|
|
104
|
+
const content = readBoundedUtf8File(file, maximumPerformanceEvidenceBytes);
|
|
105
|
+
if (content === null) {
|
|
106
|
+
throw new Error(`${label} exceeds its byte limit.`);
|
|
107
|
+
}
|
|
108
|
+
return JSON.parse(content);
|
|
109
|
+
}
|
|
110
|
+
function evidence(gameRoot, value, label) {
|
|
111
|
+
const input = record(value, label);
|
|
112
|
+
const file = text(input.file, `${label} file`);
|
|
113
|
+
const current = collectGameplayE2EPathEvidence(gameRoot, file, label);
|
|
114
|
+
if (current.kind === 'symbolic-link' || current.kind !== input.kind || current.sha256 !== input.sha256) {
|
|
115
|
+
throw new Error(`${label} no longer matches its current file/directory hash.`);
|
|
116
|
+
}
|
|
117
|
+
return current;
|
|
118
|
+
}
|
|
119
|
+
export function preparePerformanceAcceptance(gameRoot, input) {
|
|
120
|
+
const budgetFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, input.budgetFile, 'Performance budget');
|
|
121
|
+
const reportFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, input.reportFile, 'Performance report');
|
|
122
|
+
if (budgetFile === reportFile) {
|
|
123
|
+
throw new Error('Performance report must not overwrite its budget.');
|
|
124
|
+
}
|
|
125
|
+
const budget = validatePerformanceBudget(readJson(budgetFile, 'Performance budget'));
|
|
126
|
+
if (budget.profiles.some((profile) => !input.targets.includes(profile.target))) {
|
|
127
|
+
throw new Error('Performance budget targets must belong to the selected acceptance targets.');
|
|
128
|
+
}
|
|
129
|
+
const budgetEvidence = collectGameplayE2EPathEvidence(gameRoot, budgetFile, 'Performance budget');
|
|
130
|
+
if (budgetEvidence.kind !== 'file') {
|
|
131
|
+
throw new Error('Performance budget must be a regular file.');
|
|
132
|
+
}
|
|
133
|
+
return Object.freeze({ budget, budgetEvidence: Object.freeze(budgetEvidence), reportFile });
|
|
134
|
+
}
|
|
135
|
+
/** Accept only fresh untraced foreground measurements with matching authored
|
|
136
|
+
* context, adequate sample counts and current artifact/budget provenance. */
|
|
137
|
+
export function readPerformanceAcceptanceEvidence(input) {
|
|
138
|
+
const { gameRoot, prepared } = input;
|
|
139
|
+
const result = {
|
|
140
|
+
file: relativeOrAbsolute(gameRoot, prepared.reportFile),
|
|
141
|
+
found: false,
|
|
142
|
+
validationError: null,
|
|
143
|
+
budget: prepared.budgetEvidence,
|
|
144
|
+
report: null,
|
|
145
|
+
value: null,
|
|
146
|
+
};
|
|
147
|
+
let value;
|
|
148
|
+
try {
|
|
149
|
+
const file = resolveGameplayE2EPathInsideGameRoot(gameRoot, prepared.reportFile, 'Performance report');
|
|
150
|
+
if (!existsSync(file)) {
|
|
151
|
+
throw new Error('Performance collector did not produce its report.');
|
|
152
|
+
}
|
|
153
|
+
value = readJson(file, 'Performance report');
|
|
154
|
+
const reportEvidence = collectGameplayE2EPathEvidence(gameRoot, file, 'Performance report');
|
|
155
|
+
const report = record(value, 'Performance report');
|
|
156
|
+
if (report.schemaVersion !== 1 || input.startedAtMs === undefined) {
|
|
157
|
+
throw new Error('Performance report needs schemaVersion 1 and a completed collection step.');
|
|
158
|
+
}
|
|
159
|
+
text(report.profile, 'Performance report profile');
|
|
160
|
+
const at = Date.parse(text(report.generatedAt, 'Performance generatedAt'));
|
|
161
|
+
if (!Number.isFinite(at) || new Date(at).toISOString() !== report.generatedAt
|
|
162
|
+
|| at < input.startedAtMs || at > input.finishedAtMs) {
|
|
163
|
+
throw new Error('Performance report timestamp must be inside this collection run.');
|
|
164
|
+
}
|
|
165
|
+
if (input.profile !== undefined && report.profile !== input.profile) {
|
|
166
|
+
throw new Error('Performance report profile must match acceptance.');
|
|
167
|
+
}
|
|
168
|
+
const currentBudget = evidence(gameRoot, report.budget, 'Performance budget');
|
|
169
|
+
if (currentBudget.file !== prepared.budgetEvidence.file
|
|
170
|
+
|| currentBudget.sha256 !== prepared.budgetEvidence.sha256) {
|
|
171
|
+
throw new Error('Performance budget changed during acceptance.');
|
|
172
|
+
}
|
|
173
|
+
if (!Array.isArray(report.measurements) || report.measurements.length !== prepared.budget.profiles.length) {
|
|
174
|
+
throw new Error('Performance report must cover every budget profile exactly once.');
|
|
175
|
+
}
|
|
176
|
+
const seen = new Set();
|
|
177
|
+
const release = input.releaseManifestFile === undefined
|
|
178
|
+
? undefined
|
|
179
|
+
: record(readJson(input.releaseManifestFile, 'Release manifest'), 'Release manifest');
|
|
180
|
+
for (const item of report.measurements) {
|
|
181
|
+
const measurement = record(item, 'Performance measurement');
|
|
182
|
+
const id = text(measurement.id, 'Performance measurement id');
|
|
183
|
+
const budget = prepared.budget.profiles.find((profile) => profile.id === id);
|
|
184
|
+
if (budget === undefined || seen.has(id)) {
|
|
185
|
+
throw new Error('Performance measurements have missing/duplicate profiles.');
|
|
186
|
+
}
|
|
187
|
+
seen.add(id);
|
|
188
|
+
const context = record(measurement.context, `${id} context`);
|
|
189
|
+
for (const name of ['target', 'scenario', 'renderer', 'device']) {
|
|
190
|
+
if (context[name] !== budget[name]) {
|
|
191
|
+
throw new Error(`${id} ${name} does not match its budget.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const dpr = number(context.devicePixelRatio, `${id} devicePixelRatio`);
|
|
195
|
+
if (dpr === 0 || dpr > 64 || context.foreground !== true) {
|
|
196
|
+
throw new Error(`${id} must record a positive DPR and foreground measurement.`);
|
|
197
|
+
}
|
|
198
|
+
const instrumentation = record(context.instrumentation, `${id} instrumentation`);
|
|
199
|
+
if (instrumentation.trace !== false || instrumentation.screenshots !== false) {
|
|
200
|
+
throw new Error(`${id} needs an untraced measurement without screenshot capture.`);
|
|
201
|
+
}
|
|
202
|
+
for (const name of ['logicalWidth', 'logicalHeight', 'backingWidth', 'backingHeight']) {
|
|
203
|
+
if (number(context[name], `${id} ${name}`) === 0) {
|
|
204
|
+
throw new Error(`${id} dimensions must be positive.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const artifact = evidence(gameRoot, measurement.artifact, `${id} target artifact`);
|
|
208
|
+
if (release !== undefined) {
|
|
209
|
+
const target = record(record(release.targets, 'Release targets')[budget.target], `${id} release target`);
|
|
210
|
+
if (typeof target.artifact !== 'string'
|
|
211
|
+
|| path.resolve(gameRoot, target.artifact) !== path.resolve(gameRoot, artifact.file)
|
|
212
|
+
|| (target.profile !== undefined && target.profile !== report.profile)) {
|
|
213
|
+
throw new Error(`${id} artifact/profile differs from the release target.`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const snapshot = record(measurement.snapshot, `${id} snapshot`);
|
|
217
|
+
const metrics = record(snapshot.metrics, `${id} metrics`);
|
|
218
|
+
const maximums = record(snapshot.maximums, `${id} maximums`);
|
|
219
|
+
const samples = record(snapshot.sampleCounts, `${id} sampleCounts`);
|
|
220
|
+
const retained = count(snapshot.retainedSamples, `${id} retainedSamples`);
|
|
221
|
+
const frames = count(snapshot.frames, `${id} frames`);
|
|
222
|
+
const excluded = count(snapshot.excludedFrames, `${id} excludedFrames`);
|
|
223
|
+
if (frames < retained || (budget.maxExcludedFrames !== undefined && excluded > budget.maxExcludedFrames)) {
|
|
224
|
+
throw new Error(`${id} has inconsistent frame counts or too many excluded frames.`);
|
|
225
|
+
}
|
|
226
|
+
for (const [field, thresholds] of Object.entries(budget.metrics)) {
|
|
227
|
+
const n = count(samples[field], `${id}/${field} sampleCount`);
|
|
228
|
+
if (n < budget.minSamples || n > retained) {
|
|
229
|
+
throw new Error(`${id}/${field} has insufficient/inconsistent samples.`);
|
|
230
|
+
}
|
|
231
|
+
const quantiles = record(metrics[field], `${id}/${field} quantiles`);
|
|
232
|
+
const p50 = number(quantiles.p50, `${id}/${field} p50`);
|
|
233
|
+
const p95 = number(quantiles.p95, `${id}/${field} p95`);
|
|
234
|
+
const p99 = number(quantiles.p99, `${id}/${field} p99`);
|
|
235
|
+
const max = number(maximums[field], `${id}/${field} max`);
|
|
236
|
+
if (p50 > p95 || p95 > p99 || p99 > max) {
|
|
237
|
+
throw new Error(`${id}/${field} quantiles are inconsistent.`);
|
|
238
|
+
}
|
|
239
|
+
for (const [key, threshold] of Object.entries(thresholds)) {
|
|
240
|
+
const measured = key === 'max' ? max : number(quantiles[key], `${id}/${field}/${key}`);
|
|
241
|
+
if (measured > threshold) {
|
|
242
|
+
throw new Error(`${id}/${field} ${key} exceeds its budget.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (budget.maxHeapGrowthBytes !== undefined
|
|
247
|
+
&& number(measurement.heapGrowthBytes, `${id} heapGrowthBytes`, true) > budget.maxHeapGrowthBytes) {
|
|
248
|
+
throw new Error(`${id} heap growth exceeds its budget.`);
|
|
249
|
+
}
|
|
250
|
+
if (budget.maxHitches !== undefined) {
|
|
251
|
+
const hitches = record(measurement.hitches, `${id} hitches`);
|
|
252
|
+
const n = count(hitches.count, `${id} hitch count`);
|
|
253
|
+
const total = count(hitches.samples, `${id} hitch samples`, budget.minSamples);
|
|
254
|
+
if (n > total || n > budget.maxHitches || hitches.thresholdMs !== budget.hitchThresholdMs) {
|
|
255
|
+
throw new Error(`${id} hitches exceed or do not match their budget.`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { ...result, found: true, report: reportEvidence, value };
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
return {
|
|
263
|
+
...result,
|
|
264
|
+
found: existsSync(prepared.reportFile),
|
|
265
|
+
validationError: formatError(error),
|
|
266
|
+
value: value ?? null,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"vite": "8.1.3",
|
|
60
60
|
"yauzl": "3.4.0",
|
|
61
61
|
"@mpgd/catalog": "0.8.0",
|
|
62
|
-
"@mpgd/
|
|
63
|
-
"@mpgd/
|
|
62
|
+
"@mpgd/phaser-assets": "0.9.0",
|
|
63
|
+
"@mpgd/target-config": "0.21.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@types/node": "^24.0.0",
|
|
@@ -68,15 +68,15 @@
|
|
|
68
68
|
"@types/yauzl": "3.4.0",
|
|
69
69
|
"ttsc": "0.30.4",
|
|
70
70
|
"typescript": "7.0.2",
|
|
71
|
-
"@mpgd/adapter-ait": "0.13.
|
|
71
|
+
"@mpgd/adapter-ait": "0.13.3",
|
|
72
72
|
"@mpgd/adapter-browser": "0.9.0",
|
|
73
|
-
"@mpgd/adapter-capacitor": "0.7.
|
|
73
|
+
"@mpgd/adapter-capacitor": "0.7.4",
|
|
74
74
|
"@mpgd/adapter-devvit": "0.9.17",
|
|
75
|
+
"@mpgd/adapter-verse8": "0.4.3",
|
|
75
76
|
"@mpgd/analytics": "0.4.3",
|
|
76
|
-
"@mpgd/adapter-verse8": "0.4.2",
|
|
77
77
|
"@mpgd/game-services": "0.19.0",
|
|
78
78
|
"@mpgd/bridge": "0.11.0",
|
|
79
|
-
"@mpgd/game-runtime": "0.
|
|
79
|
+
"@mpgd/game-runtime": "0.6.0",
|
|
80
80
|
"@mpgd/i18n": "0.6.8",
|
|
81
81
|
"@mpgd/platform": "0.17.0"
|
|
82
82
|
},
|
|
@@ -82,3 +82,10 @@ the snapshot bounds for persistent controls.
|
|
|
82
82
|
Build and smoke the configured ONE play target using the game-owned kit path. Validate server-issued reward/checkout binding, raw-byte signed SSV, purchase token API verification, ledger-before-consume/acknowledge, retries without duplicate grants, signed PNS orphan-purchase recovery and atomic cancellation fences. Missing finalization must keep PNS pending; a busy SDK must report native not-started before rejecting a new checkout. Permit five minutes of provider/server clock skew while rejecting timestamps outside the intent window.
|
|
83
83
|
|
|
84
84
|
Use an authenticated server session rather than the SDK player pseudonym. Confirm ad IDs, real payments, lifecycle, HTTPS iframe hosting and PNS/SSV in ONE store with issued ONEconsole credentials. Generic fixture and artifact smoke do not certify real-app commerce.
|
|
85
|
+
|
|
86
|
+
Performance evidence can be required with `mpgd game accept . --performance`.
|
|
87
|
+
Provide a game-owned `performance:e2e` script and `agent/performance.budget.json`;
|
|
88
|
+
collect a fresh foreground report at `MPGD_PERFORMANCE_REPORT_FILE`. Calibrate
|
|
89
|
+
per-device/scenario CPU and cadence budgets independently, link the tested
|
|
90
|
+
artifact/budget hashes and keep trace/screenshots in separate diagnostic runs.
|
|
91
|
+
See the [report contract](https://github.com/imjlk/mpgd-kit/blob/main/docs/GAME_PERFORMANCE_ACCEPTANCE.md).
|