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.
- package/README.md +200 -20
- package/docs/NATIVE_RUNNER.md +2 -2
- package/docs/SIMULATION.md +91 -0
- package/examples/simulation/economy.mjs +22 -0
- package/package.json +7 -3
- package/src/api/client.js +9 -3
- package/src/briefLocal.js +126 -0
- package/src/cli.js +32 -3
- package/src/commands/brief.js +48 -9
- package/src/commands/check.js +187 -38
- package/src/commands/fix.js +12 -1
- package/src/commands/init.js +13 -6
- package/src/commands/personas.js +50 -0
- package/src/commands/run.js +240 -21
- package/src/commands/simulate.js +298 -0
- package/src/run/args.js +18 -1
- package/src/run/drivers/web.js +389 -79
- package/src/run/noise.js +102 -0
- package/src/run/personaLoop.js +308 -24
- package/src/run/report.js +187 -0
- package/src/run/resume.js +5 -0
- package/src/run/tools.js +1 -1
- package/src/runDefaults.js +90 -0
- package/src/simulation/index.js +176 -0
- package/src/simulation/protocol.js +90 -0
- package/src/state/index.js +4 -1
- package/src/ui/index.js +15 -1
|
@@ -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
|
|
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
|
|
71
|
-
the
|
|
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
|
|
package/src/commands/brief.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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' },
|
package/src/commands/check.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
package/src/commands/fix.js
CHANGED
|
@@ -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,
|