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.
@@ -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 model is allowed to navigate here. The persona may only move
857
- * around inside the build it was pointed at, plus the rest of loopback when
858
- * the build ITSELF is on loopback (a local web export is commonly served on
859
- * one port with an asset or API server on another).
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 `navigate` to
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 loopback allowance is conditional for a reason. A hosted build has no
867
- * business steering the browser at the developer's own machine: `localhost`
868
- * there is whatever else they happen to be running, dev servers and admin
869
- * panels included, and reaching it is a genuine escalation rather than a
870
- * convenience.
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: 'png', timeout: ACTION_TIMEOUT_MS });
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
- if (videoRequested && typeof context.route === 'function') {
1481
- const origin = new URL(url).origin;
1482
- await context.route('**/*', route => {
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 (request.isNavigationRequest() && !request.frame().parentFrame() && new URL(request.url()).origin !== origin) {
1485
- return route.abort('blockedbyclient');
1486
- }
1487
- return route.continue();
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: 'png', timeout: ACTION_TIMEOUT_MS });
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 = 'image/png';
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
- const before = lastHash;
1612
- const urlBefore = pageUrl(current);
1613
- const tokenBefore = await documentToken(current);
1614
- let typed = null;
1615
- try {
1616
- switch (action.kind) {
1617
- case 'key':
1618
- if (action.holdMs > 0 || (Array.isArray(action.keys) && action.keys.length > 0)) {
1619
- // Held keys go down together and come up in reverse, even when
1620
- // one is refused, so nothing stays held for the rest of the run.
1621
- const keys = [action.key, ...(action.keys || [])].map(key => normalizeChord(key, editModifier));
1622
- const down = [];
1623
- try {
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
- const measure = async () => {
1699
- const facts = { urlBefore, urlAfter: pageUrl(current), tokenBefore, tokenAfter: await documentToken(current), hashBefore: before, valueBefore: typed ? typed.before : null, valueAfter: typed ? typed.after : null };
1700
- // The same basis observe() hashes, so the two are comparable.
1701
- // Without it a long screen is always "changed".
1702
- facts.hashAfter = await currentHash(current);
1703
- return computeChanged(facts);
1704
- };
1705
- let changed = false;
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
- changed = await measure();
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
- changed = true;
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
+ }