ravensight-playtest 0.7.0 → 0.7.5
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 +61 -8
- package/package.json +3 -3
- package/src/auth/scopes.js +36 -0
- package/src/cli.js +42 -9
- package/src/commands/brief.js +1 -1
- package/src/commands/build.js +23 -1
- package/src/commands/check.js +5 -1
- package/src/commands/fakeCore.js +5 -1
- package/src/commands/fix.js +2 -0
- package/src/commands/hosted.js +144 -2
- package/src/commands/profile.js +2 -1
- package/src/commands/run.js +86 -5
- package/src/hosted/buildStatus.js +35 -0
- package/src/hosted/buildUpload.js +24 -2
- package/src/hosted/recordedRun.js +135 -3
- package/src/hosted/status.js +108 -0
- package/src/run/aggregate.js +69 -10
- package/src/run/budgetReserve.js +102 -0
- package/src/run/context.js +122 -3
- package/src/run/drivers/web.js +342 -126
- package/src/run/failureReason.js +77 -0
- package/src/run/gameRefusal.js +57 -0
- package/src/run/model.js +18 -1
- package/src/run/openPr.js +8 -1
- package/src/run/personaLoop.js +333 -76
- package/src/run/regenerate.js +4 -1
- package/src/run/report.js +54 -0
- package/src/run/tools.js +31 -3
- package/src/run/usage.js +54 -0
- package/src/ui/index.js +21 -6
package/src/run/drivers/web.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, rename, stat, readdir, rm, chmod } from 'node:fs/promises';
|
|
2
2
|
import { recordingLimits, recordingViewport, recordingDirectory, recordingMetadata, saveRecording, probeVideo } from './recording.js';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { gameRefusal, runnerGone } from '../gameRefusal.js';
|
|
4
5
|
import {
|
|
5
6
|
DriverError,
|
|
6
7
|
STALE_REF_MESSAGE,
|
|
@@ -31,6 +32,18 @@ import {
|
|
|
31
32
|
/** Default recording and viewport size. Kept at 720p to match the upload guidance. */
|
|
32
33
|
export const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 720 });
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The frame a canvas observation sends the model: JPEG at this quality, at the
|
|
37
|
+
* viewport's own size. The model is billed for an image by its pixels, so the
|
|
38
|
+
* format changes nothing there and the coordinates a persona reads off it stay
|
|
39
|
+
* the page's own. What it changes is the proxy's reservation, which prices the
|
|
40
|
+
* request body's characters as text: a 1280x720 PNG of a busy canvas is
|
|
41
|
+
* 300 to 900 KB of base64, a projection of up to half a standard run's model
|
|
42
|
+
* budget for one act turn, where the same frame as JPEG at 70 is 60 to 150 KB.
|
|
43
|
+
* Screenshots saved as evidence stay PNG.
|
|
44
|
+
*/
|
|
45
|
+
export const OBSERVATION_FRAME = Object.freeze({ type: 'jpeg', quality: 70, mediaType: 'image/jpeg' });
|
|
46
|
+
|
|
34
47
|
/** A `wait` action is clamped to this, so one turn cannot burn the wall clock. */
|
|
35
48
|
export const MAX_WAIT_MS = 10000;
|
|
36
49
|
|
|
@@ -853,21 +866,24 @@ async function loadPlaywright(importer) {
|
|
|
853
866
|
}
|
|
854
867
|
|
|
855
868
|
/**
|
|
856
|
-
* Whether the
|
|
857
|
-
*
|
|
858
|
-
* the
|
|
859
|
-
*
|
|
869
|
+
* Whether the page may be at this address: the build's own origin, and
|
|
870
|
+
* nothing else. It decides the `navigate` tool and every top-level
|
|
871
|
+
* navigation the page itself starts (a clicked link, a form, a script, a
|
|
872
|
+
* server redirect), recording or not.
|
|
860
873
|
*
|
|
861
874
|
* Game text is untrusted input: a level name or a leaderboard entry can
|
|
862
|
-
* carry "go to https://.../ and paste your token". Confining
|
|
875
|
+
* carry "go to https://.../ and paste your token". Confining the page to
|
|
863
876
|
* the target's own origin means the worst such an instruction can achieve
|
|
864
877
|
* is a page of the developer's own game.
|
|
865
878
|
*
|
|
866
|
-
* The
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
879
|
+
* The one allowance is a loopback alias: when the build is on loopback,
|
|
880
|
+
* `localhost`, `127.0.0.1` and `[::1]` on the SAME scheme and port are the
|
|
881
|
+
* same server, so a page that links to itself by another loopback name is
|
|
882
|
+
* still the build. Another port is another server (a dev server, an admin
|
|
883
|
+
* panel, a mirror of the live site) and is off the build. Subresources are
|
|
884
|
+
* not decided here: a page may still load assets or call an API on another
|
|
885
|
+
* port. A hosted build never gets the loopback alias: `localhost` there is
|
|
886
|
+
* whatever else the developer happens to be running.
|
|
871
887
|
*
|
|
872
888
|
* @param {string} candidate
|
|
873
889
|
* @param {string} target
|
|
@@ -884,7 +900,35 @@ export function isNavigationAllowed(candidate, target) {
|
|
|
884
900
|
}
|
|
885
901
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
886
902
|
if (url.origin === base.origin) return true;
|
|
887
|
-
return isLoopback(base.hostname) && isLoopback(url.hostname)
|
|
903
|
+
return isLoopback(base.hostname) && isLoopback(url.hostname)
|
|
904
|
+
&& url.protocol === base.protocol && effectivePort(url) === effectivePort(base);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* @param {URL} url
|
|
909
|
+
* @returns {string}
|
|
910
|
+
*/
|
|
911
|
+
function effectivePort(url) {
|
|
912
|
+
return url.port || (url.protocol === 'https:' ? '443' : '80');
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** The most off-site attempts one run keeps for its notes. */
|
|
916
|
+
export const MAX_OFF_SITE_ATTEMPTS = 50;
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* The host an off-site navigation asked for, for the persona's refusal text.
|
|
920
|
+
* Never the full address: a query string can carry anything.
|
|
921
|
+
*
|
|
922
|
+
* @param {string} candidate
|
|
923
|
+
* @returns {string}
|
|
924
|
+
*/
|
|
925
|
+
export function offSiteHost(candidate) {
|
|
926
|
+
try {
|
|
927
|
+
const url = new URL(candidate);
|
|
928
|
+
return url.host || url.protocol.replace(/:$/, '');
|
|
929
|
+
} catch {
|
|
930
|
+
return 'an address that is not a web page';
|
|
931
|
+
}
|
|
888
932
|
}
|
|
889
933
|
|
|
890
934
|
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]', '::1'];
|
|
@@ -1158,6 +1202,68 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1158
1202
|
const consoleBuffer = [];
|
|
1159
1203
|
/** Recent failed requests, newest last, bounded: what a console line is matched against. */
|
|
1160
1204
|
const networkFailures = [];
|
|
1205
|
+
// Containment. The build's addresses (the target, and where its first
|
|
1206
|
+
// load landed), every attempt to leave them since the last drain, and
|
|
1207
|
+
// the last address on the build, for a recovery.
|
|
1208
|
+
let buildTargets = [];
|
|
1209
|
+
let guardArmed = false;
|
|
1210
|
+
const offSite = [];
|
|
1211
|
+
let offSiteCount = 0;
|
|
1212
|
+
let lastBuildUrl = null;
|
|
1213
|
+
let recovery = null;
|
|
1214
|
+
|
|
1215
|
+
/** @param {string} candidate */
|
|
1216
|
+
function onBuild(candidate) {
|
|
1217
|
+
return buildTargets.some(base => isNavigationAllowed(candidate, base));
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* @param {string} url
|
|
1222
|
+
* @param {string} via
|
|
1223
|
+
*/
|
|
1224
|
+
function recordOffSite(url, via) {
|
|
1225
|
+
offSiteCount += 1;
|
|
1226
|
+
offSite.push({ host: offSiteHost(url), url: networkUrl(url), via, claimed: false });
|
|
1227
|
+
while (offSite.length > MAX_OFF_SITE_ATTEMPTS) offSite.shift();
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* A top-level document landed off the build anyway: a server redirect,
|
|
1232
|
+
* which a route never sees after its first hop. Go back, and if that does
|
|
1233
|
+
* not land on the build, load the last address that did.
|
|
1234
|
+
* @param {any} current
|
|
1235
|
+
*/
|
|
1236
|
+
async function recoverToBuild(current) {
|
|
1237
|
+
try {
|
|
1238
|
+
await current.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
1239
|
+
} catch {
|
|
1240
|
+
// Nothing to go back to, or the page would not: load the build instead.
|
|
1241
|
+
}
|
|
1242
|
+
try {
|
|
1243
|
+
if (!onBuild(current.url()) && lastBuildUrl) await current.goto(lastBuildUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1244
|
+
} catch {
|
|
1245
|
+
// The next observation shows where the page is; act refuses from there.
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** Any recovery in flight, settled. */
|
|
1250
|
+
async function settled() {
|
|
1251
|
+
while (recovery) {
|
|
1252
|
+
const pending = recovery;
|
|
1253
|
+
await pending;
|
|
1254
|
+
if (recovery === pending) recovery = null;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** @param {any} request */
|
|
1259
|
+
function isTopLevel(request) {
|
|
1260
|
+
try {
|
|
1261
|
+
return !request.frame().parentFrame();
|
|
1262
|
+
} catch {
|
|
1263
|
+
// A new window's first request is issued before its frame exists.
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1161
1267
|
|
|
1162
1268
|
/** @returns {any} */
|
|
1163
1269
|
function requirePage() {
|
|
@@ -1349,7 +1455,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1349
1455
|
const snap = await snapshotText();
|
|
1350
1456
|
const after = { text: hashText(snap, shownText(snap)) };
|
|
1351
1457
|
if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
|
|
1352
|
-
const frame = await current.screenshot({ type:
|
|
1458
|
+
const frame = await current.screenshot({ type: OBSERVATION_FRAME.type, quality: OBSERVATION_FRAME.quality, timeout: ACTION_TIMEOUT_MS });
|
|
1353
1459
|
after.screenshotBase64 = frame.toString('base64');
|
|
1354
1460
|
}
|
|
1355
1461
|
return observationHash(after);
|
|
@@ -1426,6 +1532,136 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1426
1532
|
finally { inspecting = false; }
|
|
1427
1533
|
}
|
|
1428
1534
|
|
|
1535
|
+
/**
|
|
1536
|
+
* @param {any} current
|
|
1537
|
+
* @param {import('./driver.js').Action} action
|
|
1538
|
+
* @returns {Promise<import('./driver.js').ActResult>}
|
|
1539
|
+
*/
|
|
1540
|
+
async function actOnPage(current, action) {
|
|
1541
|
+
const before = lastHash;
|
|
1542
|
+
const urlBefore = pageUrl(current);
|
|
1543
|
+
const tokenBefore = await documentToken(current);
|
|
1544
|
+
let typed = null;
|
|
1545
|
+
try {
|
|
1546
|
+
switch (action.kind) {
|
|
1547
|
+
case 'key':
|
|
1548
|
+
if (action.holdMs > 0 || (Array.isArray(action.keys) && action.keys.length > 0)) {
|
|
1549
|
+
// Held keys go down together and come up in reverse, even when
|
|
1550
|
+
// one is refused, so nothing stays held for the rest of the run.
|
|
1551
|
+
const keys = [action.key, ...(action.keys || [])].map(key => normalizeChord(key, editModifier));
|
|
1552
|
+
const down = [];
|
|
1553
|
+
try {
|
|
1554
|
+
for (const key of keys) {
|
|
1555
|
+
await current.keyboard.down(key);
|
|
1556
|
+
down.push(key);
|
|
1557
|
+
}
|
|
1558
|
+
await current.waitForTimeout(Math.min(Math.max(Number(action.holdMs) || 0, 0), MAX_WAIT_MS));
|
|
1559
|
+
} finally {
|
|
1560
|
+
for (const key of down.reverse()) await current.keyboard.up(key).catch(() => {});
|
|
1561
|
+
}
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
|
|
1565
|
+
break;
|
|
1566
|
+
case 'type':
|
|
1567
|
+
typed = await typeText(current, action);
|
|
1568
|
+
if (action.submit) await current.keyboard.press('Enter');
|
|
1569
|
+
break;
|
|
1570
|
+
case 'click': {
|
|
1571
|
+
if (!action.ref && action.target && typeof action.target === 'object') {
|
|
1572
|
+
await current.mouse.click(Number(action.target.x), Number(action.target.y));
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
const resolved = await targetFor(current, action);
|
|
1576
|
+
if (resolved && resolved.point) await current.mouse.click(resolved.point.x, resolved.point.y);
|
|
1577
|
+
else await resolved.click({ timeout: ACTION_TIMEOUT_MS });
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
case 'navigate': {
|
|
1581
|
+
const url = String(action.url);
|
|
1582
|
+
if (!onBuild(url)) {
|
|
1583
|
+
// A refusal by the runner, not a failure of the game or the
|
|
1584
|
+
// driver: ./tools.js answers it with plain text and the persona
|
|
1585
|
+
// plays on (an off-site address in a brief is common).
|
|
1586
|
+
recordOffSite(url, 'navigate');
|
|
1587
|
+
offSite[offSite.length - 1].claimed = true;
|
|
1588
|
+
return { ok: false, changed: false, refused: 'off_site', host: offSiteHost(url), error: `navigation to ${url} is outside this build's origin (${new URL(target).origin}) and was refused` };
|
|
1589
|
+
}
|
|
1590
|
+
await current.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1591
|
+
break;
|
|
1592
|
+
}
|
|
1593
|
+
case 'wait':
|
|
1594
|
+
await current.waitForTimeout(Math.min(Math.max(Number(action.ms) || 0, 0), MAX_WAIT_MS));
|
|
1595
|
+
break;
|
|
1596
|
+
default:
|
|
1597
|
+
return { ok: false, changed: false, error: `the playwright_web driver cannot do "${action.kind}"` };
|
|
1598
|
+
}
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
// Playwright colours its call log; the colour codes are noise here.
|
|
1601
|
+
const text = String(error && error.message ? error.message : error).replace(/\u001b\[[0-9;]*m/g, '');
|
|
1602
|
+
// Two elements under one name: Playwright's strict mode. Answered
|
|
1603
|
+
// with the candidates and their refs, so the next try can work.
|
|
1604
|
+
if (/strict mode violation/i.test(text)) {
|
|
1605
|
+
const count = Number((text.match(/resolved to (\d+) elements/) || [])[1]) || null;
|
|
1606
|
+
const candidates = action.target && typeof action.target === 'string' ? candidatesFor(lastNodes, action.target) : [];
|
|
1607
|
+
return { ok: false, changed: false, error: ambiguityMessage(action.target || action.ref || '', candidates, count), driverError: true };
|
|
1608
|
+
}
|
|
1609
|
+
if (error && error.driverSide) return { ok: false, changed: false, error: text, driverError: true };
|
|
1610
|
+
// A click the page kept intercepting is the game's: something sat on
|
|
1611
|
+
// top of the target and never went away. Named, never a driver error.
|
|
1612
|
+
const interceptor = text.match(/^\s*-?\s*(.+?)\s+(?:from .+? subtree\s+)?intercepts pointer events/m);
|
|
1613
|
+
const intercepted = interceptor ? interceptor[1] : null;
|
|
1614
|
+
if (/Timeout \d+ms exceeded/i.test(text) && intercepted) {
|
|
1615
|
+
return { ok: false, changed: false, error: `timed out: ${intercepted.slice(0, 160)} intercepts pointer events over the target`, driverError: false };
|
|
1616
|
+
}
|
|
1617
|
+
// A target the page shows as disabled, hidden or not editable is the
|
|
1618
|
+
// game refusing the action, which is what a player sees: evidence,
|
|
1619
|
+
// never a driver error. The resolved element is named so the report
|
|
1620
|
+
// step can cite it.
|
|
1621
|
+
const refusal = /Timeout \d+ms exceeded/i.test(text) ? gameRefusal(text) : null;
|
|
1622
|
+
if (refusal) {
|
|
1623
|
+
return { ok: false, changed: false, error: `the game did not accept this action: the target is ${refusal.state} (${refusal.element})`, driverError: false };
|
|
1624
|
+
}
|
|
1625
|
+
// The browser or page closed under the runner: the runner's failure.
|
|
1626
|
+
if (runnerGone(text)) return { ok: false, changed: false, error: text, driverError: true };
|
|
1627
|
+
// A timeout with the page unchanged while resolving a click or typing
|
|
1628
|
+
// target means the target was never found or never actionable: the
|
|
1629
|
+
// driver's failure, not the game's. Never for navigate: a game that
|
|
1630
|
+
// never loads is a real blocker.
|
|
1631
|
+
if (/Timeout \d+ms exceeded/i.test(text) && (action.kind === 'click' || action.kind === 'type')) {
|
|
1632
|
+
let unchanged = false;
|
|
1633
|
+
try {
|
|
1634
|
+
unchanged = (await currentHash(current)) === before;
|
|
1635
|
+
} catch {
|
|
1636
|
+
unchanged = false;
|
|
1637
|
+
}
|
|
1638
|
+
return { ok: false, changed: false, error: text, driverError: unchanged };
|
|
1639
|
+
}
|
|
1640
|
+
return { ok: false, changed: false, error: text };
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
const measure = async () => {
|
|
1644
|
+
const facts = { urlBefore, urlAfter: pageUrl(current), tokenBefore, tokenAfter: await documentToken(current), hashBefore: before, valueBefore: typed ? typed.before : null, valueAfter: typed ? typed.after : null };
|
|
1645
|
+
// The same basis observe() hashes, so the two are comparable.
|
|
1646
|
+
// Without it a long screen is always "changed".
|
|
1647
|
+
facts.hashAfter = await currentHash(current);
|
|
1648
|
+
return computeChanged(facts);
|
|
1649
|
+
};
|
|
1650
|
+
let changed = false;
|
|
1651
|
+
try {
|
|
1652
|
+
changed = await measure();
|
|
1653
|
+
// A click or a submit can start a navigation that has not landed yet:
|
|
1654
|
+
// look once more after a short pause before calling it a no-op.
|
|
1655
|
+
if (!changed && SETTLE_MS > 0 && (action.kind === 'click' || action.kind === 'key' || (action.kind === 'type' && action.submit))) {
|
|
1656
|
+
await new Promise(resolve => setTimeout(resolve, SETTLE_MS));
|
|
1657
|
+
changed = await measure();
|
|
1658
|
+
}
|
|
1659
|
+
} catch {
|
|
1660
|
+
changed = true;
|
|
1661
|
+
}
|
|
1662
|
+
return { ok: true, changed };
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1429
1665
|
return {
|
|
1430
1666
|
name: 'playwright_web',
|
|
1431
1667
|
supportsRefs: true,
|
|
@@ -1477,22 +1713,62 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1477
1713
|
// One token per document: set again by every new document, kept by
|
|
1478
1714
|
// pushState and hash route changes, which do not run init scripts.
|
|
1479
1715
|
if (typeof context.addInitScript === 'function') await context.addInitScript(markDocument);
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1716
|
+
// The page never leaves the build, recording or not. A top-level
|
|
1717
|
+
// navigation off it (a clicked link, a form, a script) is answered with
|
|
1718
|
+
// an empty 204, which a browser treats as "stay where you are": the
|
|
1719
|
+
// game keeps its state and nothing is fetched from the other site. An
|
|
1720
|
+
// abort would put an error page in its place instead. Only addresses
|
|
1721
|
+
// off the build are routed at all; subresources pass as before, and
|
|
1722
|
+
// an embedded frame may still load what it loads.
|
|
1723
|
+
buildTargets = [url];
|
|
1724
|
+
if (typeof context.route === 'function') {
|
|
1725
|
+
await context.route(candidate => !onBuild(String(candidate && candidate.href ? candidate.href : candidate)), route => {
|
|
1483
1726
|
const request = route.request();
|
|
1484
|
-
if (
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1727
|
+
if (!guardArmed || !request.isNavigationRequest()) return route.continue();
|
|
1728
|
+
const topLevel = isTopLevel(request);
|
|
1729
|
+
if (topLevel === false) return route.continue();
|
|
1730
|
+
// A new window's first request (no frame yet) is answered the same
|
|
1731
|
+
// way: the window never gets a document, so Playwright never even
|
|
1732
|
+
// surfaces it as a page.
|
|
1733
|
+
recordOffSite(request.url(), topLevel === null ? 'new window' : 'navigation');
|
|
1734
|
+
return route.fulfill({ status: 204, body: '' });
|
|
1488
1735
|
});
|
|
1489
1736
|
}
|
|
1490
1737
|
captureStartedAt = videoRequested ? now() : null;
|
|
1491
1738
|
page = await context.newPage();
|
|
1739
|
+
// A new window that did get a document (one on the build, or a blank
|
|
1740
|
+
// one a script means to point somewhere) is never played either: it is
|
|
1741
|
+
// closed at once and noted. (Context video may also briefly allocate a
|
|
1742
|
+
// file for it; finalization discards every non-selected file.)
|
|
1743
|
+
page.on('popup', popup => {
|
|
1744
|
+
let opened = null;
|
|
1745
|
+
try {
|
|
1746
|
+
opened = popup.url();
|
|
1747
|
+
} catch {
|
|
1748
|
+
opened = null;
|
|
1749
|
+
}
|
|
1750
|
+
recordOffSite(opened && opened !== 'about:blank' ? opened : 'about:blank', 'new window');
|
|
1751
|
+
if (!opened || opened === 'about:blank') offSite[offSite.length - 1].host = 'a new window';
|
|
1752
|
+
void popup.close().catch(() => {});
|
|
1753
|
+
});
|
|
1754
|
+
// The backstop: a top-level document that landed off the build anyway
|
|
1755
|
+
// (a server redirect after its first hop) is undone before anything
|
|
1756
|
+
// observes it. Error pages are the build's own failures, not a visit.
|
|
1757
|
+
page.on('framenavigated', frame => {
|
|
1758
|
+
try {
|
|
1759
|
+
if (!guardArmed || typeof page.mainFrame !== 'function' || frame !== page.mainFrame()) return;
|
|
1760
|
+
const landed = frame.url();
|
|
1761
|
+
if (onBuild(landed)) { lastBuildUrl = landed; return; }
|
|
1762
|
+
if (!/^https?:/i.test(landed)) return;
|
|
1763
|
+
recordOffSite(landed, 'redirect');
|
|
1764
|
+
const current = page;
|
|
1765
|
+
const previous = recovery;
|
|
1766
|
+
recovery = Promise.resolve(previous).then(() => recoverToBuild(current));
|
|
1767
|
+
} catch {
|
|
1768
|
+
// A frame that cannot be read is not a navigation worth undoing.
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1492
1771
|
if (videoRequested) {
|
|
1493
|
-
// Context video may briefly allocate a popup file; close it immediately
|
|
1494
|
-
// and discard all non-selected files during finalization.
|
|
1495
|
-
page.on('popup', popup => { void popup.close().catch(() => {}); });
|
|
1496
1772
|
page.on('crash', () => { void stop({ reason: 'game_crashed' }).catch(() => {}); });
|
|
1497
1773
|
page.on('close', () => { if (!stopPromise) void stop({ reason: 'page_closed' }).catch(() => {}); });
|
|
1498
1774
|
captureTimer = setInterval(() => { void inspectCapture(); }, 250);
|
|
@@ -1543,6 +1819,17 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1543
1819
|
if (response && response.status() >= 400) {
|
|
1544
1820
|
throw new DriverError(`${url} answered ${response.status()}`, { fatal: true });
|
|
1545
1821
|
}
|
|
1822
|
+
// Where the first load landed is the build too (a hosted address that
|
|
1823
|
+
// redirects to its canonical one, http to https). From here on, leaving
|
|
1824
|
+
// is refused.
|
|
1825
|
+
try {
|
|
1826
|
+
const landed = page.url();
|
|
1827
|
+
if (/^https?:/i.test(landed) && !onBuild(landed)) buildTargets.push(landed);
|
|
1828
|
+
lastBuildUrl = /^https?:/i.test(landed) ? landed : url;
|
|
1829
|
+
} catch {
|
|
1830
|
+
lastBuildUrl = url;
|
|
1831
|
+
}
|
|
1832
|
+
guardArmed = true;
|
|
1546
1833
|
} catch (error) {
|
|
1547
1834
|
await stop({ reason: 'launch_failed' }).catch(() => {});
|
|
1548
1835
|
if (captureDirectory) await rm(captureDirectory, { recursive: true, force: true }).catch(() => {});
|
|
@@ -1553,6 +1840,7 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1553
1840
|
/** @returns {Promise<import('./driver.js').Observation>} */
|
|
1554
1841
|
async observe() {
|
|
1555
1842
|
const current = requirePage();
|
|
1843
|
+
await settled();
|
|
1556
1844
|
const started = Date.now();
|
|
1557
1845
|
step += 1;
|
|
1558
1846
|
const snap = await snapshotText();
|
|
@@ -1579,9 +1867,9 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1579
1867
|
// is the only way to play them. Spend an image only then.
|
|
1580
1868
|
if (String(text).trim().length < THIN_OBSERVATION_CHARS) {
|
|
1581
1869
|
try {
|
|
1582
|
-
const buffer = await current.screenshot({ type:
|
|
1870
|
+
const buffer = await current.screenshot({ type: OBSERVATION_FRAME.type, quality: OBSERVATION_FRAME.quality, timeout: ACTION_TIMEOUT_MS });
|
|
1583
1871
|
observation.screenshotBase64 = buffer.toString('base64');
|
|
1584
|
-
observation.screenshotMediaType =
|
|
1872
|
+
observation.screenshotMediaType = OBSERVATION_FRAME.mediaType;
|
|
1585
1873
|
} catch {
|
|
1586
1874
|
// No image and thin text is still an observation; the persona will
|
|
1587
1875
|
// read it as a blank screen, which is what it is.
|
|
@@ -1608,115 +1896,43 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1608
1896
|
*/
|
|
1609
1897
|
async act(action) {
|
|
1610
1898
|
const current = requirePage();
|
|
1611
|
-
|
|
1612
|
-
const
|
|
1613
|
-
const
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
for (const key of keys) {
|
|
1625
|
-
await current.keyboard.down(key);
|
|
1626
|
-
down.push(key);
|
|
1627
|
-
}
|
|
1628
|
-
await current.waitForTimeout(Math.min(Math.max(Number(action.holdMs) || 0, 0), MAX_WAIT_MS));
|
|
1629
|
-
} finally {
|
|
1630
|
-
for (const key of down.reverse()) await current.keyboard.up(key).catch(() => {});
|
|
1631
|
-
}
|
|
1632
|
-
break;
|
|
1633
|
-
}
|
|
1634
|
-
await current.keyboard.press(normalizeChord(action.key, editModifier), { timeout: ACTION_TIMEOUT_MS });
|
|
1635
|
-
break;
|
|
1636
|
-
case 'type':
|
|
1637
|
-
typed = await typeText(current, action);
|
|
1638
|
-
if (action.submit) await current.keyboard.press('Enter');
|
|
1639
|
-
break;
|
|
1640
|
-
case 'click': {
|
|
1641
|
-
if (!action.ref && action.target && typeof action.target === 'object') {
|
|
1642
|
-
await current.mouse.click(Number(action.target.x), Number(action.target.y));
|
|
1643
|
-
break;
|
|
1644
|
-
}
|
|
1645
|
-
const resolved = await targetFor(current, action);
|
|
1646
|
-
if (resolved && resolved.point) await current.mouse.click(resolved.point.x, resolved.point.y);
|
|
1647
|
-
else await resolved.click({ timeout: ACTION_TIMEOUT_MS });
|
|
1648
|
-
break;
|
|
1649
|
-
}
|
|
1650
|
-
case 'navigate': {
|
|
1651
|
-
const url = String(action.url);
|
|
1652
|
-
if (!isNavigationAllowed(url, target)) {
|
|
1653
|
-
return { ok: false, changed: false, error: `navigation to ${url} is outside this build's origin (${new URL(target).origin}) and was refused` };
|
|
1654
|
-
}
|
|
1655
|
-
await current.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1656
|
-
break;
|
|
1657
|
-
}
|
|
1658
|
-
case 'wait':
|
|
1659
|
-
await current.waitForTimeout(Math.min(Math.max(Number(action.ms) || 0, 0), MAX_WAIT_MS));
|
|
1660
|
-
break;
|
|
1661
|
-
default:
|
|
1662
|
-
return { ok: false, changed: false, error: `the playwright_web driver cannot do "${action.kind}"` };
|
|
1663
|
-
}
|
|
1664
|
-
} catch (error) {
|
|
1665
|
-
// Playwright colours its call log; the colour codes are noise here.
|
|
1666
|
-
const text = String(error && error.message ? error.message : error).replace(/\u001b\[[0-9;]*m/g, '');
|
|
1667
|
-
// Two elements under one name: Playwright's strict mode. Answered
|
|
1668
|
-
// with the candidates and their refs, so the next try can work.
|
|
1669
|
-
if (/strict mode violation/i.test(text)) {
|
|
1670
|
-
const count = Number((text.match(/resolved to (\d+) elements/) || [])[1]) || null;
|
|
1671
|
-
const candidates = action.target && typeof action.target === 'string' ? candidatesFor(lastNodes, action.target) : [];
|
|
1672
|
-
return { ok: false, changed: false, error: ambiguityMessage(action.target || action.ref || '', candidates, count), driverError: true };
|
|
1673
|
-
}
|
|
1674
|
-
if (error && error.driverSide) return { ok: false, changed: false, error: text, driverError: true };
|
|
1675
|
-
// A click the page kept intercepting is the game's: something sat on
|
|
1676
|
-
// top of the target and never went away. Named, never a driver error.
|
|
1677
|
-
const interceptor = text.match(/^\s*-?\s*(.+?)\s+(?:from .+? subtree\s+)?intercepts pointer events/m);
|
|
1678
|
-
const intercepted = interceptor ? interceptor[1] : null;
|
|
1679
|
-
if (/Timeout \d+ms exceeded/i.test(text) && intercepted) {
|
|
1680
|
-
return { ok: false, changed: false, error: `timed out: ${intercepted.slice(0, 160)} intercepts pointer events over the target`, driverError: false };
|
|
1681
|
-
}
|
|
1682
|
-
// A timeout with the page unchanged while resolving a click or typing
|
|
1683
|
-
// target means the target was never found or never actionable: the
|
|
1684
|
-
// driver's failure, not the game's. Never for navigate: a game that
|
|
1685
|
-
// never loads is a real blocker.
|
|
1686
|
-
if (/Timeout \d+ms exceeded/i.test(text) && (action.kind === 'click' || action.kind === 'type')) {
|
|
1687
|
-
let unchanged = false;
|
|
1688
|
-
try {
|
|
1689
|
-
unchanged = (await currentHash(current)) === before;
|
|
1690
|
-
} catch {
|
|
1691
|
-
unchanged = false;
|
|
1692
|
-
}
|
|
1693
|
-
return { ok: false, changed: false, error: text, driverError: unchanged };
|
|
1694
|
-
}
|
|
1695
|
-
return { ok: false, changed: false, error: text };
|
|
1899
|
+
await settled();
|
|
1900
|
+
const attemptsBefore = offSiteCount;
|
|
1901
|
+
const outcome = await actOnPage(current, action);
|
|
1902
|
+
if (outcome.refused) return outcome;
|
|
1903
|
+
// A click, a key, a submit or a script that tried to leave the build:
|
|
1904
|
+
// the page stayed (or was brought back), and the persona is told the
|
|
1905
|
+
// same as for a refused `navigate`. A step that changed nothing.
|
|
1906
|
+
await settled();
|
|
1907
|
+
if (offSiteCount > attemptsBefore) {
|
|
1908
|
+
const fresh = offSite.filter(attempt => !attempt.claimed).slice(-(offSiteCount - attemptsBefore));
|
|
1909
|
+
for (const attempt of fresh) attempt.claimed = true;
|
|
1910
|
+
const host = fresh.length > 0 ? fresh[fresh.length - 1].host : 'another site';
|
|
1911
|
+
return { ok: false, changed: false, refused: 'off_site', host, error: `the page tried to leave the build for ${host}; the runner kept it on the build` };
|
|
1696
1912
|
}
|
|
1913
|
+
return outcome;
|
|
1914
|
+
},
|
|
1697
1915
|
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1916
|
+
/**
|
|
1917
|
+
* Every attempt to leave the build since the last call, claimed by an
|
|
1918
|
+
* action or not (a timer redirect between actions has no action), for the
|
|
1919
|
+
* run's notes. Hosts and addresses without their query strings only.
|
|
1920
|
+
* @returns {Array<{host: string, url: string, via: string}>}
|
|
1921
|
+
*/
|
|
1922
|
+
offSiteAttempts() {
|
|
1923
|
+
return offSite.splice(0, offSite.length).map(({ host, url: address, via }) => ({ host, url: address, via }));
|
|
1924
|
+
},
|
|
1925
|
+
|
|
1926
|
+
/** How many pages the browser has open: one, whatever the game opened. */
|
|
1927
|
+
openPages() {
|
|
1706
1928
|
try {
|
|
1707
|
-
|
|
1708
|
-
// A click or a submit can start a navigation that has not landed yet:
|
|
1709
|
-
// look once more after a short pause before calling it a no-op.
|
|
1710
|
-
if (!changed && SETTLE_MS > 0 && (action.kind === 'click' || action.kind === 'key' || (action.kind === 'type' && action.submit))) {
|
|
1711
|
-
await new Promise(resolve => setTimeout(resolve, SETTLE_MS));
|
|
1712
|
-
changed = await measure();
|
|
1713
|
-
}
|
|
1929
|
+
return context && typeof context.pages === 'function' ? context.pages().length : (page ? 1 : 0);
|
|
1714
1930
|
} catch {
|
|
1715
|
-
|
|
1931
|
+
return page ? 1 : 0;
|
|
1716
1932
|
}
|
|
1717
|
-
return { ok: true, changed };
|
|
1718
1933
|
},
|
|
1719
1934
|
|
|
1935
|
+
|
|
1720
1936
|
/**
|
|
1721
1937
|
* @param {string} name
|
|
1722
1938
|
* @returns {Promise<{path: string, relativePath: string}>}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { redactSecrets } from './secretScan.js';
|
|
3
|
+
|
|
4
|
+
/** The server stores a run's reason as `stateReason` and the dashboard shows it. */
|
|
5
|
+
export const MAX_REASON_CHARS = 400;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Token shapes the upload scan does not cover, because they are not refused
|
|
9
|
+
* there: any `gt_` or `sk-` token and a bearer header value. A reason is
|
|
10
|
+
* diagnostics, so these are replaced rather than refused.
|
|
11
|
+
*/
|
|
12
|
+
const EXTRA_PATTERNS = [
|
|
13
|
+
{ type: 'bearer_token', regex: /\bbearer\s+[A-Za-z0-9._~+/=-]{4,2048}/gi },
|
|
14
|
+
{ type: 'ravensight_token', regex: /\bgt_[A-Za-z0-9_-]{4,256}/g },
|
|
15
|
+
{ type: 'api_key', regex: /\bsk-[A-Za-z0-9_-]{4,256}/g }
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** A home directory in a path: macOS, Linux and Windows. */
|
|
19
|
+
const HOME_PATHS = [
|
|
20
|
+
/\/Users\/[^/\s'"`]+/g,
|
|
21
|
+
/\/home\/[^/\s'"`]+/g,
|
|
22
|
+
/\b[A-Za-z]:[\\/]+Users[\\/]+[^\\/\s'"`]+/gi
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function escapeRegExp(text) {
|
|
26
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The text with token-like strings and absolute home paths taken out, safe to
|
|
31
|
+
* send to the server as a run's reason.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text
|
|
34
|
+
* @param {{home?: string}} [options]
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function redactReason(text, { home = homedir() } = {}) {
|
|
38
|
+
let out = redactSecrets(text);
|
|
39
|
+
for (const { type, regex } of EXTRA_PATTERNS) out = out.replace(regex, `[redacted ${type}]`);
|
|
40
|
+
if (home && home.length > 1) out = out.replace(new RegExp(escapeRegExp(home), 'g'), '~');
|
|
41
|
+
for (const pattern of HOME_PATHS) out = out.replace(pattern, '~');
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The reason a failed run is reported with: the detail it already has, or else
|
|
47
|
+
* the caught error's message, redacted and cut to 400 characters. Never empty.
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} error
|
|
50
|
+
* @param {{detail?: string, fallback?: string, home?: string}} [options]
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function failureReason(error, { detail = '', fallback = 'the run stopped on an error', home } = {}) {
|
|
54
|
+
const message = error && typeof error === 'object' && 'message' in error ? error.message : error;
|
|
55
|
+
const raw = String(detail || '').trim() || (message == null ? '' : String(message).trim());
|
|
56
|
+
const text = redactReason(raw, home === undefined ? {} : { home }).trim().slice(0, MAX_REASON_CHARS);
|
|
57
|
+
return text || fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The reason a run whose game or browser could not start is reported with:
|
|
62
|
+
* what failed, the redacted message, and what to do about it. The message is
|
|
63
|
+
* cut first, so the instruction always survives the 400 character limit.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} driverName
|
|
66
|
+
* @param {unknown} error
|
|
67
|
+
* @param {{detail?: string, browserFix?: string, home?: string}} [options]
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function driverStartReason(driverName, error, { detail = '', browserFix = 'npx playwright install chromium', home } = {}) {
|
|
71
|
+
const browser = driverName === 'playwright_web';
|
|
72
|
+
const what = browser ? 'the browser could not start' : driverName === 'godot_driver' ? 'Godot could not start' : `the ${driverName} driver could not start`;
|
|
73
|
+
const todo = browser ? `run ${browserFix}` : 'run "ravensight-playtest check" to see what is missing';
|
|
74
|
+
const room = MAX_REASON_CHARS - what.length - todo.length - 4;
|
|
75
|
+
const message = failureReason(error, { detail, fallback: 'no error message', ...(home === undefined ? {} : { home }) }).slice(0, Math.max(0, room)).trim();
|
|
76
|
+
return `${what}: ${message}; ${todo}`;
|
|
77
|
+
}
|