@playdrop/playdrop-cli 0.12.38 → 0.12.39

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.
Files changed (34) hide show
  1. package/config/client-meta.json +2 -2
  2. package/dist/apps/build.d.ts +3 -1
  3. package/dist/apps/build.js +4 -0
  4. package/dist/apps/loadCheck.d.ts +9 -2
  5. package/dist/apps/loadCheck.js +167 -32
  6. package/dist/apps/surfaceProfiles.d.ts +10 -0
  7. package/dist/apps/surfaceProfiles.js +64 -0
  8. package/dist/apps/upload.js +2 -0
  9. package/dist/catalogue.d.ts +7 -1
  10. package/dist/catalogue.js +27 -0
  11. package/dist/commands/check.d.ts +1 -0
  12. package/dist/commands/check.js +68 -2
  13. package/dist/commands/upload.d.ts +1 -0
  14. package/dist/commands/upload.js +18 -0
  15. package/dist/commands/worker/runtime.d.ts +6 -1
  16. package/dist/commands/worker/runtime.js +20 -3
  17. package/dist/commands/worker.d.ts +23 -3
  18. package/dist/commands/worker.js +98 -5
  19. package/dist/index.js +2 -0
  20. package/node_modules/@playdrop/config/client-meta.json +2 -2
  21. package/node_modules/@playdrop/types/dist/api.d.ts +15 -0
  22. package/node_modules/@playdrop/types/dist/api.d.ts.map +1 -1
  23. package/node_modules/@playdrop/types/dist/app-playtest-tape.d.ts +49 -0
  24. package/node_modules/@playdrop/types/dist/app-playtest-tape.d.ts.map +1 -0
  25. package/node_modules/@playdrop/types/dist/app-playtest-tape.js +242 -0
  26. package/node_modules/@playdrop/types/dist/index.d.ts +1 -0
  27. package/node_modules/@playdrop/types/dist/index.d.ts.map +1 -1
  28. package/node_modules/@playdrop/types/dist/index.js +2 -0
  29. package/node_modules/@playdrop/types/dist/instrument-evidence.d.ts +3 -0
  30. package/node_modules/@playdrop/types/dist/instrument-evidence.d.ts.map +1 -1
  31. package/node_modules/@playdrop/types/dist/instrument-evidence.js +56 -5
  32. package/node_modules/@playdrop/types/dist/version.d.ts +5 -0
  33. package/node_modules/@playdrop/types/dist/version.d.ts.map +1 -1
  34. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
- "version": "0.12.38",
2
+ "version": "0.12.39",
3
3
  "build": 1,
4
- "runtimeSdkVersion": "0.12.38",
4
+ "runtimeSdkVersion": "0.12.39",
5
5
  "runtimeSdkBuild": 1,
6
6
  "clients": {
7
7
  "all": {
@@ -1,4 +1,4 @@
1
- import type { AppSurface, AppType } from '@playdrop/types';
1
+ import type { AppPlaytestTapes, AppSurface, AppType } from '@playdrop/types';
2
2
  import type { AppTask } from '../catalogue';
3
3
  export type AppBuildMetadata = {
4
4
  displayName?: string;
@@ -7,6 +7,8 @@ export type AppBuildMetadata = {
7
7
  emoji?: string | null;
8
8
  color?: string | null;
9
9
  surfaceTargets?: AppSurface[];
10
+ primarySurface?: AppSurface;
11
+ playtestTapes?: AppPlaytestTapes;
10
12
  entryPoint?: string;
11
13
  };
12
14
  export type AppBuildArtifacts = {
@@ -606,6 +606,10 @@ async function buildApp(task) {
606
606
  if (task.surfaceTargets && task.surfaceTargets.length > 0) {
607
607
  metadata.surfaceTargets = task.surfaceTargets;
608
608
  }
609
+ if (task.primarySurface)
610
+ metadata.primarySurface = task.primarySurface;
611
+ if (task.playtestTapes)
612
+ metadata.playtestTapes = task.playtestTapes;
609
613
  metadata.entryPoint = bundleArchive.entryPoint;
610
614
  return {
611
615
  bundleFile,
@@ -1,8 +1,9 @@
1
1
  import type { ApiClient } from '@playdrop/api-client';
2
- import type { UserResponse } from '@playdrop/types';
3
- import type { Frame, Page } from 'playwright-core';
2
+ import { type AppPlaytestTape, type AppSurface, type UserResponse } from '@playdrop/types';
3
+ import type { BrowserContextOptions, Frame, Page } from 'playwright-core';
4
4
  import type { AppTask } from '../catalogue';
5
5
  import { type TaskCaptureSession } from '../appUrls';
6
+ export { PLAYDROP_SURFACE_CONTEXT_OPTIONS } from './surfaceProfiles';
6
7
  export type HostedLoadCheckResult = {
7
8
  status: 'PASSED' | 'FAILED';
8
9
  errorCode: string | null;
@@ -41,6 +42,8 @@ export type ProjectCheckAction = {
41
42
  type: 'wait';
42
43
  ms: number;
43
44
  };
45
+ export declare function resolveLoadCheckSurface(surfaceTargets: unknown, primarySurface?: unknown): AppSurface;
46
+ export declare function cloneLoadCheckContextOptions(surface: AppSurface): BrowserContextOptions;
44
47
  export declare function isSoftwareWebGlRenderer(renderer: string | null | undefined): boolean;
45
48
  export declare function normalizeProjectCheckActions(value: unknown): ProjectCheckAction[];
46
49
  export declare function focusProjectCheckGameFrame(page: Pick<Page, 'mouse'>, frame: Frame): Promise<{
@@ -49,6 +52,7 @@ export declare function focusProjectCheckGameFrame(page: Pick<Page, 'mouse'>, fr
49
52
  width: number;
50
53
  height: number;
51
54
  }>;
55
+ export declare function dispatchPlaytestTapeToFrame(page: Page, frame: Frame, surface: AppSurface, tape: AppPlaytestTape): Promise<void>;
52
56
  export declare function dispatchProjectCheckActionsToFrame(page: Page, frame: Frame, actions: readonly ProjectCheckAction[]): Promise<void>;
53
57
  export declare function formatHostedLoadCheckFailure(taskName: string, result: HostedLoadCheckResult, scope?: 'local' | 'staged' | 'final'): string;
54
58
  export declare function redactHostedLoadCheckSecrets(text: string, sourceUrl: string): string;
@@ -66,6 +70,9 @@ export declare function runLocalHostedLoadCheck(input: {
66
70
  allowUnregisteredViewerLaunch?: boolean;
67
71
  screenshotPath?: string | null;
68
72
  actions?: ProjectCheckAction[];
73
+ surface?: AppSurface;
74
+ playtestTape?: AppPlaytestTape;
75
+ postReadyWaitMs?: number;
69
76
  }): Promise<HostedLoadCheckResult>;
70
77
  export declare function runUploadedHostedLoadCheck(input: {
71
78
  client: ApiClient;
@@ -1,13 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLAYDROP_SURFACE_CONTEXT_OPTIONS = void 0;
4
+ exports.resolveLoadCheckSurface = resolveLoadCheckSurface;
5
+ exports.cloneLoadCheckContextOptions = cloneLoadCheckContextOptions;
3
6
  exports.isSoftwareWebGlRenderer = isSoftwareWebGlRenderer;
4
7
  exports.normalizeProjectCheckActions = normalizeProjectCheckActions;
5
8
  exports.focusProjectCheckGameFrame = focusProjectCheckGameFrame;
9
+ exports.dispatchPlaytestTapeToFrame = dispatchPlaytestTapeToFrame;
6
10
  exports.dispatchProjectCheckActionsToFrame = dispatchProjectCheckActionsToFrame;
7
11
  exports.formatHostedLoadCheckFailure = formatHostedLoadCheckFailure;
8
12
  exports.redactHostedLoadCheckSecrets = redactHostedLoadCheckSecrets;
9
13
  exports.runLocalHostedLoadCheck = runLocalHostedLoadCheck;
10
14
  exports.runUploadedHostedLoadCheck = runUploadedHostedLoadCheck;
15
+ const types_1 = require("@playdrop/types");
11
16
  const promises_1 = require("node:fs/promises");
12
17
  const node_path_1 = require("node:path");
13
18
  const appUrls_1 = require("../appUrls");
@@ -19,33 +24,13 @@ const devAuth_1 = require("../devAuth");
19
24
  const playwright_1 = require("../playwright");
20
25
  const sessionCookie_1 = require("../sessionCookie");
21
26
  const registration_1 = require("./registration");
27
+ const surfaceProfiles_1 = require("./surfaceProfiles");
28
+ var surfaceProfiles_2 = require("./surfaceProfiles");
29
+ Object.defineProperty(exports, "PLAYDROP_SURFACE_CONTEXT_OPTIONS", { enumerable: true, get: function () { return surfaceProfiles_2.PLAYDROP_SURFACE_CONTEXT_OPTIONS; } });
22
30
  const FRAME_SELECTOR = 'iframe[title="Game"]';
23
31
  const DEFAULT_HOSTED_LOAD_TIMEOUT_MS = 15000;
24
32
  const POST_READY_SETTLE_MS = 750;
25
- const MOBILE_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1';
26
33
  const LOAD_CHECK_SURFACE_ORDER = ['DESKTOP', 'MOBILE_LANDSCAPE', 'MOBILE_PORTRAIT'];
27
- const LOAD_CHECK_SURFACE_CONTEXT_OPTIONS = {
28
- DESKTOP: {
29
- viewport: { width: 1280, height: 720 },
30
- deviceScaleFactor: 1,
31
- isMobile: false,
32
- hasTouch: false,
33
- },
34
- MOBILE_LANDSCAPE: {
35
- viewport: { width: 896, height: 414 },
36
- deviceScaleFactor: 2,
37
- isMobile: true,
38
- hasTouch: true,
39
- userAgent: MOBILE_USER_AGENT,
40
- },
41
- MOBILE_PORTRAIT: {
42
- viewport: { width: 414, height: 896 },
43
- deviceScaleFactor: 2,
44
- isMobile: true,
45
- hasTouch: true,
46
- userAgent: MOBILE_USER_AGENT,
47
- },
48
- };
49
34
  const GOOGLE_TELEMETRY_HOSTS = [
50
35
  'analytics.google.com',
51
36
  'googletagmanager.com',
@@ -189,8 +174,12 @@ function normalizeLoadCheckSurfaceTargets(value) {
189
174
  }
190
175
  return Array.from(new Set(value.map(normalizeLoadCheckSurface).filter((entry) => Boolean(entry))));
191
176
  }
192
- function resolveLoadCheckSurface(surfaceTargets) {
177
+ function resolveLoadCheckSurface(surfaceTargets, primarySurface) {
193
178
  const targets = normalizeLoadCheckSurfaceTargets(surfaceTargets);
179
+ const primary = normalizeLoadCheckSurface(primarySurface);
180
+ if (primary && targets.includes(primary)) {
181
+ return primary;
182
+ }
194
183
  for (const surface of LOAD_CHECK_SURFACE_ORDER) {
195
184
  if (targets.includes(surface)) {
196
185
  return surface;
@@ -199,11 +188,7 @@ function resolveLoadCheckSurface(surfaceTargets) {
199
188
  return 'DESKTOP';
200
189
  }
201
190
  function cloneLoadCheckContextOptions(surface) {
202
- const source = LOAD_CHECK_SURFACE_CONTEXT_OPTIONS[surface];
203
- return {
204
- ...source,
205
- viewport: source.viewport ? { ...source.viewport } : source.viewport,
206
- };
191
+ return (0, surfaceProfiles_1.clonePlaydropSurfaceContextOptions)(surface);
207
192
  }
208
193
  function resolvePostAuthHostedLaunchState(controllerMode) {
209
194
  return controllerMode === 'REQUIRED' ? 'controller_required' : 'ready';
@@ -329,6 +314,131 @@ async function focusProjectCheckGameFrame(page, frame) {
329
314
  await element.dispose().catch(() => { });
330
315
  }
331
316
  }
317
+ async function prepareProjectCheckGameFrame(frame) {
318
+ const element = await frame.frameElement();
319
+ try {
320
+ await element.scrollIntoViewIfNeeded().catch(() => { });
321
+ const box = await element.boundingBox();
322
+ if (!box) {
323
+ throw new Error('project_check_game_frame_not_visible');
324
+ }
325
+ await element.focus().catch(() => { });
326
+ await frame.evaluate(() => {
327
+ window.focus();
328
+ if (document.body) {
329
+ document.body.tabIndex = document.body.tabIndex >= 0 ? document.body.tabIndex : -1;
330
+ document.body.focus();
331
+ }
332
+ }).catch(() => { });
333
+ return box;
334
+ }
335
+ finally {
336
+ await element.dispose().catch(() => { });
337
+ }
338
+ }
339
+ function resolveTapePoint(box, event) {
340
+ return {
341
+ x: box.x + (event.x * box.width),
342
+ y: box.y + (event.y * box.height),
343
+ };
344
+ }
345
+ async function dispatchTouchPoints(session, type, points) {
346
+ await session.send('Input.dispatchTouchEvent', {
347
+ type,
348
+ touchPoints: Array.from(points.entries()).map(([id, point]) => ({
349
+ id,
350
+ x: point.x,
351
+ y: point.y,
352
+ radiusX: 1,
353
+ radiusY: 1,
354
+ force: 1,
355
+ })),
356
+ });
357
+ }
358
+ async function dispatchPlaytestTapeToFrame(page, frame, surface, tape) {
359
+ const box = await prepareProjectCheckGameFrame(frame);
360
+ const mobile = surface !== 'DESKTOP';
361
+ const session = mobile ? await page.context().newCDPSession(page) : null;
362
+ const touchPoints = new Map();
363
+ const startedAt = Date.now();
364
+ try {
365
+ for (const event of tape.events) {
366
+ const waitMs = event.atMs - (Date.now() - startedAt);
367
+ if (waitMs > 0) {
368
+ await page.waitForTimeout(waitMs);
369
+ }
370
+ if (event.type === 'keyDown') {
371
+ await page.keyboard.down(event.key);
372
+ continue;
373
+ }
374
+ if (event.type === 'keyUp') {
375
+ await page.keyboard.up(event.key);
376
+ continue;
377
+ }
378
+ if (!('x' in event) || !('y' in event)) {
379
+ throw new Error(`project_check_playtest_event_unsupported:${event.type}`);
380
+ }
381
+ const point = resolveTapePoint(box, event);
382
+ const pointerId = 'pointerId' in event ? event.pointerId ?? 0 : 0;
383
+ if (event.type === 'tap') {
384
+ if (mobile && session) {
385
+ touchPoints.set(pointerId, point);
386
+ await dispatchTouchPoints(session, 'touchStart', touchPoints);
387
+ await page.waitForTimeout(event.durationMs ?? types_1.APP_PLAYTEST_DEFAULT_TAP_DURATION_MS);
388
+ touchPoints.delete(pointerId);
389
+ await dispatchTouchPoints(session, 'touchEnd', touchPoints);
390
+ }
391
+ else {
392
+ await page.mouse.move(point.x, point.y);
393
+ await page.mouse.down({ button: 'left' });
394
+ await page.waitForTimeout(event.durationMs ?? types_1.APP_PLAYTEST_DEFAULT_TAP_DURATION_MS);
395
+ await page.mouse.up({ button: 'left' });
396
+ }
397
+ continue;
398
+ }
399
+ if (event.type === 'pointerDown') {
400
+ if (mobile && session) {
401
+ touchPoints.set(pointerId, point);
402
+ await dispatchTouchPoints(session, 'touchStart', touchPoints);
403
+ }
404
+ else {
405
+ await page.mouse.move(point.x, point.y);
406
+ await page.mouse.down({ button: 'left' });
407
+ }
408
+ continue;
409
+ }
410
+ if (event.type === 'pointerMove') {
411
+ if (mobile && session) {
412
+ touchPoints.set(pointerId, point);
413
+ await dispatchTouchPoints(session, 'touchMove', touchPoints);
414
+ }
415
+ else {
416
+ await page.mouse.move(point.x, point.y);
417
+ }
418
+ continue;
419
+ }
420
+ if (event.type === 'pointerUp') {
421
+ if (mobile && session) {
422
+ touchPoints.delete(pointerId);
423
+ await dispatchTouchPoints(session, 'touchEnd', touchPoints);
424
+ }
425
+ else {
426
+ await page.mouse.move(point.x, point.y);
427
+ await page.mouse.up({ button: 'left' });
428
+ }
429
+ }
430
+ }
431
+ const remainingMs = tape.durationMs - (Date.now() - startedAt);
432
+ if (remainingMs > 0) {
433
+ await page.waitForTimeout(remainingMs);
434
+ }
435
+ }
436
+ finally {
437
+ if (session) {
438
+ await session.detach().catch(() => { });
439
+ }
440
+ }
441
+ }
332
442
  async function dispatchProjectCheckActionsToFrame(page, frame, actions) {
333
443
  for (const action of actions) {
334
444
  if (action.type === 'wait') {
@@ -627,10 +737,22 @@ async function runHostedLoadCheck(options) {
627
737
  if (isSoftwareWebGlRenderer(webglRenderer)) {
628
738
  throw new Error(`project_check_webgl_renderer_software: WebGL renderer is "${webglRenderer}". Real hardware acceleration is required.`);
629
739
  }
630
- if (options.actions?.length) {
740
+ if (options.playtestTape && options.actions?.length) {
741
+ throw new Error('project_check_input_mode_conflict: playtest tape and legacy actions cannot run together.');
742
+ }
743
+ if (options.playtestTape) {
744
+ if (!options.playtestSurface) {
745
+ throw new Error('project_check_playtest_surface_required');
746
+ }
747
+ await dispatchPlaytestTapeToFrame(page, frame, options.playtestSurface, options.playtestTape);
748
+ }
749
+ else if (options.actions?.length) {
631
750
  await dispatchProjectCheckActionsToFrame(page, frame, options.actions);
632
751
  await page.waitForTimeout(POST_READY_SETTLE_MS);
633
752
  }
753
+ else if (options.postReadyWaitMs && options.postReadyWaitMs > 0) {
754
+ await page.waitForTimeout(options.postReadyWaitMs);
755
+ }
634
756
  screenshotPath = await saveScreenshot(page, options.screenshotPath);
635
757
  }, {
636
758
  ...options.contextOptions,
@@ -733,7 +855,11 @@ async function runLocalHostedLoadCheck(input) {
733
855
  ? null
734
856
  : input.devRouterPort;
735
857
  const localAppMetadata = mountedRuntime.runtimeAssetManifest.response.localAppMetadata;
736
- const captureSurface = resolveLoadCheckSurface(localAppMetadata.surfaceTargets);
858
+ const declaredSurfaces = normalizeLoadCheckSurfaceTargets(localAppMetadata.surfaceTargets);
859
+ const captureSurface = input.surface ?? resolveLoadCheckSurface(localAppMetadata.surfaceTargets, input.task.primarySurface);
860
+ if (!declaredSurfaces.includes(captureSurface)) {
861
+ return buildFailedLoadCheckResult('surface_unsupported', `Surface ${captureSurface} is not declared in surfaceTargets for "${input.task.name}".`, null);
862
+ }
737
863
  const contextOptions = cloneLoadCheckContextOptions(captureSurface);
738
864
  const anonymousExpectedState = resolveAnonymousHostedLaunchState({
739
865
  ...localAppMetadata,
@@ -754,6 +880,9 @@ async function runLocalHostedLoadCheck(input) {
754
880
  contextOptions,
755
881
  screenshotPath: input.screenshotPath,
756
882
  actions: input.actions,
883
+ playtestTape: input.playtestTape,
884
+ playtestSurface: captureSurface,
885
+ postReadyWaitMs: input.postReadyWaitMs,
757
886
  });
758
887
  }
759
888
  if (anonymousExpectedState === 'login_required') {
@@ -797,6 +926,9 @@ async function runLocalHostedLoadCheck(input) {
797
926
  contextOptions,
798
927
  screenshotPath: input.screenshotPath,
799
928
  actions: input.actions,
929
+ playtestTape: input.playtestTape,
930
+ playtestSurface: captureSurface,
931
+ postReadyWaitMs: input.postReadyWaitMs,
800
932
  });
801
933
  }
802
934
  return await runHostedLoadCheck({
@@ -813,6 +945,9 @@ async function runLocalHostedLoadCheck(input) {
813
945
  contextOptions,
814
946
  screenshotPath: input.screenshotPath,
815
947
  actions: input.actions,
948
+ playtestTape: input.playtestTape,
949
+ playtestSurface: captureSurface,
950
+ postReadyWaitMs: input.postReadyWaitMs,
816
951
  });
817
952
  }
818
953
  finally {
@@ -829,7 +964,7 @@ function appendLoadCheckParams(targetUrl, devAuth) {
829
964
  }
830
965
  async function runUploadedHostedLoadCheck(input) {
831
966
  const preview = await input.client.fetchAppUploadLaunchCheckPreview(input.creatorUsername, input.task.name, input.sessionId);
832
- const captureSurface = resolveLoadCheckSurface(preview.localAppMetadata.surfaceTargets);
967
+ const captureSurface = resolveLoadCheckSurface(preview.localAppMetadata.surfaceTargets, input.task.primarySurface);
833
968
  const contextOptions = cloneLoadCheckContextOptions(captureSurface);
834
969
  const anonymousExpectedState = resolveAnonymousHostedLaunchState({
835
970
  ...preview.localAppMetadata,
@@ -0,0 +1,10 @@
1
+ import type { AppSurface } from '@playdrop/types';
2
+ import type { BrowserContextOptions } from 'playwright-core';
3
+ export type PlaydropSurfaceProfile = {
4
+ contextOptions: BrowserContextOptions;
5
+ playwrightMcpDevice: string | null;
6
+ };
7
+ export declare const PLAYDROP_SURFACE_PROFILES: Record<AppSurface, PlaydropSurfaceProfile>;
8
+ export declare const PLAYDROP_SURFACE_CONTEXT_OPTIONS: Record<AppSurface, BrowserContextOptions>;
9
+ export declare function clonePlaydropSurfaceContextOptions(surface: AppSurface): BrowserContextOptions;
10
+ export declare function buildPlaywrightMcpSurfaceArgs(surface: AppSurface): string[];
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PLAYDROP_SURFACE_CONTEXT_OPTIONS = exports.PLAYDROP_SURFACE_PROFILES = void 0;
4
+ exports.clonePlaydropSurfaceContextOptions = clonePlaydropSurfaceContextOptions;
5
+ exports.buildPlaywrightMcpSurfaceArgs = buildPlaywrightMcpSurfaceArgs;
6
+ const MOBILE_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Mobile/15E148 Safari/604.1';
7
+ exports.PLAYDROP_SURFACE_PROFILES = {
8
+ DESKTOP: {
9
+ contextOptions: {
10
+ viewport: { width: 1280, height: 720 },
11
+ deviceScaleFactor: 1,
12
+ isMobile: false,
13
+ hasTouch: false,
14
+ },
15
+ playwrightMcpDevice: null,
16
+ },
17
+ MOBILE_LANDSCAPE: {
18
+ contextOptions: {
19
+ viewport: { width: 844, height: 390 },
20
+ screen: { width: 844, height: 390 },
21
+ deviceScaleFactor: 3,
22
+ isMobile: true,
23
+ hasTouch: true,
24
+ userAgent: MOBILE_USER_AGENT,
25
+ },
26
+ playwrightMcpDevice: 'iPhone 13 landscape',
27
+ },
28
+ MOBILE_PORTRAIT: {
29
+ contextOptions: {
30
+ viewport: { width: 390, height: 844 },
31
+ screen: { width: 390, height: 844 },
32
+ deviceScaleFactor: 3,
33
+ isMobile: true,
34
+ hasTouch: true,
35
+ userAgent: MOBILE_USER_AGENT,
36
+ },
37
+ playwrightMcpDevice: 'iPhone 13',
38
+ },
39
+ };
40
+ exports.PLAYDROP_SURFACE_CONTEXT_OPTIONS = {
41
+ DESKTOP: exports.PLAYDROP_SURFACE_PROFILES.DESKTOP.contextOptions,
42
+ MOBILE_LANDSCAPE: exports.PLAYDROP_SURFACE_PROFILES.MOBILE_LANDSCAPE.contextOptions,
43
+ MOBILE_PORTRAIT: exports.PLAYDROP_SURFACE_PROFILES.MOBILE_PORTRAIT.contextOptions,
44
+ };
45
+ function clonePlaydropSurfaceContextOptions(surface) {
46
+ const source = exports.PLAYDROP_SURFACE_PROFILES[surface].contextOptions;
47
+ return {
48
+ ...source,
49
+ viewport: source.viewport ? { ...source.viewport } : source.viewport,
50
+ screen: source.screen ? { ...source.screen } : source.screen,
51
+ };
52
+ }
53
+ function buildPlaywrightMcpSurfaceArgs(surface) {
54
+ const profile = exports.PLAYDROP_SURFACE_PROFILES[surface];
55
+ const viewport = profile.contextOptions.viewport;
56
+ if (!viewport) {
57
+ throw new Error(`playdrop_surface_viewport_missing:${surface}`);
58
+ }
59
+ return [
60
+ ...(profile.playwrightMcpDevice ? ['--device', profile.playwrightMcpDevice] : []),
61
+ '--viewport-size',
62
+ `${viewport.width}x${viewport.height}`,
63
+ ];
64
+ }
@@ -247,6 +247,8 @@ async function uploadAppVersion(client, task, artifacts, options) {
247
247
  design: task.design,
248
248
  clearTags: options?.clearTags,
249
249
  surfaceTargets: task.surfaceTargets,
250
+ primarySurface: task.primarySurface,
251
+ playtestTapes: task.playtestTapes,
250
252
  assetSpecSupport: (task.assetSpecSupport ?? []).map((row) => ({
251
253
  assetSpecRef: row.assetSpec,
252
254
  versionRange: row.versionRange,
@@ -1,4 +1,4 @@
1
- import { type AppAchievementCatalogueDefinition, type AppAuthMode, type AppControllerMode, type AppHostingMode, type AppLeaderboardCatalogueDefinition, type AppMetadataAssetSpecSupport, type AppSurface, type AppType, type AppVersionDesign, type AppVersionVisibility, type AssetSpecContract, type AssetSpecStatus, type AssetSpecValidationKind, type CatalogueTagGroupDefinition, type ContentLicense, type PlayerMetaDefinitionStatus } from '@playdrop/types';
1
+ import { type AppAchievementCatalogueDefinition, type AppAuthMode, type AppControllerMode, type AppHostingMode, type AppLeaderboardCatalogueDefinition, type AppMetadataAssetSpecSupport, type AppPlaytestTapes, type AppSurface, type AppType, type AppVersionDesign, type AppVersionVisibility, type AssetSpecContract, type AssetSpecStatus, type AssetSpecValidationKind, type CatalogueTagGroupDefinition, type ContentLicense, type PlayerMetaDefinitionStatus } from '@playdrop/types';
2
2
  import { type AssetSpecSupportDeclaration } from './assetSpecs';
3
3
  export type CatalogueJson = {
4
4
  apps?: Array<Record<string, unknown>>;
@@ -40,6 +40,8 @@ export type AppCatalogueEntry = {
40
40
  remixSource?: string | null;
41
41
  remix?: string;
42
42
  surfaceTargets?: Record<string, unknown> | AppSurface[] | null;
43
+ primarySurface?: string | null;
44
+ playtestTapes?: unknown;
43
45
  design?: unknown;
44
46
  version?: string;
45
47
  releaseNotes?: string;
@@ -178,6 +180,8 @@ export type AppTask = {
178
180
  emoji?: string | null;
179
181
  color?: string | null;
180
182
  surfaceTargets: AppSurface[];
183
+ primarySurface?: AppSurface;
184
+ playtestTapes?: AppPlaytestTapes;
181
185
  design?: AppVersionDesign;
182
186
  missingMetadata: string[];
183
187
  version?: string;
@@ -346,6 +350,8 @@ export type ValidatedAppMetadata = {
346
350
  color?: string | null;
347
351
  surfaceTargets: AppSurface[];
348
352
  surfaceTargetMap: SurfaceTargetsMap;
353
+ primarySurface?: AppSurface;
354
+ playtestTapes?: AppPlaytestTapes;
349
355
  missingFields: string[];
350
356
  warnings: string[];
351
357
  errors: string[];
package/dist/catalogue.js CHANGED
@@ -868,6 +868,29 @@ function validateAppMetadata(entry) {
868
868
  catch (error) {
869
869
  errors.push(error?.message || 'invalid surfaceTargets');
870
870
  }
871
+ let primarySurface;
872
+ if (Object.prototype.hasOwnProperty.call(entry, 'primarySurface')) {
873
+ primarySurface = (0, types_1.parseAppSurface)(entry.primarySurface) ?? undefined;
874
+ if (!primarySurface) {
875
+ errors.push('primarySurface must be DESKTOP, MOBILE_LANDSCAPE, or MOBILE_PORTRAIT');
876
+ }
877
+ else if (!surfaceTargetsList.includes(primarySurface)) {
878
+ errors.push(`primarySurface ${primarySurface} must also be enabled in surfaceTargets`);
879
+ }
880
+ }
881
+ let playtestTapes;
882
+ if (Object.prototype.hasOwnProperty.call(entry, 'playtestTapes')) {
883
+ const normalized = (0, types_1.normalizeAppPlaytestTapes)(entry.playtestTapes, {
884
+ surfaceTargets: surfaceTargetsList,
885
+ requireComplete: true,
886
+ });
887
+ if (!normalized.ok) {
888
+ errors.push(normalized.error);
889
+ }
890
+ else {
891
+ playtestTapes = normalized.value;
892
+ }
893
+ }
871
894
  return {
872
895
  displayName,
873
896
  description,
@@ -876,6 +899,8 @@ function validateAppMetadata(entry) {
876
899
  color,
877
900
  surfaceTargets: surfaceTargetsList,
878
901
  surfaceTargetMap: surfaceTargetsMap,
902
+ primarySurface,
903
+ playtestTapes,
879
904
  missingFields,
880
905
  warnings,
881
906
  errors,
@@ -1486,6 +1511,8 @@ function buildAppTasks(rootDir, catalogues, options) {
1486
1511
  emoji: metadata.emoji === undefined ? undefined : metadata.emoji,
1487
1512
  color: metadata.color === undefined ? undefined : metadata.color,
1488
1513
  surfaceTargets: metadata.surfaceTargets,
1514
+ primarySurface: metadata.primarySurface,
1515
+ playtestTapes: metadata.playtestTapes,
1489
1516
  design,
1490
1517
  missingMetadata: metadata.missingFields,
1491
1518
  // Versioning fields
@@ -3,6 +3,7 @@ type ProjectCheckOptions = {
3
3
  timeout?: string | number;
4
4
  screenshot?: string;
5
5
  actions?: string;
6
+ tape?: string;
6
7
  };
7
8
  export declare function check(targetArg: string | undefined, options?: ProjectCheckOptions): Promise<void>;
8
9
  export {};
@@ -53,6 +53,13 @@ function readProjectCheckActions(actionsPath) {
53
53
  function defaultProjectCheckScreenshotPath(appName) {
54
54
  return (0, node_path_1.join)('assets', 'marketing', 'playdrop', 'check', `${appName}.png`);
55
55
  }
56
+ function buildComparisonScreenshotPath(basePath, run) {
57
+ const extension = (0, node_path_1.extname)(basePath);
58
+ if (!extension) {
59
+ return `${basePath}-${run}.png`;
60
+ }
61
+ return `${basePath.slice(0, -extension.length)}-${run}${extension}`;
62
+ }
56
63
  async function check(targetArg, options = {}) {
57
64
  let timeoutMs;
58
65
  let actions;
@@ -86,6 +93,27 @@ async function check(targetArg, options = {}) {
86
93
  process.exitCode = 1;
87
94
  return;
88
95
  }
96
+ let tapeSurface = null;
97
+ let playtestTape = null;
98
+ if (options.tape) {
99
+ if (actions.length > 0) {
100
+ (0, messages_1.printErrorWithHelp)('--tape and --actions cannot be used together.', ['Use the catalogue playtest tape by itself so the timed sequence remains deterministic.'], { command: 'project check' });
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ tapeSurface = (0, types_1.parseAppSurface)(options.tape);
105
+ if (!tapeSurface) {
106
+ (0, messages_1.printErrorWithHelp)(`Invalid tape surface "${options.tape}".`, ['Use DESKTOP, MOBILE_LANDSCAPE, or MOBILE_PORTRAIT.'], { command: 'project check' });
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ playtestTape = taskLookup.task.playtestTapes?.[tapeSurface] ?? null;
111
+ if (!playtestTape) {
112
+ (0, messages_1.printErrorWithHelp)(`No ${tapeSurface} playtest tape is declared for this app.`, [`Add playtestTapes.${tapeSurface} to catalogue.json.`], { command: 'project check' });
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+ }
89
117
  let appName = resolvedTarget.appName;
90
118
  try {
91
119
  const match = (0, catalogue_utils_1.findAppDefinition)(filePath);
@@ -176,7 +204,7 @@ async function check(targetArg, options = {}) {
176
204
  console.log(`[check] package.json detected at ${projectLabel}, but no "dev" script was found. Run your app build manually if needed.`);
177
205
  }
178
206
  }
179
- const result = await (0, loadCheck_1.runLocalHostedLoadCheck)({
207
+ const runCheck = async (input) => await (0, loadCheck_1.runLocalHostedLoadCheck)({
180
208
  client,
181
209
  apiBase: envConfig.apiBase,
182
210
  webBase: envConfig.webBase,
@@ -186,9 +214,47 @@ async function check(targetArg, options = {}) {
186
214
  token,
187
215
  currentUser: null,
188
216
  captureSession,
189
- screenshotPath,
217
+ screenshotPath: input.screenshotPath,
190
218
  actions,
219
+ surface: input.surface,
220
+ playtestTape: input.tape,
221
+ postReadyWaitMs: input.postReadyWaitMs,
191
222
  });
223
+ if (tapeSurface && playtestTape) {
224
+ const idleScreenshotPath = buildComparisonScreenshotPath(screenshotPath, 'idle');
225
+ const tapeScreenshotPath = buildComparisonScreenshotPath(screenshotPath, 'tape');
226
+ console.log(`[check] Running ${playtestTape.durationMs} ms zero-input baseline on ${tapeSurface}.`);
227
+ const idleResult = await runCheck({
228
+ screenshotPath: idleScreenshotPath,
229
+ surface: tapeSurface,
230
+ postReadyWaitMs: playtestTape.durationMs,
231
+ });
232
+ if (idleResult.status !== 'PASSED') {
233
+ (0, messages_1.printErrorWithHelp)((0, loadCheck_1.formatHostedLoadCheckFailure)(appName, idleResult, 'local'), idleResult.screenshotPath ? [`Idle screenshot: ${idleResult.screenshotPath}`] : [], { command: 'project check' });
234
+ process.exitCode = 1;
235
+ return;
236
+ }
237
+ console.log(`[check] Reopening a clean run and executing the ${tapeSurface} tape.`);
238
+ const tapeResult = await runCheck({
239
+ screenshotPath: tapeScreenshotPath,
240
+ surface: tapeSurface,
241
+ tape: playtestTape,
242
+ });
243
+ if (tapeResult.status !== 'PASSED') {
244
+ (0, messages_1.printErrorWithHelp)((0, loadCheck_1.formatHostedLoadCheckFailure)(appName, tapeResult, 'local'), tapeResult.screenshotPath ? [`Tape screenshot: ${tapeResult.screenshotPath}`] : [], { command: 'project check' });
245
+ process.exitCode = 1;
246
+ return;
247
+ }
248
+ console.log(`[check] Captured matched idle and tape runs for ${currentUsername}/${appName}.`);
249
+ console.log(`[check] Idle: ${idleResult.screenshotPath ?? idleScreenshotPath}`);
250
+ console.log(`[check] Tape: ${tapeResult.screenshotPath ?? tapeScreenshotPath}`);
251
+ console.log('[check] The tape passes only when visible evidence shows it meaningfully beats idle.');
252
+ for (const signal of playtestTape.successSignals) {
253
+ console.log(`[check] Success signal ${signal.kind}: ${signal.description}`);
254
+ }
255
+ return;
256
+ }
257
+ const result = await runCheck({ screenshotPath });
192
258
  if (result.status !== 'PASSED') {
193
259
  (0, messages_1.printErrorWithHelp)((0, loadCheck_1.formatHostedLoadCheckFailure)(appName, result, 'local'), result.screenshotPath ? [`Screenshot: ${result.screenshotPath}`] : [], { command: 'project check' });
194
260
  process.exitCode = 1;
@@ -37,6 +37,7 @@ export type WorkerAppPublishResult = {
37
37
  };
38
38
  export type WorkerPlaydropAssetRequirement = 'PACK' | 'ASSET_OR_PACK';
39
39
  export declare function resolveWorkerPlaydropAssetRequirement(value: unknown): WorkerPlaydropAssetRequirement | null;
40
+ export declare function assertAgentNewGamePlaytestContract(task: AppTask): void;
40
41
  export declare function assertTaskPlaytestEvidenceManifest(task: AppTask, options?: {
41
42
  requireOutcomeProof?: boolean;
42
43
  }): AgentTaskPlaytestProof | null;