ravensight-playtest 0.3.1 → 0.4.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.
@@ -0,0 +1,126 @@
1
+ import { join } from 'node:path';
2
+ import { readJson, writeJson } from './fsutil.js';
3
+ import { CliError } from './errors.js';
4
+
5
+ /**
6
+ * The brief fields that stay on this machine: `expected_noise` and
7
+ * `test_account`.
8
+ *
9
+ * The server's expectations brief schema refuses any key it does not list
10
+ * (`additionalProperties: false`, drafts included), and a test account's
11
+ * password has no business in a record the whole studio can read. So these
12
+ * two live in `.ravensight/brief.local.json`, which `init` adds to
13
+ * `.gitignore` and which is written readable by its owner only. `brief --from`
14
+ * accepts them in the same file as the server's fields and splits them off;
15
+ * `run` and `resume` hand them to the persona with the rest of the brief.
16
+ *
17
+ * The password is still sent to the model like any other prompt text: that is
18
+ * how a persona can type it. It is for a throwaway account made for testing.
19
+ */
20
+
21
+ /** The keys this file holds. */
22
+ export const LOCAL_BRIEF_FIELDS = Object.freeze(['expected_noise', 'test_account']);
23
+
24
+ /** The shortest test account password accepted. */
25
+ export const MIN_PASSWORD_CHARS = 8;
26
+
27
+ /** What a password is printed as. */
28
+ export const HIDDEN_PASSWORD = '(hidden)';
29
+
30
+ /**
31
+ * @param {{repoRoot?: string}} [options]
32
+ * @returns {string}
33
+ */
34
+ export function localBriefFile(options = {}) {
35
+ return join(options.repoRoot || process.cwd(), '.ravensight', 'brief.local.json');
36
+ }
37
+
38
+ /**
39
+ * Split a brief fields object into what the server stores and what stays here.
40
+ *
41
+ * @param {Object} fields
42
+ * @returns {{server: Object, local: Object}}
43
+ */
44
+ export function splitLocalFields(fields = {}) {
45
+ const server = {};
46
+ const local = {};
47
+ for (const [key, value] of Object.entries(fields || {})) {
48
+ if (LOCAL_BRIEF_FIELDS.includes(key)) local[key] = value;
49
+ else server[key] = value;
50
+ }
51
+ return { server, local };
52
+ }
53
+
54
+ /**
55
+ * What is wrong with a local brief, one line per problem; empty when it is fine.
56
+ *
57
+ * @param {Object} local
58
+ * @returns {Array<string>}
59
+ */
60
+ export function localBriefProblems(local = {}) {
61
+ const problems = [];
62
+ if (local.expected_noise !== undefined) {
63
+ if (!Array.isArray(local.expected_noise) || !local.expected_noise.every(entry => typeof entry === 'string' && entry.trim())) {
64
+ problems.push('expected_noise must be an array of non empty strings, such as ["401 /auth/me before login"]');
65
+ }
66
+ }
67
+ if (local.test_account !== undefined) {
68
+ const account = local.test_account;
69
+ if (!account || typeof account !== 'object' || Array.isArray(account)) {
70
+ problems.push('test_account must be an object: { "email": ..., "password": ..., "how_to_sign_in": ... }');
71
+ } else {
72
+ for (const key of ['email', 'password']) {
73
+ if (typeof account[key] !== 'string' || !account[key]) problems.push(`test_account.${key} must be a non empty string`);
74
+ }
75
+ // The runner scrubs the password out of every report; a short one
76
+ // ("test", "ux") would also match ordinary words and enum values.
77
+ if (typeof account.password === 'string' && account.password && account.password.length < MIN_PASSWORD_CHARS) {
78
+ problems.push(`test_account.password must be at least ${MIN_PASSWORD_CHARS} characters, so scrubbing it from reports cannot rewrite ordinary words`);
79
+ }
80
+ if (account.how_to_sign_in !== undefined && typeof account.how_to_sign_in !== 'string') problems.push('test_account.how_to_sign_in must be a string');
81
+ }
82
+ }
83
+ return problems;
84
+ }
85
+
86
+ /**
87
+ * The local brief, or {} when there is none. Never throws for a missing file;
88
+ * a file that is not an object is an error naming it.
89
+ *
90
+ * @param {{repoRoot?: string}} [options]
91
+ * @returns {Promise<Object>}
92
+ */
93
+ export async function loadLocalBrief(options = {}) {
94
+ const file = localBriefFile(options);
95
+ const value = await readJson(file, null);
96
+ if (value === null) return {};
97
+ if (typeof value !== 'object' || Array.isArray(value)) throw new CliError(`${file} is not a brief fields object.`);
98
+ return Object.fromEntries(Object.entries(value).filter(([key]) => LOCAL_BRIEF_FIELDS.includes(key)));
99
+ }
100
+
101
+ /**
102
+ * Merge these fields into the local brief and write it, owner readable only.
103
+ *
104
+ * @param {Object} local
105
+ * @param {{repoRoot?: string}} [options]
106
+ * @returns {Promise<Object>} what the file now holds
107
+ */
108
+ export async function saveLocalBrief(local, options = {}) {
109
+ const problems = localBriefProblems(local);
110
+ if (problems.length > 0) throw new CliError(`The local brief fields are not valid: ${problems.join('; ')}.`);
111
+ const merged = { ...(await loadLocalBrief(options)), ...local };
112
+ await writeJson(localBriefFile(options), merged, { mode: 0o600 });
113
+ return merged;
114
+ }
115
+
116
+ /**
117
+ * The local brief as it may be printed: the password masked.
118
+ *
119
+ * @param {Object} local
120
+ * @returns {Object}
121
+ */
122
+ export function displayLocalBrief(local = {}) {
123
+ const out = { ...local };
124
+ if (out.test_account && typeof out.test_account === 'object') out.test_account = { ...out.test_account, ...(out.test_account.password ? { password: HIDDEN_PASSWORD } : {}) };
125
+ return out;
126
+ }
package/src/cli.js CHANGED
@@ -52,13 +52,16 @@ async function loadRunner(name, exportName) {
52
52
  */
53
53
  export const RUNNER_HELP = `
54
54
  Run options (parsed by the runner, see src/run/args.js):
55
- --personas <list> comma separated persona slugs, default the pack's set
55
+ --personas <list> comma separated persona slugs, default one (curious-kid); "personas" lists them
56
56
  --driver <name> playwright_web, godot_driver, desktop_driver, unity_driver, unreal_driver, android_driver, ios_driver or cli_stdio
57
57
  --controller <name> standard or jev action selection (default standard)
58
58
  --build-url <url> the web build to play
59
59
  --godot-project <dir> the Godot project directory
60
60
  --driver-config <file> local WebDriver or JSON-RPC bridge configuration
61
61
  --max-actions <n> lower the actions one persona may take; it can only lower the persona's own patience, never raise it
62
+ --long a long persona run: twice the actions, model budget and wall time of a standard
63
+ run, for a game whose screens are heavy or whose goal is far in. It costs twice
64
+ a standard run; the price is read from the estimate and confirmed before any charge
62
65
  --concurrency <n> personas at once, default 2
63
66
  --upload-video upload session.webm as well as the reports
64
67
  --upload-transcript upload transcript.jsonl as well as the reports
@@ -67,8 +70,9 @@ Run options (parsed by the runner, see src/run/args.js):
67
70
  --headed show the browser
68
71
  --headless hide it
69
72
  --verbose print each turn
70
- --skip-preflight native drivers: register without probing the automation server first;
71
- the job is registered and charged even if the server turns out to be down
73
+ --skip-preflight register without the free checks first: the build health check (any driver)
74
+ and the automation server probe (native drivers); the job is registered and
75
+ charged even if the build or the server turns out to be down
72
76
 
73
77
  Accepted and reported as not applied by this release:
74
78
  --quality, --offline-upload, --build-dir
@@ -179,6 +183,13 @@ export function buildProgram() {
179
183
  }));
180
184
  }));
181
185
 
186
+ common(program.command('personas').description('list the persona slugs --personas accepts'))
187
+ .option('--game <gameId>', 'the game, when this repo is not linked')
188
+ .action(capture(slot, async options => {
189
+ const { personas } = await import('./commands/personas.js');
190
+ return personas(flagsFrom(options, { game: options.game }));
191
+ }));
192
+
182
193
  common(program.command('init').description('link this repo to a Ravensight game'))
183
194
  .option('--game <gameId>', 'the game to link')
184
195
  .option('--force', 'relink a repo that is already linked')
@@ -305,6 +316,24 @@ export function buildProgram() {
305
316
  return run(flagsFrom(options, { game: options.game, argv: command.args }));
306
317
  }));
307
318
 
319
+ common(program.command('simulate').description('run a paid, seeded batch against a local headless game'))
320
+ .option('--game <gameId>', 'the game, when this repo is not linked')
321
+ .option('--config <file>', 'simulation JSON: seed, runs, max_steps, workers and strategies')
322
+ .option('--driver-config <file>', 'local JSON-RPC executable configuration')
323
+ .option('--resume <file>', 'resume the same paid batch from its state.json, without another charge')
324
+ .action(capture(slot, async options => {
325
+ const { simulateCommand } = await import('./commands/simulate.js');
326
+ return simulateCommand(flagsFrom(options, { game: options.game, configFile: options.config, driverConfig: options.driverConfig, resume: options.resume }));
327
+ }));
328
+
329
+ common(program.command('simulation-replay').description('verify a recorded simulation trace locally; creates no paid job'))
330
+ .argument('<trace>', 'a selected trace.json written by simulate')
331
+ .requiredOption('--driver-config <file>', 'local JSON-RPC executable configuration')
332
+ .action(capture(slot, async (trace, options) => {
333
+ const { replayCommand } = await import('./commands/simulate.js');
334
+ return replayCommand(trace, flagsFrom(options, { driverConfig: options.driverConfig }));
335
+ }));
336
+
308
337
  return program;
309
338
  }
310
339
 
@@ -7,6 +7,8 @@ import { readJson } from '../fsutil.js';
7
7
  import { briefLink, openInBrowser } from '../dashboard.js';
8
8
  import { ui } from '../ui/index.js';
9
9
  import { CliError, ExitCode } from '../errors.js';
10
+ import { DEFAULT_PERSONAS, RUN_MODULES, describeBreakdown, personaCount } from '../runDefaults.js';
11
+ import { displayLocalBrief, loadLocalBrief, localBriefFile, saveLocalBrief, splitLocalFields } from '../briefLocal.js';
10
12
 
11
13
  /** The goals the server's expectations-brief schema accepts, in its own order. */
12
14
  export const GOALS = Object.freeze([
@@ -19,7 +21,6 @@ export const GOALS = Object.freeze([
19
21
  'performance'
20
22
  ]);
21
23
 
22
- const DEFAULT_MODULES = ['game_profile', 'persona_playtest'];
23
24
 
24
25
  /**
25
26
  * The four things a quick brief needs, which are exactly the four the server
@@ -79,6 +80,13 @@ function showBrief(brief, rendered) {
79
80
  ui.info(rendered || '');
80
81
  }
81
82
 
83
+ /** Print the fields kept on this machine, the password masked. */
84
+ function showLocalBrief(local, repoRoot) {
85
+ ui.blank();
86
+ ui.info(`On this machine only (${localBriefFile({ repoRoot })}):`);
87
+ ui.info(JSON.stringify(local, null, 2));
88
+ }
89
+
82
90
  /**
83
91
  * A question asker over a pair of streams.
84
92
  *
@@ -212,13 +220,21 @@ async function editBrief(context, flags, deps) {
212
220
  }
213
221
 
214
222
  if (flags.show) {
223
+ const local = displayLocalBrief(await loadLocalBrief({ repoRoot: flags.repoRoot }));
224
+ const hasLocal = Object.keys(local).length > 0;
215
225
  if (!current.brief) {
216
- if (flags.json) ui.json({ brief: null });
217
- else ui.info('No brief yet. Run brief with no flags to write one.');
226
+ if (flags.json) ui.json({ brief: null, local });
227
+ else {
228
+ ui.info('No brief yet. Run brief with no flags to write one.');
229
+ if (hasLocal) showLocalBrief(local, flags.repoRoot);
230
+ }
218
231
  return ExitCode.OK;
219
232
  }
220
- if (flags.json) ui.json(current);
221
- else showBrief(current.brief, current.rendered);
233
+ if (flags.json) ui.json({ ...current, local });
234
+ else {
235
+ showBrief(current.brief, current.rendered);
236
+ if (hasLocal) showLocalBrief(local, flags.repoRoot);
237
+ }
222
238
  return ExitCode.OK;
223
239
  }
224
240
 
@@ -226,12 +242,22 @@ async function editBrief(context, flags, deps) {
226
242
  const parentVersion = current.brief ? current.brief.version : null;
227
243
 
228
244
  let fields;
245
+ let localSaved = [];
229
246
  if (flags.from) {
230
247
  const loaded = await readJson(flags.from, null);
231
248
  if (!loaded || typeof loaded !== 'object') {
232
249
  throw new CliError(`${flags.from} does not contain a brief fields object.`);
233
250
  }
234
251
  fields = loaded.fields && typeof loaded.fields === 'object' ? loaded.fields : loaded;
252
+ // expected_noise and test_account stay on this machine: the server's
253
+ // brief schema refuses keys it does not list, and a password has no place
254
+ // in a record the studio shares.
255
+ const split = splitLocalFields(fields);
256
+ fields = split.server;
257
+ if (Object.keys(split.local).length > 0) {
258
+ await saveLocalBrief(split.local, { repoRoot: flags.repoRoot });
259
+ localSaved = Object.keys(split.local);
260
+ }
235
261
  } else {
236
262
  fields = await askQuickBrief(existingFields, deps);
237
263
  }
@@ -260,8 +286,13 @@ async function editBrief(context, flags, deps) {
260
286
  // A refused check does not undo a saved brief, and the brief is the point.
261
287
  }
262
288
 
263
- const estimateModules = flags.modules || (context.config && context.config.modules) || DEFAULT_MODULES;
264
- const estimatePersonas = flags.personas || (context.config && context.config.personas) || [];
289
+ // The run `run` would start: its modules and its personas (the flag, the
290
+ // repo config, else the default one). Before 0.3.2 this priced a game
291
+ // profile with no personas and quoted $4.00 for a job nobody was about to
292
+ // start.
293
+ const estimateModules = flags.modules || (context.config && context.config.modules) || [...RUN_MODULES];
294
+ const configuredPersonas = flags.personas || (context.config && Array.isArray(context.config.personas) && context.config.personas.length > 0 ? context.config.personas : null);
295
+ const estimatePersonas = estimateModules.includes('persona_playtest') ? (configuredPersonas || [...DEFAULT_PERSONAS]) : (configuredPersonas || []);
265
296
  let estimate = null;
266
297
  try {
267
298
  estimate = await api.brief.estimate(gameId, { modules: estimateModules, personas: estimatePersonas });
@@ -270,11 +301,14 @@ async function editBrief(context, flags, deps) {
270
301
  }
271
302
 
272
303
  if (flags.json) {
273
- ui.json({ ...saved, estimate });
304
+ ui.json({ ...saved, estimate, local_saved: localSaved });
274
305
  return ExitCode.OK;
275
306
  }
276
307
 
277
308
  ui.ok(`Saved brief version ${saved.brief.version} (${saved.brief.status}).`);
309
+ if (localSaved.length > 0) {
310
+ ui.info(`Kept ${localSaved.join(' and ')} in ${localBriefFile({ repoRoot: flags.repoRoot })}, on this machine only: the server's brief does not store them.`);
311
+ }
278
312
  const after = saved.brief.completeness || completenessFor(saved.brief.fields);
279
313
  if (!after.complete) {
280
314
  ui.warn(`Still missing: ${after.missing.join(', ')}`);
@@ -283,7 +317,12 @@ async function editBrief(context, flags, deps) {
283
317
 
284
318
  if (estimate) {
285
319
  ui.blank();
286
- ui.info(`A ${estimateModules.join(' plus ')} job would cost ${ui.money(estimate.estimate_cents)}.`);
320
+ const units = describeBreakdown(estimate.breakdown, ui.money);
321
+ const what = estimateModules.includes('persona_playtest')
322
+ ? `A run of ${personaCount(estimatePersonas.length)} (${estimatePersonas.join(', ')})${estimateModules.includes('game_profile') ? ' with a game profile' : ''}`
323
+ : `A ${estimateModules.join(' plus ')} job`;
324
+ ui.info(`${what} would cost ${ui.money(estimate.estimate_cents)}${units ? `: ${units}` : ''}.`);
325
+ if (!estimateModules.includes('game_profile')) ui.info('A game profile ("ravensight-playtest profile") is a separate job with its own price.');
287
326
  if (estimate.wall_warning) ui.warn(estimate.wall_warning);
288
327
  ui.table(estimate.breakdown, [
289
328
  { key: 'unit', label: 'UNIT' },
@@ -11,8 +11,10 @@ import { CLI_VERSION, compareVersions } from '../version.js';
11
11
  import { ui } from '../ui/index.js';
12
12
  import { ExitCode } from '../errors.js';
13
13
  import { completenessFor } from './brief.js';
14
+ import { RUN_MODULES, defaultPersonasFor, describeBreakdown, personaCount, unknownPersonaMessage, unknownPersonas } from '../runDefaults.js';
14
15
  import { WEBDRIVER_NAMES } from '../run/drivers/webdriver.js';
15
16
  import { inspectNativeBuild, probeAutomation, platformWords } from '../run/drivers/webdriver-probe.js';
17
+ import { CHECK_PLAYTEST_IDENTITY, DEFAULT_VIEWPORT, WEB_OBSERVATION_MAX_CHARS, injectPlaytestIdentity, measureSnapshot, readSnapshot } from '../run/drivers/web.js';
16
18
  import path from 'node:path';
17
19
 
18
20
  export const NODE_MIN_MAJOR = 22;
@@ -64,21 +66,109 @@ export function browserSeverity(flags, config) {
64
66
  return config.driver === 'playwright_web' ? 'warn' : null;
65
67
  }
66
68
 
69
+ /** How long `check` waits for a web build's first screen before it warns instead. */
70
+ export const FIRST_SCREEN_TIMEOUT_MS = 20000;
71
+
72
+ /** How long `check` waits for a first screen's network to go quiet, which a game with a socket never does. */
73
+ export const FIRST_SCREEN_SETTLE_MS = 3000;
74
+
75
+ /**
76
+ * A list with this many look-alike items or more on the first screen is
77
+ * worth a long run: the persona sees two and a count and has to search or
78
+ * scroll its way to the one it wants, or, when the items share a name and the
79
+ * page is under the cap, reads all of them on every step.
80
+ */
81
+ export const LONG_LIST_ITEMS = 50;
82
+
83
+ /** `18,400`. */
84
+ const count = value => Number(value).toLocaleString('en-US');
85
+
86
+ /**
87
+ * Load the first screen of a web build in an already launched browser and
88
+ * measure it the way a persona's observation would see it.
89
+ *
90
+ * @param {any} browser
91
+ * @param {string} url
92
+ * @param {{timeoutMs?: number}} [options]
93
+ * @returns {Promise<{ok: true, aria: boolean, fullChars: number, shownChars: number, longestRun: number}|{ok: false, detail: string}>}
94
+ */
95
+ export async function measureFirstScreen(browser, url, { timeoutMs = FIRST_SCREEN_TIMEOUT_MS } = {}) {
96
+ let page = null;
97
+ try {
98
+ if (!browser || typeof browser.newPage !== 'function') throw new Error('this Playwright cannot open a page');
99
+ page = await browser.newPage({ viewport: DEFAULT_VIEWPORT });
100
+ // Marked as a playtest before any of the game's scripts run, so a build
101
+ // with the Ravensight web SDK never records this load as a real player.
102
+ // A Playwright that cannot mark the page does not load it at all.
103
+ if (typeof page.addInitScript !== 'function') throw new Error('this Playwright cannot mark the page as a check, so it was not loaded');
104
+ await page.addInitScript(injectPlaytestIdentity, { ...CHECK_PLAYTEST_IDENTITY });
105
+ await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
106
+ if (typeof page.waitForLoadState === 'function') {
107
+ try {
108
+ await page.waitForLoadState('networkidle', { timeout: FIRST_SCREEN_SETTLE_MS });
109
+ } catch {
110
+ // A page that keeps a connection open is measured as it stands.
111
+ }
112
+ }
113
+ const snap = await readSnapshot(page);
114
+ return { ok: true, ...measureSnapshot(snap, { viewport: DEFAULT_VIEWPORT }) };
115
+ } catch (error) {
116
+ return { ok: false, detail: String(error && error.message ? error.message : error).split('\n')[0] };
117
+ } finally {
118
+ if (page && typeof page.close === 'function') {
119
+ try {
120
+ await page.close();
121
+ } catch {
122
+ // The browser closes it with everything else.
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Why a measured first screen is worth a long run, or an empty list when it
130
+ * is not: the whole compacted text over the observation cap, or a very long
131
+ * list of look-alike items.
132
+ *
133
+ * @param {{fullChars: number, longestRun: number}} measure
134
+ * @returns {string[]}
135
+ */
136
+ export function heavyReasons(measure) {
137
+ const reasons = [];
138
+ if (measure.fullChars > WEB_OBSERVATION_MAX_CHARS) {
139
+ const ratio = measure.fullChars / WEB_OBSERVATION_MAX_CHARS;
140
+ reasons.push(`first screen is ${count(measure.fullChars)} characters, about ${ratio < 10 ? ratio.toFixed(1) : Math.round(ratio)}x the ${count(WEB_OBSERVATION_MAX_CHARS)} a persona reads per step`);
141
+ }
142
+ if (measure.longestRun >= LONG_LIST_ITEMS) {
143
+ reasons.push(measure.longestRunCollapsed === false
144
+ ? `first screen has a list of ${count(measure.longestRun)} look-alike items, which a persona sees in full on every step`
145
+ : `first screen has a list of ${count(measure.longestRun)} look-alike items, which a persona sees as two and a count`);
146
+ }
147
+ return reasons;
148
+ }
149
+
67
150
  /**
68
151
  * Import Playwright, launch headless Chromium, close it. The same three steps
69
152
  * a web run starts with, so a pass here means the run will get a browser.
70
153
  *
154
+ * With a `url`, the browser also loads that first screen and measures it
155
+ * (see {@link measureFirstScreen}); a page that does not load is reported
156
+ * in `firstScreen`, never as a failed browser.
157
+ *
71
158
  * @param {(specifier: string) => Promise<any>} [importer] test seam
72
- * @returns {Promise<{ok: boolean, detail: string}>}
159
+ * @param {{url?: string|null, timeoutMs?: number}} [options]
160
+ * @returns {Promise<{ok: boolean, detail: string, firstScreen?: Object}>}
73
161
  */
74
- export async function probeBrowser(importer) {
162
+ export async function probeBrowser(importer, { url = null, timeoutMs } = {}) {
75
163
  const load = importer || (specifier => import(specifier));
76
164
  let browser = null;
77
165
  try {
78
166
  const playwright = await load('playwright');
79
167
  browser = await playwright.chromium.launch({ headless: true });
80
168
  const version = typeof browser.version === 'function' ? browser.version() : '';
81
- return { ok: true, detail: `chromium ${version}`.trim() + ' launched headless' };
169
+ const detail = `chromium ${version}`.trim() + ' launched headless';
170
+ if (!url) return { ok: true, detail };
171
+ return { ok: true, detail, firstScreen: await measureFirstScreen(browser, url, { timeoutMs }) };
82
172
  } catch (error) {
83
173
  return { ok: false, detail: String(error && error.message ? error.message : error).split('\n')[0] };
84
174
  } finally {
@@ -450,42 +540,17 @@ export async function runChecks(flags = {}, deps = {}) {
450
540
 
451
541
  /* -------------------------------------------------- balance and pack */
452
542
 
543
+ /** The standard estimate and its request, so a heavy first screen can quote the long price beside it. */
544
+ let priced = null;
453
545
  if (gameId && whoami && inScope) {
454
- const modules = flags.modules || (config && config.modules) || ['game_profile', 'persona_playtest'];
455
- const personas = flags.personas || (config && config.personas) || [];
456
- try {
457
- const estimate = await authed.jobs.estimate(gameId, { modules, personas });
458
- if (estimate.balance_cents === null || estimate.balance_cents === undefined) {
459
- rows.push(row(
460
- 'balance',
461
- 'warn',
462
- `estimate ${ui.money(estimate.estimate_cents)}, balance hidden by your role`,
463
- 'ask an owner or admin to check the balance'
464
- ));
465
- } else {
466
- rows.push(row(
467
- 'balance',
468
- estimate.ok ? 'pass' : 'fail',
469
- `estimate ${ui.money(estimate.estimate_cents)}, available ${ui.money(estimate.balance_cents)}`,
470
- estimate.ok ? null : `top up at ${estimate.topup_url}`
471
- ));
472
- }
473
- // Not a failure and not hidden by role: a job this size is still
474
- // allowed, it may just outrun its wall clock, which is worth seeing
475
- // here rather than only after `run` has already asked for a price.
476
- if (estimate.wall_warning) {
477
- rows.push(row('persona wall clock', 'warn', estimate.wall_warning));
478
- }
479
- } catch (error) {
480
- rows.push(row(
481
- 'balance',
482
- 'warn',
483
- error instanceof ApiError ? error.message : String(error && error.message)
484
- ));
485
- }
486
-
546
+ // What `run` would price: its modules, and with no personas named (by the
547
+ // flag or the repo config) the default persona set. Before 0.3.2 the
548
+ // persona module priced no persona at all.
549
+ const modules = flags.modules || (config && config.modules) || [...RUN_MODULES];
550
+ const named = flags.personas || (config && Array.isArray(config.personas) && config.personas.length > 0 ? config.personas : null);
551
+ let pack = null;
487
552
  try {
488
- const pack = await packs.get({ api: authed, gameId, modules });
553
+ pack = await packs.get({ api: authed, gameId, modules });
489
554
  const manifest = (await authed.packs.manifest()).body;
490
555
  const verified = packs.verifyAgainstManifest(manifest, pack.bundle);
491
556
  rows.push(row(
@@ -504,6 +569,48 @@ export async function runChecks(flags = {}, deps = {}) {
504
569
  'the CLI cannot run without personas and schemas'
505
570
  ));
506
571
  }
572
+ const available = pack && pack.bundle && Array.isArray(pack.bundle.personas) ? pack.bundle.personas : null;
573
+ const personas = modules.includes('persona_playtest') ? (named || defaultPersonasFor(available)) : (named || []);
574
+ const unknown = available ? unknownPersonas(personas, available) : [];
575
+ if (unknown.length > 0) {
576
+ rows.push(row('personas', 'fail', unknownPersonaMessage(unknown, available), 'ravensight-playtest personas lists the valid slugs'));
577
+ }
578
+
579
+ if (unknown.length === 0) {
580
+ try {
581
+ const estimate = await authed.jobs.estimate(gameId, { modules, personas });
582
+ priced = { estimate, modules, personas };
583
+ const units = describeBreakdown(estimate.breakdown, ui.money);
584
+ const summary = `estimate ${ui.money(estimate.estimate_cents)}${modules.includes('persona_playtest') ? ` for ${personaCount(personas.length)} (${personas.join(', ')})` : ''}${units ? `: ${units}` : ''}`;
585
+ if (estimate.balance_cents === null || estimate.balance_cents === undefined) {
586
+ rows.push(row(
587
+ 'balance',
588
+ 'warn',
589
+ `${summary}; balance hidden by your role`,
590
+ 'ask an owner or admin to check the balance'
591
+ ));
592
+ } else {
593
+ rows.push(row(
594
+ 'balance',
595
+ estimate.ok ? 'pass' : 'fail',
596
+ `${summary}; available ${ui.money(estimate.balance_cents)}`,
597
+ estimate.ok ? null : `top up at ${estimate.topup_url}`
598
+ ));
599
+ }
600
+ // Not a failure and not hidden by role: a job this size is still
601
+ // allowed, it may just outrun its wall clock, which is worth seeing
602
+ // here rather than only after `run` has already asked for a price.
603
+ if (estimate.wall_warning) {
604
+ rows.push(row('persona wall clock', 'warn', estimate.wall_warning));
605
+ }
606
+ } catch (error) {
607
+ rows.push(row(
608
+ 'balance',
609
+ 'warn',
610
+ error instanceof ApiError ? error.message : String(error && error.message)
611
+ ));
612
+ }
613
+ }
507
614
  }
508
615
 
509
616
  /* --------------------------------------------------------- the machine */
@@ -533,13 +640,17 @@ export async function runChecks(flags = {}, deps = {}) {
533
640
 
534
641
  const browserNeed = browserSeverity(flags, await configForBrowser(flags));
535
642
  if (browserNeed) {
536
- const probe = await probeBrowser(deps.importer);
643
+ const firstScreenUrl = plan.driverName === 'playwright_web' && /^https?:\/\//i.test(String(plan.target || '')) ? plan.target : null;
644
+ const probe = await probeBrowser(deps.importer, { url: firstScreenUrl, timeoutMs: deps.firstScreenTimeoutMs });
537
645
  rows.push(row(
538
646
  'browser',
539
647
  probe.ok ? 'pass' : browserNeed,
540
648
  probe.detail,
541
649
  probe.ok ? null : BROWSER_FIX
542
650
  ));
651
+ if (probe.firstScreen) {
652
+ rows.push(await firstScreenRow(probe.firstScreen, firstScreenUrl, { authed, gameId, priced }));
653
+ }
543
654
  }
544
655
 
545
656
  const godot = await (deps.inspectGodot || inspectGodot)(
@@ -587,6 +698,44 @@ export async function runChecks(flags = {}, deps = {}) {
587
698
  return { rows, ok: rows.every(entry => entry.status !== 'fail'), ...facts };
588
699
  }
589
700
 
701
+ /**
702
+ * The `first screen` row: how heavy the build's first screen is for a
703
+ * persona, and a suggestion to use `--long` when it is heavy. Both prices are
704
+ * read from the server's free estimate; nothing here is charged. A first
705
+ * screen that did not load is a warning, never a failure: `run` has its own
706
+ * health check, and the server may simply not be started yet.
707
+ *
708
+ * @param {Object} measure from {@link measureFirstScreen}
709
+ * @param {string} url
710
+ * @param {{authed: Object|null, gameId: string|null, priced: Object|null}} context
711
+ * @returns {Promise<Object>} a row
712
+ */
713
+ export async function firstScreenRow(measure, url, { authed, gameId, priced }) {
714
+ if (!measure.ok) {
715
+ return row('first screen', 'warn', `could not load ${url} to measure it: ${measure.detail}`, 'start the build, then run check again to see how heavy its first screen is');
716
+ }
717
+ const reasons = heavyReasons(measure);
718
+ if (reasons.length === 0) {
719
+ return row('first screen', 'pass', `${count(measure.fullChars)} characters, within the ${count(WEB_OBSERVATION_MAX_CHARS)} a persona reads per step`);
720
+ }
721
+ let prices = 'a long run costs twice a standard run; run shows the price before any charge';
722
+ if (priced && priced.modules.includes('persona_playtest') && authed && gameId) {
723
+ try {
724
+ // A read like the standard estimate above: it prices, it charges nothing.
725
+ const long = await authed.jobs.estimate(gameId, { modules: priced.modules, personas: priced.personas, runLength: 'long' });
726
+ prices = `long ${ui.money(long.estimate_cents)} against standard ${ui.money(priced.estimate.estimate_cents)}`;
727
+ } catch {
728
+ // The suggestion stands without the figure.
729
+ }
730
+ }
731
+ return row(
732
+ 'first screen',
733
+ 'warn',
734
+ `${reasons.join('; ')}. Consider --long: twice the actions, model budget and wall time per persona (${prices})`,
735
+ 'ravensight-playtest run --long'
736
+ );
737
+ }
738
+
590
739
  /**
591
740
  * `ravensight-playtest check`
592
741
  * @param {Object} [flags]
@@ -15,7 +15,8 @@ import { createRepoTools, PROFILE_TOOLS } from '../run/repoTools.js';
15
15
  import { assertNoSecrets } from '../run/secretScan.js';
16
16
  import * as stateFile from '../run/state.js';
17
17
  import { createUsageLedger, writeUsageFile } from '../run/usage.js';
18
- import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor } from './run.js';
18
+ import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor, usableLocalBrief } from './run.js';
19
+ import { loadLocalBrief } from '../briefLocal.js';
19
20
 
20
21
  /**
21
22
  * `ravensight-playtest fix <target>`: take one finding, change the code on a
@@ -784,6 +785,15 @@ export async function runVerify({
784
785
  });
785
786
  heartbeat.start();
786
787
 
788
+ // The verify replay plays like `run` does: with the brief's local fields,
789
+ // so it can sign in with the test account and knows the expected noise.
790
+ let localBrief = {};
791
+ try {
792
+ localBrief = usableLocalBrief(await loadLocalBrief({ repoRoot }));
793
+ } catch (error) {
794
+ log(`verify: ${String(error && error.message)} The replay goes ahead without expected_noise and test_account.`);
795
+ }
796
+
787
797
  let outcome;
788
798
  try {
789
799
  outcome = await runPersona({
@@ -801,6 +811,7 @@ export async function runVerify({
801
811
  skill: skillFor(pack, 'persona_playtest'),
802
812
  // The brief fetched above, as `run` does: the pack does not always carry one.
803
813
  brief: verifyBrief(brief || pack.brief || null, finding),
814
+ localBrief,
804
815
  intentNotes: pack.intent_notes || [],
805
816
  schemas: pack.schemas || [],
806
817
  maxActions,