@mpgd/cli 0.10.1 → 0.11.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/browser-gameplay-e2e.d.ts +40 -0
- package/dist/browser-gameplay-e2e.js +54 -0
- package/dist/evidence-io.d.ts +13 -0
- package/dist/evidence-io.js +45 -0
- package/dist/game-acceptance.js +8 -42
- package/dist/gameplay-e2e.js +9 -22
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/microsoft-store-pwa-e2e.d.ts +68 -0
- package/dist/microsoft-store-pwa-e2e.js +257 -0
- package/package.json +7 -7
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { GameplayE2EDriver, GameplayE2EObservation, GameplayE2EState } from './gameplay-e2e.js';
|
|
2
|
+
export interface BrowserGameplayE2EViewport {
|
|
3
|
+
readonly width: number;
|
|
4
|
+
readonly height: number;
|
|
5
|
+
}
|
|
6
|
+
export interface BrowserGameplayE2EScreenshotOptions {
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly type: 'png';
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The minimal Playwright-compatible page surface used by the browser driver.
|
|
12
|
+
* Consumers keep Playwright and browser credentials in their own test harness.
|
|
13
|
+
*/
|
|
14
|
+
export interface BrowserGameplayE2EPage {
|
|
15
|
+
viewportSize(): BrowserGameplayE2EViewport | null;
|
|
16
|
+
readonly mouse: {
|
|
17
|
+
click(x: number, y: number): Promise<unknown>;
|
|
18
|
+
};
|
|
19
|
+
readonly keyboard: {
|
|
20
|
+
press(key: string): Promise<unknown>;
|
|
21
|
+
};
|
|
22
|
+
waitForTimeout(durationMs: number): Promise<unknown>;
|
|
23
|
+
screenshot(options: BrowserGameplayE2EScreenshotOptions): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
export interface BrowserGameplayE2EInspectInput<TPage extends BrowserGameplayE2EPage = BrowserGameplayE2EPage> {
|
|
26
|
+
readonly page: TPage;
|
|
27
|
+
readonly state: GameplayE2EState;
|
|
28
|
+
readonly phase: 'after' | 'before' | 'resumed';
|
|
29
|
+
}
|
|
30
|
+
export interface CreateBrowserGameplayE2EDriverInput<TPage extends BrowserGameplayE2EPage = BrowserGameplayE2EPage> {
|
|
31
|
+
readonly page: TPage;
|
|
32
|
+
readonly pause: (page: TPage) => Promise<void>;
|
|
33
|
+
readonly resume: (page: TPage) => Promise<void>;
|
|
34
|
+
readonly inspect: (input: BrowserGameplayE2EInspectInput<TPage>) => Promise<GameplayE2EObservation>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Creates a browser driver for generic input and screenshot operations while
|
|
38
|
+
* leaving lifecycle orchestration and game-state inspection with the game.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createBrowserGameplayE2EDriver<TPage extends BrowserGameplayE2EPage>(input: CreateBrowserGameplayE2EDriverInput<TPage>): GameplayE2EDriver;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a browser driver for generic input and screenshot operations while
|
|
3
|
+
* leaving lifecycle orchestration and game-state inspection with the game.
|
|
4
|
+
*/
|
|
5
|
+
export function createBrowserGameplayE2EDriver(input) {
|
|
6
|
+
return {
|
|
7
|
+
perform: async (action) => {
|
|
8
|
+
switch (action.type) {
|
|
9
|
+
case 'tap': {
|
|
10
|
+
const viewport = readBrowserGameplayE2EViewport(input.page);
|
|
11
|
+
const x = resolveBrowserGameplayE2ETapCoordinate(action.x, viewport.width, 'x');
|
|
12
|
+
const y = resolveBrowserGameplayE2ETapCoordinate(action.y, viewport.height, 'y');
|
|
13
|
+
await input.page.mouse.click(x, y);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
case 'key':
|
|
17
|
+
await input.page.keyboard.press(action.key);
|
|
18
|
+
return;
|
|
19
|
+
case 'wait':
|
|
20
|
+
await input.page.waitForTimeout(action.durationMs);
|
|
21
|
+
return;
|
|
22
|
+
default: {
|
|
23
|
+
const unsupportedAction = action;
|
|
24
|
+
throw new Error(`Unsupported browser Gameplay E2E action: ${String(unsupportedAction)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
pause: () => input.pause(input.page),
|
|
29
|
+
resume: () => input.resume(input.page),
|
|
30
|
+
inspect: ({ state, phase }) => input.inspect({ page: input.page, state, phase }),
|
|
31
|
+
captureScreenshot: async ({ file }) => {
|
|
32
|
+
await input.page.screenshot({ path: file, type: 'png' });
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function readBrowserGameplayE2EViewport(page) {
|
|
37
|
+
const viewport = page.viewportSize();
|
|
38
|
+
if (viewport === null) {
|
|
39
|
+
throw new Error('Browser Gameplay E2E taps require a configured page viewport.');
|
|
40
|
+
}
|
|
41
|
+
if (!Number.isSafeInteger(viewport.width)
|
|
42
|
+
|| viewport.width <= 0
|
|
43
|
+
|| !Number.isSafeInteger(viewport.height)
|
|
44
|
+
|| viewport.height <= 0) {
|
|
45
|
+
throw new Error('Browser Gameplay E2E viewport dimensions must be positive safe integers.');
|
|
46
|
+
}
|
|
47
|
+
return viewport;
|
|
48
|
+
}
|
|
49
|
+
function resolveBrowserGameplayE2ETapCoordinate(coordinate, viewportSize, axis) {
|
|
50
|
+
if (!Number.isFinite(coordinate) || coordinate < 0 || coordinate > 1) {
|
|
51
|
+
throw new Error(`Browser Gameplay E2E ${axis} tap coordinates must be between 0 and 1.`);
|
|
52
|
+
}
|
|
53
|
+
return Math.min(viewportSize - 1, Math.floor(coordinate * viewportSize));
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface EvidenceReportFilesInput {
|
|
2
|
+
readonly jsonFile: string;
|
|
3
|
+
readonly markdownFile: string;
|
|
4
|
+
readonly report: unknown;
|
|
5
|
+
readonly markdown: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function writeEvidenceReportFiles(input: EvidenceReportFilesInput): void;
|
|
8
|
+
export declare function readBoundedUtf8File(file: string, maximumBytes: number): string | null;
|
|
9
|
+
export declare function relativeOrAbsolute(root: string, file: string): string;
|
|
10
|
+
export declare function escapeMarkdownTable(value: string): string;
|
|
11
|
+
export declare function escapeMarkdownInline(value: string): string;
|
|
12
|
+
export declare function formatDuration(durationMs: number): string;
|
|
13
|
+
export declare function formatError(error: unknown): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function writeEvidenceReportFiles(input) {
|
|
4
|
+
writeFileSync(input.jsonFile, `${JSON.stringify(input.report, null, 2)}\n`);
|
|
5
|
+
writeFileSync(input.markdownFile, input.markdown);
|
|
6
|
+
}
|
|
7
|
+
export function readBoundedUtf8File(file, maximumBytes) {
|
|
8
|
+
const buffer = Buffer.allocUnsafe(maximumBytes + 1);
|
|
9
|
+
const fileDescriptor = openSync(file, 'r');
|
|
10
|
+
let offset = 0;
|
|
11
|
+
try {
|
|
12
|
+
while (offset < buffer.length) {
|
|
13
|
+
const bytesRead = readSync(fileDescriptor, buffer, offset, buffer.length - offset, null);
|
|
14
|
+
if (bytesRead === 0) {
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
offset += bytesRead;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
closeSync(fileDescriptor);
|
|
22
|
+
}
|
|
23
|
+
return offset > maximumBytes ? null : buffer.subarray(0, offset).toString('utf8');
|
|
24
|
+
}
|
|
25
|
+
export function relativeOrAbsolute(root, file) {
|
|
26
|
+
const relative = path.relative(root, file);
|
|
27
|
+
return relative.startsWith('..') || path.isAbsolute(relative) ? file : relative || '.';
|
|
28
|
+
}
|
|
29
|
+
export function escapeMarkdownTable(value) {
|
|
30
|
+
return escapeMarkdownInline(value).replaceAll('|', '\\|');
|
|
31
|
+
}
|
|
32
|
+
export function escapeMarkdownInline(value) {
|
|
33
|
+
return value
|
|
34
|
+
.replaceAll('&', '&')
|
|
35
|
+
.replaceAll('<', '<')
|
|
36
|
+
.replaceAll('>', '>')
|
|
37
|
+
.replace(/([\\`*_\[\]])/gu, '\\$1')
|
|
38
|
+
.replaceAll('\n', ' ');
|
|
39
|
+
}
|
|
40
|
+
export function formatDuration(durationMs) {
|
|
41
|
+
return durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
|
|
42
|
+
}
|
|
43
|
+
export function formatError(error) {
|
|
44
|
+
return error instanceof Error ? error.message : String(error);
|
|
45
|
+
}
|
package/dist/game-acceptance.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { escapeMarkdownInline, escapeMarkdownTable, formatDuration, formatError, readBoundedUtf8File, relativeOrAbsolute, writeEvidenceReportFiles, } from './evidence-io.js';
|
|
4
5
|
import { collectGameplayE2EPathEvidence, maximumGameplayE2EStates, readGameplayE2EPlan, resolveGameplayE2EPathInsideGameRoot, } from './gameplay-e2e.js';
|
|
5
6
|
export const defaultGameAcceptanceCommandTimeoutMs = 30 * 60 * 1000;
|
|
6
7
|
const defaultGameAcceptanceReleaseManifestFile = 'artifacts/release-manifest.json';
|
|
@@ -120,8 +121,12 @@ export function runGameAcceptance(input) {
|
|
|
120
121
|
const jsonFile = path.join(reportDir, 'acceptance-report.json');
|
|
121
122
|
const markdownFile = path.join(reportDir, 'acceptance-report.md');
|
|
122
123
|
mkdirSync(reportDir, { recursive: true });
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
writeEvidenceReportFiles({
|
|
125
|
+
jsonFile,
|
|
126
|
+
markdownFile,
|
|
127
|
+
report,
|
|
128
|
+
markdown: renderGameAcceptanceMarkdown(report),
|
|
129
|
+
});
|
|
125
130
|
return { report, jsonFile, markdownFile };
|
|
126
131
|
}
|
|
127
132
|
export function renderGameAcceptanceMarkdown(report) {
|
|
@@ -505,28 +510,6 @@ function validateCurrentPathEvidence(evidence, gameRoot, label) {
|
|
|
505
510
|
? null
|
|
506
511
|
: `Gameplay E2E ${label} hash does not match its current contents.`;
|
|
507
512
|
}
|
|
508
|
-
function relativeOrAbsolute(root, file) {
|
|
509
|
-
const relative = path.relative(root, file);
|
|
510
|
-
return relative.startsWith('..') || path.isAbsolute(relative) ? file : relative || '.';
|
|
511
|
-
}
|
|
512
|
-
function readBoundedUtf8File(file, maximumBytes) {
|
|
513
|
-
const buffer = Buffer.allocUnsafe(maximumBytes + 1);
|
|
514
|
-
const fileDescriptor = openSync(file, 'r');
|
|
515
|
-
let offset = 0;
|
|
516
|
-
try {
|
|
517
|
-
while (offset < buffer.length) {
|
|
518
|
-
const bytesRead = readSync(fileDescriptor, buffer, offset, buffer.length - offset, null);
|
|
519
|
-
if (bytesRead === 0) {
|
|
520
|
-
break;
|
|
521
|
-
}
|
|
522
|
-
offset += bytesRead;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
finally {
|
|
526
|
-
closeSync(fileDescriptor);
|
|
527
|
-
}
|
|
528
|
-
return offset > maximumBytes ? null : buffer.subarray(0, offset).toString('utf8');
|
|
529
|
-
}
|
|
530
513
|
function resolveRunnableStep(step) {
|
|
531
514
|
if (step.command === undefined || step.command.length === 0) {
|
|
532
515
|
return { ok: false, detail: `Acceptance step ${step.id} is missing command.` };
|
|
@@ -536,23 +519,6 @@ function resolveRunnableStep(step) {
|
|
|
536
519
|
}
|
|
537
520
|
return { ok: true, command: step.command, cwd: step.cwd };
|
|
538
521
|
}
|
|
539
|
-
function escapeMarkdownTable(value) {
|
|
540
|
-
return escapeMarkdownInline(value).replaceAll('|', '\\|');
|
|
541
|
-
}
|
|
542
|
-
function escapeMarkdownInline(value) {
|
|
543
|
-
return value
|
|
544
|
-
.replaceAll('&', '&')
|
|
545
|
-
.replaceAll('<', '<')
|
|
546
|
-
.replaceAll('>', '>')
|
|
547
|
-
.replace(/([\\`*_[\]])/gu, '\\$1')
|
|
548
|
-
.replaceAll('\n', ' ');
|
|
549
|
-
}
|
|
550
|
-
function formatDuration(durationMs) {
|
|
551
|
-
return durationMs < 1000 ? `${durationMs}ms` : `${(durationMs / 1000).toFixed(1)}s`;
|
|
552
|
-
}
|
|
553
|
-
function formatError(error) {
|
|
554
|
-
return error instanceof Error ? error.message : String(error);
|
|
555
|
-
}
|
|
556
522
|
function isTimeoutError(error) {
|
|
557
523
|
return 'code' in error && error.code === 'ETIMEDOUT';
|
|
558
524
|
}
|
package/dist/gameplay-e2e.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { closeSync, existsSync, lstatSync, mkdirSync, opendirSync, openSync, readFileSync, readlinkSync, readSync, rmSync,
|
|
2
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, opendirSync, openSync, readFileSync, readlinkSync, readSync, rmSync, } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { escapeMarkdownInline, escapeMarkdownTable, formatDuration, formatError as formatEvidenceError, relativeOrAbsolute, writeEvidenceReportFiles, } from './evidence-io.js';
|
|
4
5
|
export const defaultGameplayE2EReportFile = 'artifacts/gameplay-e2e/gameplay-e2e-report.json';
|
|
5
6
|
const gameplayStateIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
6
7
|
const gameplayKeyControlCharacterPattern = /[\u0000-\u001f\u007f]/u;
|
|
@@ -140,8 +141,12 @@ export async function runGameplayE2E(input) {
|
|
|
140
141
|
// Driver callbacks are untrusted and may have changed output paths while the run was active.
|
|
141
142
|
const verifiedJsonFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, jsonFile, 'Gameplay E2E report');
|
|
142
143
|
const verifiedMarkdownFile = resolveGameplayE2EPathInsideGameRoot(gameRoot, markdownFile, 'Gameplay E2E Markdown report');
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
writeEvidenceReportFiles({
|
|
145
|
+
jsonFile: verifiedJsonFile,
|
|
146
|
+
markdownFile: verifiedMarkdownFile,
|
|
147
|
+
report,
|
|
148
|
+
markdown: renderGameplayE2EMarkdown(report),
|
|
149
|
+
});
|
|
145
150
|
return { report, jsonFile: verifiedJsonFile, markdownFile: verifiedMarkdownFile };
|
|
146
151
|
}
|
|
147
152
|
export function renderGameplayE2EMarkdown(report) {
|
|
@@ -647,10 +652,6 @@ function readBoundedInteger(value, source, minimum, maximum) {
|
|
|
647
652
|
}
|
|
648
653
|
return result;
|
|
649
654
|
}
|
|
650
|
-
function relativeOrAbsolute(root, file) {
|
|
651
|
-
const relative = path.relative(root, file);
|
|
652
|
-
return relative.startsWith('..') || path.isAbsolute(relative) ? file : relative || '.';
|
|
653
|
-
}
|
|
654
655
|
function replaceFileExtension(file, extension) {
|
|
655
656
|
const currentExtension = path.extname(file);
|
|
656
657
|
return currentExtension.length === 0
|
|
@@ -662,23 +663,9 @@ function assertGameplayE2EJsonReportFile(file) {
|
|
|
662
663
|
throw new Error('Gameplay E2E JSON report file must not use a Markdown extension.');
|
|
663
664
|
}
|
|
664
665
|
}
|
|
665
|
-
function escapeMarkdownTable(value) {
|
|
666
|
-
return escapeMarkdownInline(value).replaceAll('|', '\\|');
|
|
667
|
-
}
|
|
668
|
-
function escapeMarkdownInline(value) {
|
|
669
|
-
return value
|
|
670
|
-
.replaceAll('&', '&')
|
|
671
|
-
.replaceAll('<', '<')
|
|
672
|
-
.replaceAll('>', '>')
|
|
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
666
|
function formatError(error) {
|
|
680
667
|
if (error instanceof GameplayE2EAggregatedError) {
|
|
681
668
|
return `${error.message} ${error.errors.map(formatError).join(' ')}`;
|
|
682
669
|
}
|
|
683
|
-
return
|
|
670
|
+
return formatEvidenceError(error);
|
|
684
671
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
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 { createBrowserGameplayE2EDriver, type BrowserGameplayE2EInspectInput, type BrowserGameplayE2EPage, type BrowserGameplayE2EScreenshotOptions, type BrowserGameplayE2EViewport, type CreateBrowserGameplayE2EDriverInput, } from './browser-gameplay-e2e.js';
|
|
2
3
|
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';
|
|
4
|
+
export { assertMicrosoftStorePwaCacheTransition, assertMicrosoftStorePwaPrecacheUrl, assertMicrosoftStorePwaReleaseEvidence, inspectMicrosoftStorePwaBrowserCacheTransition, microsoftStorePwaCacheSchema, readMicrosoftStorePwaBrowserReleaseEvidence, requestMicrosoftStorePwaBrowserUpdate, resolveMicrosoftStorePwaScopedCacheName, type AssertMicrosoftStorePwaCacheTransitionInput, type InspectMicrosoftStorePwaCacheTransitionInput, type MicrosoftStorePwaBrowserPage, type MicrosoftStorePwaCachedIndexObservation, type MicrosoftStorePwaCacheTransitionEvidence, type MicrosoftStorePwaCacheTransitionObservation, type MicrosoftStorePwaReleaseEvidence, } from './microsoft-store-pwa-e2e.js';
|
|
3
5
|
export { assertProductionTargetReadiness, type ProductionTargetReadinessInput, } from './production-target-readiness.js';
|
|
4
6
|
export declare function runMpgdCli(args: readonly string[]): Promise<void>;
|
|
5
7
|
export declare function runCreateGameCli(args: readonly string[]): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,9 @@ import { cli } from 'gunshi';
|
|
|
9
9
|
import { defaultGameAcceptanceCommandTimeoutMs, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, } from './game-acceptance.js';
|
|
10
10
|
import { resolveGameplayE2EReportFile } from './gameplay-e2e.js';
|
|
11
11
|
export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, } from './game-acceptance.js';
|
|
12
|
+
export { createBrowserGameplayE2EDriver, } from './browser-gameplay-e2e.js';
|
|
12
13
|
export { collectGameplayE2EPathEvidence, defaultGameplayE2EReportFile, maximumGameplayE2EStates, parseGameplayE2EPlan, readGameplayE2EPlan, renderGameplayE2EMarkdown, resolveGameplayE2EReportFile, runGameplayE2E, } from './gameplay-e2e.js';
|
|
14
|
+
export { assertMicrosoftStorePwaCacheTransition, assertMicrosoftStorePwaPrecacheUrl, assertMicrosoftStorePwaReleaseEvidence, inspectMicrosoftStorePwaBrowserCacheTransition, microsoftStorePwaCacheSchema, readMicrosoftStorePwaBrowserReleaseEvidence, requestMicrosoftStorePwaBrowserUpdate, resolveMicrosoftStorePwaScopedCacheName, } from './microsoft-store-pwa-e2e.js';
|
|
13
15
|
export { assertProductionTargetReadiness, } from './production-target-readiness.js';
|
|
14
16
|
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
|
|
15
17
|
const packageRoot = resolvePackageRoot(sourceDir);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export declare const microsoftStorePwaCacheSchema: 'microsoft-store-offline-v1';
|
|
2
|
+
export interface MicrosoftStorePwaReleaseEvidence {
|
|
3
|
+
readonly schemaVersion: 1;
|
|
4
|
+
readonly cacheSchema: typeof microsoftStorePwaCacheSchema;
|
|
5
|
+
readonly pwaId: string;
|
|
6
|
+
readonly appVersion: string;
|
|
7
|
+
readonly buildId: string;
|
|
8
|
+
readonly sourceGitSha: string;
|
|
9
|
+
readonly kitGitSha: string;
|
|
10
|
+
readonly configTarget: 'microsoft-store';
|
|
11
|
+
readonly revision: string;
|
|
12
|
+
readonly cachePrefix: string;
|
|
13
|
+
readonly cacheNamePattern: string;
|
|
14
|
+
readonly precacheUrls: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The minimal Playwright-compatible page surface used by the PWA helpers.
|
|
18
|
+
* Consumers keep Playwright and browser credentials in their own harness.
|
|
19
|
+
*/
|
|
20
|
+
export interface MicrosoftStorePwaBrowserPage {
|
|
21
|
+
evaluate<TResult>(pageFunction: () => TResult | Promise<TResult>): Promise<TResult>;
|
|
22
|
+
evaluate<TResult, TArgument>(pageFunction: (argument: TArgument) => TResult | Promise<TResult>, argument: TArgument): Promise<TResult>;
|
|
23
|
+
}
|
|
24
|
+
export interface MicrosoftStorePwaCachedIndexObservation {
|
|
25
|
+
readonly referencesReleaseA: boolean;
|
|
26
|
+
readonly referencesReleaseB: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface MicrosoftStorePwaCacheTransitionObservation {
|
|
29
|
+
readonly registrationScope: string;
|
|
30
|
+
readonly scopedCacheNames: {
|
|
31
|
+
readonly a: string;
|
|
32
|
+
readonly b: string;
|
|
33
|
+
};
|
|
34
|
+
readonly cacheNames: readonly string[];
|
|
35
|
+
readonly cachedReleaseBIndex: MicrosoftStorePwaCachedIndexObservation;
|
|
36
|
+
}
|
|
37
|
+
export interface InspectMicrosoftStorePwaCacheTransitionInput<TPage extends MicrosoftStorePwaBrowserPage = MicrosoftStorePwaBrowserPage> {
|
|
38
|
+
readonly page: TPage;
|
|
39
|
+
readonly releaseA: MicrosoftStorePwaReleaseEvidence;
|
|
40
|
+
readonly releaseB: MicrosoftStorePwaReleaseEvidence;
|
|
41
|
+
readonly releaseAIndexMarker: string;
|
|
42
|
+
readonly releaseBIndexMarker: string;
|
|
43
|
+
readonly releaseBIndexRequestCount: number;
|
|
44
|
+
readonly preservedCacheNames?: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
export interface AssertMicrosoftStorePwaCacheTransitionInput extends MicrosoftStorePwaCacheTransitionObservation {
|
|
47
|
+
readonly releaseA: MicrosoftStorePwaReleaseEvidence;
|
|
48
|
+
readonly releaseB: MicrosoftStorePwaReleaseEvidence;
|
|
49
|
+
readonly releaseBIndexRequestCount: number;
|
|
50
|
+
readonly preservedCacheNames?: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
export interface MicrosoftStorePwaCacheTransitionEvidence extends MicrosoftStorePwaCacheTransitionObservation {
|
|
53
|
+
readonly checks: {
|
|
54
|
+
readonly releaseBActivated: true;
|
|
55
|
+
readonly releaseACacheRemoved: true;
|
|
56
|
+
readonly preservedCachesRetained: true;
|
|
57
|
+
readonly releaseBIndexCached: true;
|
|
58
|
+
readonly staleReleaseAIndexExcluded: true;
|
|
59
|
+
readonly releaseBInstallBypassedHttpCache: true;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export declare function readMicrosoftStorePwaBrowserReleaseEvidence<TPage extends MicrosoftStorePwaBrowserPage>(page: TPage, releaseUrl?: string): Promise<MicrosoftStorePwaReleaseEvidence>;
|
|
63
|
+
export declare function requestMicrosoftStorePwaBrowserUpdate<TPage extends MicrosoftStorePwaBrowserPage>(page: TPage): Promise<void>;
|
|
64
|
+
export declare function inspectMicrosoftStorePwaBrowserCacheTransition<TPage extends MicrosoftStorePwaBrowserPage>(input: InspectMicrosoftStorePwaCacheTransitionInput<TPage>): Promise<MicrosoftStorePwaCacheTransitionEvidence>;
|
|
65
|
+
export declare function assertMicrosoftStorePwaCacheTransition(input: AssertMicrosoftStorePwaCacheTransitionInput): MicrosoftStorePwaCacheTransitionEvidence;
|
|
66
|
+
export declare function resolveMicrosoftStorePwaScopedCacheName(evidenceInput: MicrosoftStorePwaReleaseEvidence, registrationScopeInput: string): string;
|
|
67
|
+
export declare function assertMicrosoftStorePwaReleaseEvidence(input: unknown): MicrosoftStorePwaReleaseEvidence;
|
|
68
|
+
export declare function assertMicrosoftStorePwaPrecacheUrl(input: unknown): string;
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export const microsoftStorePwaCacheSchema = 'microsoft-store-offline-v1';
|
|
3
|
+
export async function readMicrosoftStorePwaBrowserReleaseEvidence(page, releaseUrl) {
|
|
4
|
+
const explicitReleaseUrl = releaseUrl === undefined
|
|
5
|
+
? undefined
|
|
6
|
+
: requireNonEmptyString(releaseUrl, 'PWA release evidence URL');
|
|
7
|
+
const input = await page.evaluate(async (browserInput) => {
|
|
8
|
+
let requestedUrl = browserInput.releaseUrl;
|
|
9
|
+
if (requestedUrl === undefined) {
|
|
10
|
+
const browser = globalThis;
|
|
11
|
+
const registration = await browser.navigator.serviceWorker.getRegistration();
|
|
12
|
+
if (registration === undefined) {
|
|
13
|
+
throw new Error('Missing service worker registration before reading release evidence.');
|
|
14
|
+
}
|
|
15
|
+
requestedUrl = new URL('./pwa-release.json', registration.scope).href;
|
|
16
|
+
}
|
|
17
|
+
const response = await fetch(requestedUrl);
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error(`Failed to read PWA release evidence: ${String(response.status)}.`);
|
|
20
|
+
}
|
|
21
|
+
return response.json();
|
|
22
|
+
}, { releaseUrl: explicitReleaseUrl });
|
|
23
|
+
return assertMicrosoftStorePwaReleaseEvidence(input);
|
|
24
|
+
}
|
|
25
|
+
export async function requestMicrosoftStorePwaBrowserUpdate(page) {
|
|
26
|
+
await page.evaluate(async () => {
|
|
27
|
+
const browser = globalThis;
|
|
28
|
+
const registration = await browser.navigator.serviceWorker.getRegistration();
|
|
29
|
+
if (registration === undefined) {
|
|
30
|
+
throw new Error('Missing service worker registration before update.');
|
|
31
|
+
}
|
|
32
|
+
await registration.update();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export async function inspectMicrosoftStorePwaBrowserCacheTransition(input) {
|
|
36
|
+
const releaseA = assertMicrosoftStorePwaReleaseEvidence(input.releaseA);
|
|
37
|
+
const releaseB = assertMicrosoftStorePwaReleaseEvidence(input.releaseB);
|
|
38
|
+
const releaseAIndexMarker = requireNonEmptyString(input.releaseAIndexMarker, 'release A index marker');
|
|
39
|
+
const releaseBIndexMarker = requireNonEmptyString(input.releaseBIndexMarker, 'release B index marker');
|
|
40
|
+
const observation = await input.page.evaluate(async (browserInput) => {
|
|
41
|
+
const browser = globalThis;
|
|
42
|
+
const registration = await browser.navigator.serviceWorker.ready;
|
|
43
|
+
const encodedScope = encodeURIComponent(registration.scope);
|
|
44
|
+
const scopedCacheNames = {
|
|
45
|
+
a: browserInput.releaseACacheNamePattern.replace('{scope}', encodedScope),
|
|
46
|
+
b: browserInput.releaseBCacheNamePattern.replace('{scope}', encodedScope),
|
|
47
|
+
};
|
|
48
|
+
const cacheNames = await browser.caches.keys();
|
|
49
|
+
const releaseBCache = await browser.caches.open(scopedCacheNames.b);
|
|
50
|
+
const cachedIndexUrl = new URL('./index.html', registration.scope).href;
|
|
51
|
+
const cachedIndexResponse = await releaseBCache.match(cachedIndexUrl);
|
|
52
|
+
if (cachedIndexResponse === undefined) {
|
|
53
|
+
throw new Error('Release B cache is missing index.html.');
|
|
54
|
+
}
|
|
55
|
+
const cachedIndex = await cachedIndexResponse.text();
|
|
56
|
+
return {
|
|
57
|
+
registrationScope: registration.scope,
|
|
58
|
+
scopedCacheNames,
|
|
59
|
+
cacheNames,
|
|
60
|
+
cachedReleaseBIndex: {
|
|
61
|
+
referencesReleaseA: cachedIndex.includes(browserInput.releaseAIndexMarker),
|
|
62
|
+
referencesReleaseB: cachedIndex.includes(browserInput.releaseBIndexMarker),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}, {
|
|
66
|
+
releaseACacheNamePattern: releaseA.cacheNamePattern,
|
|
67
|
+
releaseBCacheNamePattern: releaseB.cacheNamePattern,
|
|
68
|
+
releaseAIndexMarker,
|
|
69
|
+
releaseBIndexMarker,
|
|
70
|
+
});
|
|
71
|
+
return assertMicrosoftStorePwaCacheTransition({
|
|
72
|
+
releaseA,
|
|
73
|
+
releaseB,
|
|
74
|
+
releaseBIndexRequestCount: input.releaseBIndexRequestCount,
|
|
75
|
+
...(input.preservedCacheNames === undefined
|
|
76
|
+
? {}
|
|
77
|
+
: { preservedCacheNames: input.preservedCacheNames }),
|
|
78
|
+
...observation,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export function assertMicrosoftStorePwaCacheTransition(input) {
|
|
82
|
+
const releaseA = assertMicrosoftStorePwaReleaseEvidence(input.releaseA);
|
|
83
|
+
const releaseB = assertMicrosoftStorePwaReleaseEvidence(input.releaseB);
|
|
84
|
+
const expectedCacheNames = {
|
|
85
|
+
a: resolveMicrosoftStorePwaScopedCacheName(releaseA, input.registrationScope),
|
|
86
|
+
b: resolveMicrosoftStorePwaScopedCacheName(releaseB, input.registrationScope),
|
|
87
|
+
};
|
|
88
|
+
if (releaseA.pwaId !== releaseB.pwaId || releaseA.cachePrefix !== releaseB.cachePrefix) {
|
|
89
|
+
throw new Error('PWA cache transition releases must identify the same application.');
|
|
90
|
+
}
|
|
91
|
+
if (releaseA.buildId === releaseB.buildId || releaseA.revision === releaseB.revision) {
|
|
92
|
+
throw new Error('PWA cache transition releases must use distinct builds and revisions.');
|
|
93
|
+
}
|
|
94
|
+
if (input.scopedCacheNames.a !== expectedCacheNames.a
|
|
95
|
+
|| input.scopedCacheNames.b !== expectedCacheNames.b) {
|
|
96
|
+
throw new Error('Observed PWA cache names do not match the registration scope.');
|
|
97
|
+
}
|
|
98
|
+
if (!input.cacheNames.includes(expectedCacheNames.b)) {
|
|
99
|
+
throw new Error('Release B cache must exist after activation.');
|
|
100
|
+
}
|
|
101
|
+
if (input.cacheNames.includes(expectedCacheNames.a)) {
|
|
102
|
+
throw new Error('Release A cache must be deleted after release B activates.');
|
|
103
|
+
}
|
|
104
|
+
for (const cacheName of input.preservedCacheNames ?? []) {
|
|
105
|
+
if (!input.cacheNames.includes(requireNonEmptyString(cacheName, 'preserved cache name'))) {
|
|
106
|
+
throw new Error(`Activating release B removed unrelated cache ${cacheName}.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!input.cachedReleaseBIndex.referencesReleaseB) {
|
|
110
|
+
throw new Error('Release B cache must contain release B index.html.');
|
|
111
|
+
}
|
|
112
|
+
if (input.cachedReleaseBIndex.referencesReleaseA) {
|
|
113
|
+
throw new Error('Release B cache must not contain release A index.html.');
|
|
114
|
+
}
|
|
115
|
+
if (!Number.isSafeInteger(input.releaseBIndexRequestCount) || input.releaseBIndexRequestCount <= 0) {
|
|
116
|
+
throw new Error('Release B install must request index.html while bypassing the HTTP cache.');
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
registrationScope: requireRegistrationScope(input.registrationScope),
|
|
120
|
+
scopedCacheNames: expectedCacheNames,
|
|
121
|
+
cacheNames: [...input.cacheNames],
|
|
122
|
+
cachedReleaseBIndex: { ...input.cachedReleaseBIndex },
|
|
123
|
+
checks: {
|
|
124
|
+
releaseBActivated: true,
|
|
125
|
+
releaseACacheRemoved: true,
|
|
126
|
+
preservedCachesRetained: true,
|
|
127
|
+
releaseBIndexCached: true,
|
|
128
|
+
staleReleaseAIndexExcluded: true,
|
|
129
|
+
releaseBInstallBypassedHttpCache: true,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export function resolveMicrosoftStorePwaScopedCacheName(evidenceInput, registrationScopeInput) {
|
|
134
|
+
const evidence = assertMicrosoftStorePwaReleaseEvidence(evidenceInput);
|
|
135
|
+
const registrationScope = requireRegistrationScope(registrationScopeInput);
|
|
136
|
+
return evidence.cacheNamePattern.replace('{scope}', encodeURIComponent(registrationScope));
|
|
137
|
+
}
|
|
138
|
+
export function assertMicrosoftStorePwaReleaseEvidence(input) {
|
|
139
|
+
assertRecord(input, 'PWA release evidence');
|
|
140
|
+
if (input.schemaVersion !== 1 || input.cacheSchema !== microsoftStorePwaCacheSchema) {
|
|
141
|
+
throw new Error('Unsupported Microsoft Store PWA release evidence schema.');
|
|
142
|
+
}
|
|
143
|
+
if (input.configTarget !== 'microsoft-store') {
|
|
144
|
+
throw new Error('PWA release evidence must target microsoft-store.');
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(input.precacheUrls)) {
|
|
147
|
+
throw new Error('PWA release evidence precacheUrls must be an array.');
|
|
148
|
+
}
|
|
149
|
+
const pwaId = requireGameSpecificPwaId(input.pwaId);
|
|
150
|
+
const revision = requireRevision(input.revision);
|
|
151
|
+
const expectedCachePrefix = `mpgd-pwa-${createHash('sha256')
|
|
152
|
+
.update(pwaId)
|
|
153
|
+
.digest('hex')
|
|
154
|
+
.slice(0, 12)}-`;
|
|
155
|
+
const cachePrefix = requireNonEmptyString(input.cachePrefix, 'PWA cache prefix');
|
|
156
|
+
const cacheNamePattern = requireNonEmptyString(input.cacheNamePattern, 'PWA cache name pattern');
|
|
157
|
+
const precacheUrls = input.precacheUrls.map(assertMicrosoftStorePwaPrecacheUrl);
|
|
158
|
+
if (cachePrefix !== expectedCachePrefix) {
|
|
159
|
+
throw new Error('PWA cache prefix is inconsistent with its application ID.');
|
|
160
|
+
}
|
|
161
|
+
if (cacheNamePattern !== `${cachePrefix}{scope}-${revision}`) {
|
|
162
|
+
throw new Error('PWA cache name pattern is inconsistent.');
|
|
163
|
+
}
|
|
164
|
+
if (precacheUrls.length !== new Set(precacheUrls).size
|
|
165
|
+
|| precacheUrls.some((url, index) => index > 0 && compareCodeUnits(precacheUrls[index - 1] ?? '', url) > 0)) {
|
|
166
|
+
throw new Error('PWA precache URLs must be unique and sorted.');
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
schemaVersion: 1,
|
|
170
|
+
cacheSchema: microsoftStorePwaCacheSchema,
|
|
171
|
+
pwaId,
|
|
172
|
+
appVersion: requireNonEmptyString(input.appVersion, 'PWA app version'),
|
|
173
|
+
buildId: requireNonEmptyString(input.buildId, 'PWA build ID'),
|
|
174
|
+
sourceGitSha: requireGitSha(input.sourceGitSha, 'PWA source Git SHA'),
|
|
175
|
+
kitGitSha: requireGitSha(input.kitGitSha, 'PWA kit Git SHA'),
|
|
176
|
+
configTarget: 'microsoft-store',
|
|
177
|
+
revision,
|
|
178
|
+
cachePrefix,
|
|
179
|
+
cacheNamePattern,
|
|
180
|
+
precacheUrls,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export function assertMicrosoftStorePwaPrecacheUrl(input) {
|
|
184
|
+
const url = requireNonEmptyString(input, 'PWA precache URL');
|
|
185
|
+
if (!url.startsWith('./')
|
|
186
|
+
|| url.includes('\\')
|
|
187
|
+
|| url.includes('?')
|
|
188
|
+
|| url.includes('#')
|
|
189
|
+
|| hasDotSegment(url)) {
|
|
190
|
+
throw new Error(`Unsafe PWA precache URL: ${url}`);
|
|
191
|
+
}
|
|
192
|
+
return url;
|
|
193
|
+
}
|
|
194
|
+
function hasDotSegment(url) {
|
|
195
|
+
for (const segment of url.slice(2).split('/')) {
|
|
196
|
+
let decoded;
|
|
197
|
+
try {
|
|
198
|
+
decoded = decodeURIComponent(segment);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
if (decoded === '.' || decoded === '..') {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
function requireGameSpecificPwaId(input) {
|
|
210
|
+
const pwaId = requireNonEmptyString(input, 'PWA ID');
|
|
211
|
+
if (pwaId === '.' || pwaId === './' || pwaId === '/') {
|
|
212
|
+
throw new Error('Microsoft Store PWA manifest id must be game-specific.');
|
|
213
|
+
}
|
|
214
|
+
return pwaId;
|
|
215
|
+
}
|
|
216
|
+
function requireRegistrationScope(input) {
|
|
217
|
+
const scope = requireNonEmptyString(input, 'service worker registration scope');
|
|
218
|
+
let parsed;
|
|
219
|
+
try {
|
|
220
|
+
parsed = new URL(scope);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
throw new Error('Service worker registration scope must be an absolute URL.');
|
|
224
|
+
}
|
|
225
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
226
|
+
throw new Error('Service worker registration scope must use HTTP or HTTPS.');
|
|
227
|
+
}
|
|
228
|
+
return scope;
|
|
229
|
+
}
|
|
230
|
+
function requireRevision(input) {
|
|
231
|
+
const revision = requireNonEmptyString(input, 'PWA revision');
|
|
232
|
+
if (!/^[0-9a-f]{16}$/u.test(revision)) {
|
|
233
|
+
throw new Error('PWA revision must be a 16-character hexadecimal digest.');
|
|
234
|
+
}
|
|
235
|
+
return revision;
|
|
236
|
+
}
|
|
237
|
+
function requireGitSha(input, label) {
|
|
238
|
+
const value = requireNonEmptyString(input, label);
|
|
239
|
+
if (!/^[0-9a-f]{40}$/u.test(value)) {
|
|
240
|
+
throw new Error(`${label} must be a full 40-character lowercase Git SHA.`);
|
|
241
|
+
}
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
function requireNonEmptyString(input, label) {
|
|
245
|
+
if (typeof input !== 'string' || input.trim().length === 0) {
|
|
246
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
247
|
+
}
|
|
248
|
+
return input.trim();
|
|
249
|
+
}
|
|
250
|
+
function assertRecord(input, label) {
|
|
251
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
252
|
+
throw new Error(`${label} must be an object.`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function compareCodeUnits(left, right) {
|
|
256
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
257
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -50,17 +50,17 @@
|
|
|
50
50
|
"ttsc": "0.18.4",
|
|
51
51
|
"typescript": "7.0.2",
|
|
52
52
|
"@mpgd/adapter-ait": "0.4.3",
|
|
53
|
-
"@mpgd/adapter-browser": "0.4.3",
|
|
54
53
|
"@mpgd/adapter-capacitor": "0.4.3",
|
|
55
|
-
"@mpgd/adapter-devvit": "0.8.
|
|
54
|
+
"@mpgd/adapter-devvit": "0.8.2",
|
|
55
|
+
"@mpgd/adapter-browser": "0.4.3",
|
|
56
56
|
"@mpgd/analytics": "0.3.6",
|
|
57
57
|
"@mpgd/bridge": "0.6.0",
|
|
58
|
-
"@mpgd/catalog": "0.4.0",
|
|
59
|
-
"@mpgd/game-services": "0.8.1",
|
|
60
|
-
"@mpgd/platform": "0.6.0",
|
|
61
58
|
"@mpgd/i18n": "0.5.2",
|
|
59
|
+
"@mpgd/catalog": "0.4.0",
|
|
60
|
+
"@mpgd/target-config": "0.7.0",
|
|
61
|
+
"@mpgd/game-services": "0.8.2",
|
|
62
62
|
"@mpgd/phaser-assets": "0.4.1",
|
|
63
|
-
"@mpgd/
|
|
63
|
+
"@mpgd/platform": "0.6.0"
|
|
64
64
|
},
|
|
65
65
|
"main": "./dist/index.js",
|
|
66
66
|
"types": "./dist/index.d.ts",
|