ravensight-playtest 0.2.5 → 0.2.7
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 +32 -2
- package/package.json +1 -1
- package/src/auth/session.js +4 -0
- package/src/commands/check.js +9 -2
- package/src/commands/fakeCore.js +36 -7
- package/src/commands/run.js +99 -4
- package/src/run/aggregate.js +1 -0
- package/src/run/drivers/web.js +10 -0
- package/src/run/personaLoop.js +193 -12
- package/src/run/synthetic.js +12 -1
- package/src/states.js +4 -2
package/README.md
CHANGED
|
@@ -223,11 +223,41 @@ from the page: the browser driver sets `window.__RAVENSIGHT_PLAYTEST__` and
|
|
|
223
223
|
`localStorage['ravensight.playtest']` before the game's scripts run. It is never
|
|
224
224
|
sent as a request header, which would reach your game's own API too.
|
|
225
225
|
|
|
226
|
+
With no ingest key configured, a Godot run says "no ingest key configured, so
|
|
227
|
+
no synthetic telemetry was sent". A web run says "synthetic telemetry comes from
|
|
228
|
+
the game's own SDK on the page, sent with the run's playtest token" instead,
|
|
229
|
+
because the page's SDK already sends the game's events marked synthetic.
|
|
230
|
+
|
|
226
231
|
When a run reaches its model budget before the report step, nothing more is
|
|
227
232
|
asked of the model. The persona's notes are written to a partial `report.json`
|
|
228
233
|
(`partial: true`, one unreviewed finding per note with the nearest screenshot as
|
|
229
234
|
evidence) and uploaded, so the job's aggregate still counts them.
|
|
230
235
|
|
|
236
|
+
When the budget runs out during the report step itself, the same partial report
|
|
237
|
+
is uploaded and the run is completed as `budget_exceeded`. The job does not exit 2 for it, and the CLI
|
|
238
|
+
prints "Run ran out of budget; the partial report was uploaded and the refund
|
|
239
|
+
decision is the server's". When the server's answer says a refund is
|
|
240
|
+
expected, the line ends "a refund of $X.XX is expected when the job settles"
|
|
241
|
+
instead, and "the run was refunded" once the job has settled and the run was
|
|
242
|
+
refunded. If the server refused the partial report, it says so and where the
|
|
243
|
+
notes are instead. An older API that refuses that completion prints its own
|
|
244
|
+
message, the run is completed as `failed`, and the job exits 1.
|
|
245
|
+
|
|
246
|
+
How the report step reads what happened:
|
|
247
|
+
|
|
248
|
+
- Console errors are recorded as automatic notes. A 4xx on a request (`Failed
|
|
249
|
+
to load resource`, `HTTP 404`, `status: 401`, `401 Unauthorized` and similar)
|
|
250
|
+
is a network note: context, not a bug. A finding whose only evidence is
|
|
251
|
+
automatic network notes is capped at `minor` by the CLI, and its description
|
|
252
|
+
says so.
|
|
253
|
+
- Every note carries `after`, the URL and title of the screen that followed
|
|
254
|
+
it. A blocker or major finding has to cite the observation where the failure
|
|
255
|
+
is visible and must not be contradicted by that next screen or by a later
|
|
256
|
+
persona note.
|
|
257
|
+
- The model is told never to write calendar dates. `report.md` carries the
|
|
258
|
+
CLI's own `Generated at` timestamp under its title, and the aggregate report's
|
|
259
|
+
header uses its `generated_at`.
|
|
260
|
+
|
|
231
261
|
### fix
|
|
232
262
|
|
|
233
263
|
Takes one finding, attempts the change on a branch of its own, replays the
|
|
@@ -383,7 +413,7 @@ start avoids that.
|
|
|
383
413
|
|---|---|
|
|
384
414
|
| 0 | Success |
|
|
385
415
|
| 1 | Failure |
|
|
386
|
-
| 2 | Budget exceeded. Partial results were written and uploaded. |
|
|
416
|
+
| 2 | Budget exceeded during play. Partial results were written and uploaded. A run that ran out during the report step and was completed does not make the job exit 2. |
|
|
387
417
|
| 3 | Environment check failed |
|
|
388
418
|
| 4 | Authentication failed |
|
|
389
419
|
| 5 | Model API error after retries |
|
|
@@ -395,7 +425,7 @@ CI can treat 2 as a success with a warning.
|
|
|
395
425
|
|
|
396
426
|
| Variable | Effect |
|
|
397
427
|
|---|---|
|
|
398
|
-
| `RAVENSIGHT_TOKEN` | Use this token instead of the keychain. The CI path. |
|
|
428
|
+
| `RAVENSIGHT_TOKEN` | Use this token instead of the keychain. The CI path. It must be a `gt_cli_` token, the kind `login` stores. A `gt_mcp_` token is an MCP personal access token and a `gt_live_` token is a game's ingest key; `check` refuses both and says which one it saw. |
|
|
399
429
|
| `RAVENSIGHT_API_URL` | The API base URL. Defaults to `https://api.ravensight.io`. |
|
|
400
430
|
| `RAVENSIGHT_DASHBOARD_URL` | Where the dashboard is, if it is not derivable from the API URL. |
|
|
401
431
|
| `RAVENSIGHT_GAME_ID` | The game, when there is no linked repo. |
|
package/package.json
CHANGED
package/src/auth/session.js
CHANGED
|
@@ -68,6 +68,10 @@ export function looksLikeIngestKey(token) {
|
|
|
68
68
|
return typeof token === 'string' && token.startsWith('gt_live_');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
export function looksLikeMcpToken(token) {
|
|
72
|
+
return typeof token === 'string' && token.startsWith('gt_mcp_');
|
|
73
|
+
}
|
|
74
|
+
|
|
71
75
|
/**
|
|
72
76
|
* What to do when the server refuses the credential.
|
|
73
77
|
*
|
package/src/commands/check.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createClient } from '../api/client.js';
|
|
2
2
|
import { ApiError } from '../api/errors.js';
|
|
3
3
|
import { resolveApiUrl, paths } from '../paths.js';
|
|
4
|
-
import { resolveToken, looksLikeIngestKey } from '../auth/session.js';
|
|
4
|
+
import { resolveToken, looksLikeIngestKey, looksLikeMcpToken } from '../auth/session.js';
|
|
5
5
|
import { backendName } from '../auth/keychain.js';
|
|
6
6
|
import { loadConfig } from '../config.js';
|
|
7
7
|
import { packs } from '../packs/index.js';
|
|
@@ -183,12 +183,19 @@ export async function runChecks(flags = {}, deps = {}) {
|
|
|
183
183
|
'that is an ingest key, not a CLI token',
|
|
184
184
|
'run login, or set RAVENSIGHT_TOKEN to a gt_cli_ token'
|
|
185
185
|
));
|
|
186
|
+
} else if (looksLikeMcpToken(token)) {
|
|
187
|
+
rows.push(row(
|
|
188
|
+
'token valid',
|
|
189
|
+
'fail',
|
|
190
|
+
'This is an MCP personal access token (gt_mcp_). The CLI needs its own token (gt_cli_): run `ravensight-playtest login`, or set RAVENSIGHT_TOKEN to a gt_cli_ token in CI.',
|
|
191
|
+
'ravensight-playtest login'
|
|
192
|
+
));
|
|
186
193
|
} else if (!token) {
|
|
187
194
|
rows.push(row('token valid', 'fail', `not logged in to ${host}`, 'ravensight-playtest login'));
|
|
188
195
|
}
|
|
189
196
|
|
|
190
197
|
let whoami = null;
|
|
191
|
-
if (token && !looksLikeIngestKey(token)) {
|
|
198
|
+
if (token && !looksLikeIngestKey(token) && !looksLikeMcpToken(token)) {
|
|
192
199
|
const authed = deps.authedApi || createClient({ apiUrl, token });
|
|
193
200
|
try {
|
|
194
201
|
whoami = await authed.cli.whoami();
|
package/src/commands/fakeCore.js
CHANGED
|
@@ -18,13 +18,17 @@ const SKILL = ['---', 'name: persona-playtest', 'prompt_version: persona-playtes
|
|
|
18
18
|
const PROFILE_SKILL = ['---', 'name: game-profile', 'prompt_version: game-profile@0.1.0', '---', '# Game Profile'].join('\n');
|
|
19
19
|
const FIX_SKILL = ['---', 'name: fix', 'prompt_version: fix@0.1.0', '---', '# Fix'].join('\n');
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* The server's table, verbatim, including the change that lets a run out of
|
|
23
|
+
* money in its report step end as budget_exceeded from `reporting` or
|
|
24
|
+
* `uploading`.
|
|
25
|
+
*/
|
|
22
26
|
export const RUN_TRANSITIONS = Object.freeze({
|
|
23
27
|
queued: ['launching', 'canceled', 'skipped', 'failed', 'interrupted'],
|
|
24
28
|
launching: ['playing', 'failed', 'canceled', 'interrupted'],
|
|
25
29
|
playing: ['reporting', 'failed', 'canceled', 'budget_exceeded', 'interrupted'],
|
|
26
|
-
reporting: ['uploading', 'failed', 'interrupted'],
|
|
27
|
-
uploading: ['succeeded', 'failed', 'interrupted'],
|
|
30
|
+
reporting: ['uploading', 'failed', 'budget_exceeded', 'interrupted'],
|
|
31
|
+
uploading: ['succeeded', 'failed', 'budget_exceeded', 'interrupted'],
|
|
28
32
|
interrupted: ['launching', 'playing', 'reporting', 'uploading', 'failed', 'canceled'],
|
|
29
33
|
succeeded: [],
|
|
30
34
|
failed: [],
|
|
@@ -33,6 +37,13 @@ export const RUN_TRANSITIONS = Object.freeze({
|
|
|
33
37
|
skipped: []
|
|
34
38
|
});
|
|
35
39
|
|
|
40
|
+
/** An API from before that change, which refuses budget_exceeded after `playing`. */
|
|
41
|
+
export const OLDER_RUN_TRANSITIONS = Object.freeze({
|
|
42
|
+
...RUN_TRANSITIONS,
|
|
43
|
+
reporting: ['uploading', 'failed', 'interrupted'],
|
|
44
|
+
uploading: ['succeeded', 'failed', 'interrupted']
|
|
45
|
+
});
|
|
46
|
+
|
|
36
47
|
export const RUN_TERMINAL_STATES = Object.freeze(['succeeded', 'failed', 'canceled', 'budget_exceeded', 'skipped']);
|
|
37
48
|
|
|
38
49
|
/** Shaped like cli-core's `ApiError`: `status`, `code`, `body`. */
|
|
@@ -234,7 +245,10 @@ export function fakeCore({
|
|
|
234
245
|
findings = [fakeFinding()],
|
|
235
246
|
triage = { verdict: 'bug', confidence: 0.9, source: 'brief' },
|
|
236
247
|
graphContext = { levels: [{ name: 'title', exits: ['level_1'] }] },
|
|
237
|
-
verifyVerdict = { reproduced: 0.1, source: 'judge' }
|
|
248
|
+
verifyVerdict = { reproduced: 0.1, source: 'judge' },
|
|
249
|
+
transitions = RUN_TRANSITIONS,
|
|
250
|
+
completeExtras = {},
|
|
251
|
+
runRefund = {}
|
|
238
252
|
} = {}) {
|
|
239
253
|
const calls = [];
|
|
240
254
|
const record = (name, payload) => calls.push({ name, ...payload });
|
|
@@ -250,7 +264,7 @@ export function fakeCore({
|
|
|
250
264
|
|
|
251
265
|
const move = (runId, to) => {
|
|
252
266
|
const from = runStates.get(runId) || 'queued';
|
|
253
|
-
if (!(
|
|
267
|
+
if (!(transitions[from] || []).includes(to)) {
|
|
254
268
|
throw new FakeApiError({
|
|
255
269
|
status: 409,
|
|
256
270
|
code: 'invalid_transition',
|
|
@@ -347,8 +361,23 @@ export function fakeCore({
|
|
|
347
361
|
if (RUN_TERMINAL_STATES.includes(from)) {
|
|
348
362
|
throw new FakeApiError({ status: 409, code: 'run_already_complete', message: 'That run has already finished.', body: { state: from } });
|
|
349
363
|
}
|
|
350
|
-
|
|
351
|
-
|
|
364
|
+
// The server's complete reads the table too, after advancing a
|
|
365
|
+
// `reporting` run that is succeeding to `uploading` itself.
|
|
366
|
+
const state = body.state || 'succeeded';
|
|
367
|
+
const effective = from === 'reporting' && state === 'succeeded' ? 'uploading' : from;
|
|
368
|
+
if (!(transitions[effective] || []).includes(state)) {
|
|
369
|
+
throw new FakeApiError({ status: 409, code: 'invalid_transition', message: `A run cannot go from "${effective}" to "${state}".`, body: { from: effective, to: state } });
|
|
370
|
+
}
|
|
371
|
+
runStates.set(runId, state);
|
|
372
|
+
// The server's refund fields ride on `run`. Before the job settles
|
|
373
|
+
// `refunded` is false and `refund_expected` says whether settling
|
|
374
|
+
// would give the run's unit back.
|
|
375
|
+
return {
|
|
376
|
+
run: { run_id: runId, state, refunded: false, refunded_cents: 0, refund_expected: false, refund_expected_cents: 0, ...runRefund },
|
|
377
|
+
dropped_findings: 0,
|
|
378
|
+
report_lint: [],
|
|
379
|
+
...completeExtras
|
|
380
|
+
};
|
|
352
381
|
},
|
|
353
382
|
async list(gameId, jobId) {
|
|
354
383
|
record('list', { jobId });
|
package/src/commands/run.js
CHANGED
|
@@ -433,7 +433,14 @@ export async function playJob({
|
|
|
433
433
|
log
|
|
434
434
|
});
|
|
435
435
|
|
|
436
|
+
// A run that ran out of money in the report step, after its partial report
|
|
437
|
+
// was uploaded and the run completed, delivered what it bought: it does not
|
|
438
|
+
// make the job exit 2. One the server refused to complete exits 1, never 2,
|
|
439
|
+
// because 2 promises the partial results were shipped.
|
|
440
|
+
const playBudgetHit = results.some(result => result.state === 'budget_exceeded' && !result.reportStepBudget);
|
|
436
441
|
const budgetHit = results.some(result => result.state === 'budget_exceeded');
|
|
442
|
+
const reportBudgetRefused = results.some(result => result.reportStepBudget === 'refused');
|
|
443
|
+
const reportBudgetDelivered = results.some(result => result.reportStepBudget === 'delivered' || result.reportStepBudget === 'dry_upload');
|
|
437
444
|
const canceled = results.some(result => result.state === 'canceled');
|
|
438
445
|
const anySucceeded = results.some(result => result.state === 'succeeded');
|
|
439
446
|
|
|
@@ -449,8 +456,9 @@ export async function playJob({
|
|
|
449
456
|
log(`Reports: ${directory}`);
|
|
450
457
|
|
|
451
458
|
if (canceled) return EXIT.CANCELED;
|
|
452
|
-
if (
|
|
453
|
-
|
|
459
|
+
if (reportBudgetRefused) return EXIT.FAILED;
|
|
460
|
+
if (playBudgetHit) return EXIT.BUDGET;
|
|
461
|
+
return anySucceeded || reportBudgetDelivered ? EXIT.OK : EXIT.FAILED;
|
|
454
462
|
}
|
|
455
463
|
|
|
456
464
|
/**
|
|
@@ -554,7 +562,47 @@ export async function playPersonaRun({
|
|
|
554
562
|
if (inFlight) inFlight.delete(runId);
|
|
555
563
|
const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
|
|
556
564
|
await stateFile.patchRun(repoRoot, journal, runId, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
|
|
557
|
-
|
|
565
|
+
const delivery = {};
|
|
566
|
+
const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, delivery });
|
|
567
|
+
if (isReportStepBudget(failed)) {
|
|
568
|
+
// The money ran out while the report was being written. The partial
|
|
569
|
+
// report went up and the run is completed as budget_exceeded from
|
|
570
|
+
// `uploading`; what happens to the charge is the server's decision.
|
|
571
|
+
if (answer) {
|
|
572
|
+
await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
|
|
573
|
+
// "Uploaded" only when it was: the server can refuse the partial
|
|
574
|
+
// report (and the run is completed without it), and the report may
|
|
575
|
+
// never have been written.
|
|
576
|
+
const message = delivery.reportRefused || !failed.report
|
|
577
|
+
? `Run ran out of budget; the server refused the partial report; the persona's notes are in ${directory}`
|
|
578
|
+
: reportBudgetMessage(answer);
|
|
579
|
+
log(`run ${runId} (${persona.slug}): ${message}`);
|
|
580
|
+
return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, reportStepBudget: 'delivered' };
|
|
581
|
+
}
|
|
582
|
+
if (options.dryUpload) {
|
|
583
|
+
// Nothing was sent, so the server still holds the run where it was.
|
|
584
|
+
// Not a refusal, so the job does not exit 1 for it.
|
|
585
|
+
log(`run ${runId} (${persona.slug}) ran out of budget while writing its report: dry upload, run not completed on the server.`);
|
|
586
|
+
return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, reportStepBudget: 'dry_upload' };
|
|
587
|
+
}
|
|
588
|
+
log(`run ${runId} (${persona.slug}) ran out of budget while writing its report, and the server would not complete it: ${delivery.message || 'no reason given'}`);
|
|
589
|
+
// An older API refuses budget_exceeded after `playing`. The CLI moved
|
|
590
|
+
// the run to `uploading` itself, so it does not leave it there: `failed`
|
|
591
|
+
// is allowed from `uploading` on every table.
|
|
592
|
+
try {
|
|
593
|
+
await api.runs.complete(gameId, jobId, runId, {
|
|
594
|
+
state: 'failed',
|
|
595
|
+
reason: `ran out of budget while writing the report; the server refused budget_exceeded (${delivery.message || 'no reason given'})`,
|
|
596
|
+
actions_taken: failed.actionsTaken,
|
|
597
|
+
quit_reason: failed.quitReason
|
|
598
|
+
});
|
|
599
|
+
await stateFile.patchRun(repoRoot, journal, runId, { completed: true, state: 'failed' });
|
|
600
|
+
log(`run ${runId} (${persona.slug}): completed as failed instead.`);
|
|
601
|
+
} catch (fallbackError) {
|
|
602
|
+
log(`run ${runId} (${persona.slug}): could not be completed as failed either (${String(fallbackError && fallbackError.message)}). "ravensight-playtest resume" can retry it.`);
|
|
603
|
+
}
|
|
604
|
+
return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, reportStepBudget: 'refused', error };
|
|
605
|
+
}
|
|
558
606
|
log(`run ${runId} (${persona.slug}) failed: ${String(error && error.message)}`);
|
|
559
607
|
return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, error };
|
|
560
608
|
}
|
|
@@ -576,6 +624,9 @@ export async function playPersonaRun({
|
|
|
576
624
|
apiUrl,
|
|
577
625
|
ingestKey: (config && config.ingest_key) || process.env.RAVENSIGHT_INGEST_KEY || null,
|
|
578
626
|
playtestToken: entry.playtest_token,
|
|
627
|
+
// The web driver injects the playtest token into the page (see
|
|
628
|
+
// driverOptions), so the game's own SDK sends with it.
|
|
629
|
+
pageSdk: driverName === 'playwright_web' && Boolean(entry.playtest_token),
|
|
579
630
|
context: { jobId, runId, persona: persona.slug, gameVersion: (config && config.game_version) || 'unknown' },
|
|
580
631
|
report: outcome.report,
|
|
581
632
|
driver: driverName,
|
|
@@ -604,7 +655,7 @@ export async function playPersonaRun({
|
|
|
604
655
|
* uploaded and completed in exactly this order, including the `uploading`
|
|
605
656
|
* transition the server's table allows from `reporting` and nowhere else.
|
|
606
657
|
*/
|
|
607
|
-
export async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot }) {
|
|
658
|
+
export async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery = null }) {
|
|
608
659
|
const body = {
|
|
609
660
|
state: outcome.state,
|
|
610
661
|
reason: outcome.quitDetail || '',
|
|
@@ -655,6 +706,7 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
655
706
|
// report.json either way.
|
|
656
707
|
if (!(body.report && body.report.partial && error && error.status === 422 && error.code === 'invalid_report')) throw error;
|
|
657
708
|
log(`run ${runId}: the server refused the partial report (${String(error.message)}), so the run is completed without it. The notes are in ${directory}.`);
|
|
709
|
+
if (delivery) delivery.reportRefused = true;
|
|
658
710
|
const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
|
|
659
711
|
answer = await api.runs.complete(gameId, jobId, runId, withoutReport);
|
|
660
712
|
}
|
|
@@ -667,11 +719,54 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
667
719
|
}
|
|
668
720
|
return answer;
|
|
669
721
|
} catch (error) {
|
|
722
|
+
if (delivery) {
|
|
723
|
+
delivery.message = String(error && error.message);
|
|
724
|
+
delivery.status = error && error.status;
|
|
725
|
+
delivery.code = error && error.code;
|
|
726
|
+
}
|
|
670
727
|
log(`run ${runId}: upload or complete was refused (${String(error && error.message)}). The artifacts are in the run directory and "ravensight-playtest resume" can retry it.`);
|
|
671
728
|
return null;
|
|
672
729
|
}
|
|
673
730
|
}
|
|
674
731
|
|
|
732
|
+
/**
|
|
733
|
+
* Whether a run ran out of money in the report step: it is budget_exceeded
|
|
734
|
+
* and the server had already accepted `reporting` (or `uploading`) for it.
|
|
735
|
+
*
|
|
736
|
+
* @param {Object} outcome
|
|
737
|
+
* @returns {boolean}
|
|
738
|
+
*/
|
|
739
|
+
export function isReportStepBudget(outcome) {
|
|
740
|
+
return Boolean(outcome) && outcome.state === 'budget_exceeded' && ['reporting', 'uploading'].includes(outcome.reachedState);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* What the developer is told when a report-step budget run was completed.
|
|
745
|
+
* The refund is the server's decision; the CLI only repeats what the
|
|
746
|
+
* complete answer says about it. Before the job settles the answer carries
|
|
747
|
+
* `refund_expected` and `refund_expected_cents`; `refunded` is true only once
|
|
748
|
+
* settlement gave the unit back. Each may be on the answer or on its `run`.
|
|
749
|
+
*
|
|
750
|
+
* @param {Object} answer the complete response
|
|
751
|
+
* @returns {string}
|
|
752
|
+
*/
|
|
753
|
+
export function reportBudgetMessage(answer) {
|
|
754
|
+
const read = name => {
|
|
755
|
+
if (!answer) return undefined;
|
|
756
|
+
if (answer[name] !== undefined) return answer[name];
|
|
757
|
+
return answer.run ? answer.run[name] : undefined;
|
|
758
|
+
};
|
|
759
|
+
const prefix = 'Run ran out of budget; the partial report was uploaded and';
|
|
760
|
+
if (read('refunded') === true) return `${prefix} the run was refunded`;
|
|
761
|
+
if (read('refund_expected') === true) {
|
|
762
|
+
const cents = read('refund_expected_cents');
|
|
763
|
+
return Number.isFinite(Number(cents)) && cents !== null && Number(cents) > 0
|
|
764
|
+
? `${prefix} a refund of ${formatCents(cents)} is expected when the job settles`
|
|
765
|
+
: `${prefix} a refund is expected when the job settles`;
|
|
766
|
+
}
|
|
767
|
+
return `${prefix} the refund decision is the server's`;
|
|
768
|
+
}
|
|
769
|
+
|
|
675
770
|
/**
|
|
676
771
|
* Build the aggregate, and drive the `aggregate_report` run's own state machine
|
|
677
772
|
* while doing it.
|
package/src/run/aggregate.js
CHANGED
|
@@ -447,6 +447,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
|
|
|
447
447
|
' "known":["iss_..."], "excluded_by_intent":[{"issue_id":"iss_...","intent":"<the stated intent it matches>"}],',
|
|
448
448
|
' "tldr":["up to five short bullets: the most important fixes and the strongest positive signal"]}',
|
|
449
449
|
'',
|
|
450
|
+
'Never write calendar dates or times in the tldr or anywhere else; the runner stamps generated_at on the report.',
|
|
450
451
|
'Rules. A criterion no run touched is "not testable"; never invent evidence for one. An issue is "known" only when the brief already describes it. An issue is excluded by intent only when the persona experience is CONSISTENT with the stated intent; when it CONTRADICTS the intent, leave it in the list, because that is the valuable case.',
|
|
451
452
|
'',
|
|
452
453
|
`success criteria:\n${criteria.length > 0 ? criteria.map(row => ` ${row.criterion_id}: ${row.text}`).join('\n') : ' (none stated)'}`,
|
package/src/run/drivers/web.js
CHANGED
|
@@ -361,6 +361,16 @@ export function createWebDriver({ importer, headless = true, playtest = null } =
|
|
|
361
361
|
url: current.url(),
|
|
362
362
|
elapsedMs: Date.now() - started
|
|
363
363
|
};
|
|
364
|
+
// The page title is what a note's "after" state is checked against in
|
|
365
|
+
// the report step. A page that cannot answer has no title, not an error.
|
|
366
|
+
try {
|
|
367
|
+
if (typeof current.title === 'function') {
|
|
368
|
+
const title = await current.title();
|
|
369
|
+
if (title) observation.title = String(title);
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// No title is still an observation.
|
|
373
|
+
}
|
|
364
374
|
// Canvas builds (a Godot or Unity web export is one <canvas> and
|
|
365
375
|
// nothing else) have no accessibility tree worth reading, so vision
|
|
366
376
|
// is the only way to play them. Spend an image only then.
|
package/src/run/personaLoop.js
CHANGED
|
@@ -51,8 +51,22 @@ const nextOrder = () => {
|
|
|
51
51
|
return ORDER;
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* A 4xx on a request, in the shapes a browser console and a game's own logging
|
|
56
|
+
* print it: `Failed to load resource: ... status of 4xx`, `HTTP 404`,
|
|
57
|
+
* `status: 401`, `statusCode=403`, `401 Unauthorized`. A missing asset or a
|
|
58
|
+
* refused call, not a crash.
|
|
59
|
+
*/
|
|
60
|
+
export const RESOURCE_4XX = /Failed to load resource: the server responded with a status of 4\d\d|\bHTTP 4\d\d\b|status(?:Code)?\s*[:=]?\s*4\d\d\b|\b4\d\d (?:Unauthorized|Forbidden|Not Found|Bad Request)\b/i;
|
|
61
|
+
|
|
62
|
+
/** The screenshots the runner takes on its own, which no persona described. */
|
|
63
|
+
const RUNNER_SCREENSHOTS = Object.freeze(['landing', 'console-error', 'final']);
|
|
64
|
+
|
|
65
|
+
/** Severities above `minor`, which network notes alone cannot earn. */
|
|
66
|
+
const ABOVE_MINOR = Object.freeze(['blocker', 'major']);
|
|
67
|
+
|
|
68
|
+
/** What a capped finding's description gains, so the reader knows why. */
|
|
69
|
+
export const NETWORK_CAP_NOTE = 'Severity capped at minor by the runner: the only evidence is automatic network notes, and a failed request is a finding above minor only when the persona saw the game break because of it.';
|
|
56
70
|
|
|
57
71
|
/** A persona note kind as a report finding category. */
|
|
58
72
|
export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
|
|
@@ -127,6 +141,10 @@ export async function runPersona(args) {
|
|
|
127
141
|
const startedAt = new Date().toISOString();
|
|
128
142
|
const notes = [];
|
|
129
143
|
const screenshots = [];
|
|
144
|
+
// Every observation the persona was shown, by index, so a note can carry
|
|
145
|
+
// what the screen looked like next and a finding can cite where a failure
|
|
146
|
+
// is visible.
|
|
147
|
+
const observations = [];
|
|
130
148
|
let imageBudget = maxImages;
|
|
131
149
|
|
|
132
150
|
await mkdir(path.join(runDirectory, 'screenshots'), { recursive: true });
|
|
@@ -186,6 +204,7 @@ export async function runPersona(args) {
|
|
|
186
204
|
ledger,
|
|
187
205
|
notes,
|
|
188
206
|
screenshots,
|
|
207
|
+
observations,
|
|
189
208
|
imageBudget,
|
|
190
209
|
keepObservations,
|
|
191
210
|
onImageBudget: next => {
|
|
@@ -242,6 +261,7 @@ export async function runPersona(args) {
|
|
|
242
261
|
ledger,
|
|
243
262
|
notes,
|
|
244
263
|
screenshots,
|
|
264
|
+
observations,
|
|
245
265
|
play,
|
|
246
266
|
startedAt,
|
|
247
267
|
endedAt,
|
|
@@ -305,6 +325,7 @@ async function playLoop({
|
|
|
305
325
|
ledger,
|
|
306
326
|
notes,
|
|
307
327
|
screenshots,
|
|
328
|
+
observations = [],
|
|
308
329
|
imageBudget,
|
|
309
330
|
keepObservations,
|
|
310
331
|
heartbeat,
|
|
@@ -343,7 +364,26 @@ async function playLoop({
|
|
|
343
364
|
let wrapUpLeft = Infinity;
|
|
344
365
|
const maxTurns = maxActions * 3 + 20;
|
|
345
366
|
|
|
367
|
+
// Notes waiting for the next observation, which is the screen they are
|
|
368
|
+
// checked against: a failure the next screen does not show is not a
|
|
369
|
+
// blocker.
|
|
370
|
+
let awaitingAfter = [];
|
|
371
|
+
const observed = observation => {
|
|
372
|
+
const entry = { index: observations.length + 1, step: observation.step, url: observation.url || null, title: observationTitle(observation) };
|
|
373
|
+
observations.push(entry);
|
|
374
|
+
for (const note of awaitingAfter) note.after = { url: entry.url, title: entry.title };
|
|
375
|
+
awaitingAfter = [];
|
|
376
|
+
return entry;
|
|
377
|
+
};
|
|
378
|
+
const noted = note => {
|
|
379
|
+
const entry = { ...note, at_action: actions, observation: observations.length, order: nextOrder() };
|
|
380
|
+
notes.push(entry);
|
|
381
|
+
awaitingAfter.push(entry);
|
|
382
|
+
return entry;
|
|
383
|
+
};
|
|
384
|
+
|
|
346
385
|
const first = await driver.observe();
|
|
386
|
+
observed(first);
|
|
347
387
|
latestObservation = first.text;
|
|
348
388
|
visualObservation = Boolean(first.screenshotBase64);
|
|
349
389
|
transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
|
|
@@ -458,7 +498,7 @@ async function playLoop({
|
|
|
458
498
|
// (bug, confusing, good, suggestion, reaction) would otherwise
|
|
459
499
|
// overwrite the transcript line's `kind`, and every note would
|
|
460
500
|
// read as a different sort of line.
|
|
461
|
-
|
|
501
|
+
noted(note);
|
|
462
502
|
transcript.write({ kind: 'note', tool: toolUse.name, note });
|
|
463
503
|
},
|
|
464
504
|
onScreenshot: shot => {
|
|
@@ -479,6 +519,7 @@ async function playLoop({
|
|
|
479
519
|
actions
|
|
480
520
|
});
|
|
481
521
|
if (executed.observation) {
|
|
522
|
+
observed(executed.observation);
|
|
482
523
|
latestObservation = executed.observation.text;
|
|
483
524
|
visualObservation = Boolean(executed.observation.screenshotBase64);
|
|
484
525
|
recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
|
|
@@ -513,8 +554,8 @@ async function playLoop({
|
|
|
513
554
|
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
|
|
514
555
|
for (const entry of consoleErrors) {
|
|
515
556
|
const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
|
|
516
|
-
const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true };
|
|
517
|
-
|
|
557
|
+
const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
|
|
558
|
+
noted(note);
|
|
518
559
|
transcript.write({ kind: 'note', note });
|
|
519
560
|
}
|
|
520
561
|
asides.push(`The game logged ${consoleErrors.length} error(s): ${consoleErrors.map(entry => entry.text).join(' | ').slice(0, 800)}${shot ? ` A screenshot is saved at ${shot.relativePath}.` : ''}`);
|
|
@@ -584,6 +625,7 @@ async function reportPhase({
|
|
|
584
625
|
ledger,
|
|
585
626
|
notes,
|
|
586
627
|
screenshots,
|
|
628
|
+
observations = [],
|
|
587
629
|
play,
|
|
588
630
|
startedAt,
|
|
589
631
|
endedAt,
|
|
@@ -600,9 +642,16 @@ async function reportPhase({
|
|
|
600
642
|
`actions taken: ${play.actionsTaken} stop reason: ${play.quitReason}${play.quitDetail ? ` (${play.quitDetail})` : ''}`,
|
|
601
643
|
'',
|
|
602
644
|
'Notes you logged, in order:',
|
|
603
|
-
notes.length > 0 ? notes.map((note, i) =>
|
|
645
|
+
notes.length > 0 ? notes.map((note, i) => noteLine(note, i)).join('\n') : '(none)',
|
|
646
|
+
'',
|
|
647
|
+
'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it. A finding whose only evidence is automatic network notes is capped at minor by the runner.',
|
|
648
|
+
'',
|
|
649
|
+
'Observations the persona was shown, by index:',
|
|
650
|
+
observationLines(observations),
|
|
604
651
|
'',
|
|
605
|
-
'
|
|
652
|
+
'Each note carries "after": the URL and title of the observation that followed it. A blocker or major finding must cite the observation index where the failure is visible, as evidence {"type": "transcript_step", "ref": "observation N"}, and must not be contradicted by its note\'s "after" state or by a later persona note: if the next screen or a later note shows the game working, it is not a blocker or a major. Cite a note as {"type": "transcript_step", "ref": "note N"}.',
|
|
653
|
+
'',
|
|
654
|
+
'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated.',
|
|
606
655
|
'',
|
|
607
656
|
'Screenshots available as evidence (use these exact paths in evidence refs):',
|
|
608
657
|
screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
|
|
@@ -669,7 +718,7 @@ async function reportPhase({
|
|
|
669
718
|
quitReason: play.quitReason,
|
|
670
719
|
quitDetail: play.quitDetail
|
|
671
720
|
};
|
|
672
|
-
const candidate = parsed ? stampReport(parsed, facts) : null;
|
|
721
|
+
const candidate = parsed ? capNetworkOnlyFindings(stampReport(parsed, facts), { notes, screenshots }) : null;
|
|
673
722
|
const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
|
|
674
723
|
const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
|
|
675
724
|
// The server lints the same four sections at `complete` and records the
|
|
@@ -715,6 +764,7 @@ async function reportPhase({
|
|
|
715
764
|
if (!markdown) {
|
|
716
765
|
markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
|
|
717
766
|
}
|
|
767
|
+
markdown = stampGeneratedAt(markdown, new Date().toISOString());
|
|
718
768
|
|
|
719
769
|
await writeReportFiles({ runDirectory, markdown, report });
|
|
720
770
|
return { report, markdown, lint };
|
|
@@ -1033,17 +1083,148 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
|
|
|
1033
1083
|
play,
|
|
1034
1084
|
reason: 'The run reached its model budget before the report step. What the persona saw is below, and report.json carries the notes as unreviewed findings.'
|
|
1035
1085
|
});
|
|
1036
|
-
|
|
1086
|
+
const stamped = stampGeneratedAt(markdown, new Date().toISOString());
|
|
1087
|
+
await writeReportFiles({ runDirectory, markdown: stamped, report });
|
|
1037
1088
|
outcome.report = report;
|
|
1038
|
-
outcome.reportMarkdown =
|
|
1089
|
+
outcome.reportMarkdown = stamped;
|
|
1039
1090
|
}
|
|
1040
1091
|
|
|
1041
1092
|
async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
|
|
1042
|
-
const markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason });
|
|
1093
|
+
const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString());
|
|
1043
1094
|
assertNoSecrets('report.md', markdown);
|
|
1044
1095
|
await writeFile(path.join(runDirectory, RUN_FILES.reportMd), markdown, 'utf8');
|
|
1045
1096
|
}
|
|
1046
1097
|
|
|
1098
|
+
/**
|
|
1099
|
+
* A note as one numbered line of the report prompt: its kind, whether the
|
|
1100
|
+
* runner recorded it, the observation it was made on and the screen that
|
|
1101
|
+
* followed it.
|
|
1102
|
+
*
|
|
1103
|
+
* @param {Object} note
|
|
1104
|
+
* @param {number} i
|
|
1105
|
+
* @returns {string}
|
|
1106
|
+
*/
|
|
1107
|
+
export function noteLine(note, i) {
|
|
1108
|
+
const tags = [
|
|
1109
|
+
note.automatic ? 'automatic' : null,
|
|
1110
|
+
Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
|
|
1111
|
+
note.screenshot ? `screenshot ${note.screenshot}` : null
|
|
1112
|
+
].filter(Boolean);
|
|
1113
|
+
const after = note.after
|
|
1114
|
+
? ` after: ${JSON.stringify({ url: note.after.url ?? null, title: note.after.title ?? null })}`
|
|
1115
|
+
: ' after: (no later observation)';
|
|
1116
|
+
return `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}${tags.length > 0 ? ` (${tags.join(', ')})` : ''}${after}`;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** The most observations listed in the report prompt; the newest are kept. */
|
|
1120
|
+
export const MAX_OBSERVATIONS_IN_REPORT = 200;
|
|
1121
|
+
|
|
1122
|
+
function observationLines(observations) {
|
|
1123
|
+
if (!observations || observations.length === 0) return '(none)';
|
|
1124
|
+
const shown = observations.slice(-MAX_OBSERVATIONS_IN_REPORT);
|
|
1125
|
+
const lines = shown.map(entry => `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}`);
|
|
1126
|
+
if (shown.length < observations.length) lines.unshift(`(the first ${observations.length - shown.length} are left out)`);
|
|
1127
|
+
return lines.join('\n');
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* A short title for an observation: the page title when the driver has one,
|
|
1132
|
+
* otherwise the first line of what was observed.
|
|
1133
|
+
*
|
|
1134
|
+
* @param {Object} observation
|
|
1135
|
+
* @returns {string|null}
|
|
1136
|
+
*/
|
|
1137
|
+
export function observationTitle(observation) {
|
|
1138
|
+
if (observation && typeof observation.title === 'string' && observation.title.trim()) return observation.title.trim().slice(0, 120);
|
|
1139
|
+
const line = String((observation && observation.text) || '').split('\n').map(part => part.trim()).find(Boolean);
|
|
1140
|
+
return line ? line.slice(0, 80) : null;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Cap at `minor` every finding whose evidence is only automatic network
|
|
1145
|
+
* notes, in code rather than by asking.
|
|
1146
|
+
*
|
|
1147
|
+
* A finding rests only on network notes when it cites at least one (a
|
|
1148
|
+
* `note N` ref to an automatic network note, or the runner's console-error
|
|
1149
|
+
* screenshot whose notes were all network notes) and nothing else: no persona
|
|
1150
|
+
* note, no automatic non-network note, no observation ref, no screenshot the
|
|
1151
|
+
* persona took, no other evidence type. Only the runner's own landing and
|
|
1152
|
+
* final screenshots neither count for nor against it.
|
|
1153
|
+
*
|
|
1154
|
+
* @param {Object} report a stamped report
|
|
1155
|
+
* @param {{notes: Array<Object>, screenshots: Array<Object>}} context
|
|
1156
|
+
* @returns {Object} the report, with capped findings rewritten
|
|
1157
|
+
*/
|
|
1158
|
+
export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] } = {}) {
|
|
1159
|
+
if (!report || !Array.isArray(report.findings)) return report;
|
|
1160
|
+
const byShot = new Map();
|
|
1161
|
+
for (const note of notes) {
|
|
1162
|
+
if (!note || !note.automatic || !note.screenshot) continue;
|
|
1163
|
+
if (!byShot.has(note.screenshot)) byShot.set(note.screenshot, []);
|
|
1164
|
+
byShot.get(note.screenshot).push(note);
|
|
1165
|
+
}
|
|
1166
|
+
const reasons = new Map(screenshots.filter(shot => shot && shot.relativePath).map(shot => [shot.relativePath, String(shot.reason || '')]));
|
|
1167
|
+
|
|
1168
|
+
const verdict = finding => {
|
|
1169
|
+
let network = 0;
|
|
1170
|
+
for (const item of Array.isArray(finding.evidence) ? finding.evidence : []) {
|
|
1171
|
+
if (!item) continue;
|
|
1172
|
+
const ref = String(item.ref || '');
|
|
1173
|
+
if (item.type === 'transcript_step') {
|
|
1174
|
+
// An observation ref, or anything that is not a network note, is
|
|
1175
|
+
// other evidence.
|
|
1176
|
+
const match = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
|
|
1177
|
+
const note = match ? notes[Number(match[1]) - 1] : null;
|
|
1178
|
+
if (note && note.automatic && note.kind === 'network') network += 1;
|
|
1179
|
+
else return false;
|
|
1180
|
+
} else if (item.type === 'screenshot') {
|
|
1181
|
+
const attached = byShot.get(ref);
|
|
1182
|
+
if (attached && attached.length > 0) {
|
|
1183
|
+
if (attached.every(note => note.kind === 'network')) network += 1;
|
|
1184
|
+
else return false;
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
// The runner's own landing and final screenshots say nothing either
|
|
1188
|
+
// way; any screenshot the persona took is other evidence.
|
|
1189
|
+
const reason = reasons.has(ref) ? reasons.get(ref) : null;
|
|
1190
|
+
if (reason !== null && RUNNER_SCREENSHOTS.includes(reason)) continue;
|
|
1191
|
+
return false;
|
|
1192
|
+
} else {
|
|
1193
|
+
return false;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
return network > 0;
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
return {
|
|
1200
|
+
...report,
|
|
1201
|
+
findings: report.findings.map(finding => {
|
|
1202
|
+
if (!finding || !ABOVE_MINOR.includes(finding.severity) || !verdict(finding)) return finding;
|
|
1203
|
+
const description = String(finding.description || '');
|
|
1204
|
+
return { ...finding, severity: 'minor', description: description ? `${description}\n\n${NETWORK_CAP_NOTE}` : NETWORK_CAP_NOTE };
|
|
1205
|
+
})
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Put the runner's own timestamp at the head of a report.md, right under
|
|
1211
|
+
* its first heading, replacing one stamped before. Whatever the model wrote
|
|
1212
|
+
* in its prose is left alone; the header is what says when the report was
|
|
1213
|
+
* generated.
|
|
1214
|
+
*
|
|
1215
|
+
* @param {string} markdown
|
|
1216
|
+
* @param {string} generatedAt an ISO timestamp
|
|
1217
|
+
* @returns {string}
|
|
1218
|
+
*/
|
|
1219
|
+
export function stampGeneratedAt(markdown, generatedAt) {
|
|
1220
|
+
const line = `**Generated at:** ${generatedAt}`;
|
|
1221
|
+
const lines = String(markdown || '').split('\n').filter(entry => !/^\*\*Generated at:\*\* /.test(entry));
|
|
1222
|
+
const heading = lines.findIndex(entry => /^# /.test(entry));
|
|
1223
|
+
if (heading === -1) return [line, '', ...lines].join('\n');
|
|
1224
|
+
lines.splice(heading + 1, 0, line);
|
|
1225
|
+
return lines.join('\n');
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1047
1228
|
/**
|
|
1048
1229
|
* The `prompt_version` the report has to carry, read out of the skill's own
|
|
1049
1230
|
* frontmatter so a content pack update moves it without a CLI release.
|
|
@@ -1056,4 +1237,4 @@ export function promptVersionOf(skill) {
|
|
|
1056
1237
|
return match ? match[1] : null;
|
|
1057
1238
|
}
|
|
1058
1239
|
|
|
1059
|
-
export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot };
|
|
1240
|
+
export default { runPersona, buildSystem, promptVersionOf, shouldEscalate, renderFallbackMarkdown, notesToFindings, nearestScreenshot, capNetworkOnlyFindings, stampGeneratedAt, noteLine, observationTitle };
|
package/src/run/synthetic.js
CHANGED
|
@@ -154,6 +154,9 @@ export class SyntheticSession {
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/** The run summary's line when the page's own SDK sends the run's telemetry. */
|
|
158
|
+
export const PAGE_SDK_REASON = "synthetic telemetry comes from the game's own SDK on the page, sent with the run's playtest token";
|
|
159
|
+
|
|
157
160
|
/**
|
|
158
161
|
* Emit the three events a run produces, best effort.
|
|
159
162
|
*
|
|
@@ -161,6 +164,8 @@ export class SyntheticSession {
|
|
|
161
164
|
* @param {string} args.apiUrl
|
|
162
165
|
* @param {string|null} args.ingestKey
|
|
163
166
|
* @param {string|null} args.playtestToken
|
|
167
|
+
* @param {boolean} [args.pageSdk] the driver injected the playtest token into
|
|
168
|
+
* the page (the web driver), so the game's own SDK sends with it
|
|
164
169
|
* @param {Object} args.context
|
|
165
170
|
* @param {Object} args.report the run's report.json
|
|
166
171
|
* @param {string} args.driver
|
|
@@ -168,8 +173,14 @@ export class SyntheticSession {
|
|
|
168
173
|
* @param {(message: string) => void} [args.log]
|
|
169
174
|
* @returns {Promise<{sent: boolean, accepted: number, reason?: string}>}
|
|
170
175
|
*/
|
|
171
|
-
export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, context, report, driver, fetchImpl, log }) {
|
|
176
|
+
export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, pageSdk = false, context, report, driver, fetchImpl, log }) {
|
|
172
177
|
if (!ingestKey) {
|
|
178
|
+
// A web build carries its own SDK, and the web driver injected the run's
|
|
179
|
+
// playtest token into the page, so the game's own events already arrive
|
|
180
|
+
// marked synthetic. Saying "nothing was sent" there would be wrong.
|
|
181
|
+
if (pageSdk && playtestToken) {
|
|
182
|
+
return { sent: false, accepted: 0, pageSdk: true, reason: PAGE_SDK_REASON };
|
|
183
|
+
}
|
|
173
184
|
return { sent: false, accepted: 0, reason: 'no ingest key configured, so no synthetic telemetry was sent' };
|
|
174
185
|
}
|
|
175
186
|
if (!playtestToken) {
|
package/src/states.js
CHANGED
|
@@ -90,8 +90,10 @@ export const RUN_TRANSITIONS = Object.freeze({
|
|
|
90
90
|
queued: ['launching', 'canceled', 'skipped', 'failed', 'interrupted'],
|
|
91
91
|
launching: ['playing', 'failed', 'canceled', 'interrupted'],
|
|
92
92
|
playing: ['reporting', 'failed', 'canceled', 'budget_exceeded', 'interrupted'],
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// A run out of money in its report step uploads its partial report and
|
|
94
|
+
// ends here; the API allows it from 0.2.7's companion server change.
|
|
95
|
+
reporting: ['uploading', 'failed', 'budget_exceeded', 'interrupted'],
|
|
96
|
+
uploading: ['succeeded', 'failed', 'budget_exceeded', 'interrupted'],
|
|
95
97
|
interrupted: ['launching', 'playing', 'reporting', 'uploading', 'failed', 'canceled'],
|
|
96
98
|
succeeded: [],
|
|
97
99
|
failed: [],
|