ravensight-playtest 0.2.7 → 0.2.9

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 CHANGED
@@ -206,6 +206,7 @@ splitting the job in two is what gets everything played.
206
206
  | `--upload-video` | Upload `session.webm` as well as the reports. |
207
207
  | `--upload-transcript` | Upload `transcript.jsonl` as well as the reports. |
208
208
  | `--dry-upload` | List every file and byte that would be sent, and send nothing. |
209
+ | `--key-modifier <meta\|control>` | The modifier the browser driver uses for editing shortcuts such as select all. Defaults to this machine's own (Command on macOS, Control elsewhere); set it when the browser's platform is not this machine's. `RAVENSIGHT_KEY_MODIFIER` does the same. |
209
210
  | `--headed`, `--headless` | Show or hide the browser. |
210
211
  | `--verbose` | Print each turn. |
211
212
 
@@ -253,10 +254,56 @@ How the report step reads what happened:
253
254
  - Every note carries `after`, the URL and title of the screen that followed
254
255
  it. A blocker or major finding has to cite the observation where the failure
255
256
  is visible and must not be contradicted by that next screen or by a later
256
- persona note.
257
+ persona note. It also has to cite the screenshot taken at or after that
258
+ observation (the prompt says which observation each screenshot followed),
259
+ and that screenshot must not show what the finding says is missing.
260
+ - Every evidence entry is `{"type": ..., "ref": ...}`. A screenshot path the
261
+ model put under `path` or `file` is moved to `ref` before the report is
262
+ checked; the file still has to exist in the run directory. If the server
263
+ still refuses `report.json` at `complete`, the CLI prints every validation
264
+ message it sent, the run is not counted as succeeded or in the aggregate,
265
+ and the job exits 1.
257
266
  - The model is told never to write calendar dates. `report.md` carries the
258
267
  CLI's own `Generated at` timestamp under its title, and the aggregate report's
259
268
  header uses its `generated_at`.
269
+ - A template slot the model copied instead of filling (`**Date:** <run date>`,
270
+ `<driver>`, `<N actions>`) is filled from the CLI's own facts, or "not
271
+ recorded" when it has none, in `report.md`, the prose of `report.json` and
272
+ the aggregate. Other angle brackets are left as written: keys such as
273
+ `<Enter>`, elements such as `<form>`, types such as `Array<string>`, links,
274
+ code and quoted game text.
275
+ - The server keeps a finding only when one of its evidence references points
276
+ at a file the run uploaded. After delivery the CLI marks the findings it
277
+ dropped (by the server's `dropped_finding_ids`, or by applying the same rule
278
+ to the files it uploaded when its count matches): `dropped: true` in
279
+ `report.json`, out of the summary in `report.md` and into a section headed
280
+ "Dropped by the server (no usable evidence)", and out of the aggregate and
281
+ the synthetic telemetry, so the local report counts what the dashboard
282
+ counts. When it cannot tell which, `report.md` says how many and that the
283
+ dashboard is the record. The rewritten files are journalled as local edits,
284
+ and `upload <job>` never re-sends a completed run's report files.
285
+
286
+ ### What personas do well
287
+
288
+ A persona plays like a first time player at human speed: menus, onboarding,
289
+ the first few minutes, unclear text, screens that do not respond, and whether
290
+ it would come back. On the web, when a persona replaces a field's value (the
291
+ `type` tool with `replace`), the driver clears it with `fill`, the same way on
292
+ macOS, Windows and Linux; ordinary typing is keystrokes. An editing shortcut
293
+ such as select all uses the platform's own modifier (Command on a Mac, Control
294
+ elsewhere, or `--key-modifier`).
295
+
296
+ On a long web page the persona's text view of the screen is capped at 4,000
297
+ characters. Over the cap, buttons, links and fields are kept first, a long run
298
+ of look-alike items (a list of 249 cards) is shown as its first two and "and
299
+ 247 more like it", and what changed since the last look comes next, so a form
300
+ after a long list still reaches the persona.
301
+
302
+ Each persona starts at the game's URL as a fresh player with no account, so a
303
+ sign-up form with validation can use up a whole unit before the game itself is
304
+ reached. Point `--build-url` at a guest or demo path, or say where to start in
305
+ the brief's `how_to_start` note (for example "choose Play as guest"), when the
306
+ sign-up is not what you want tested.
260
307
 
261
308
  ### fix
262
309
 
@@ -407,6 +454,10 @@ lost to it. The CLI lints locally first and feeds any miss back into one repair
407
454
  turn, which costs a model turn; a report written with the four headings from the
408
455
  start avoids that.
409
456
 
457
+ The repository's tests check rendered reports against `test/fixtures/server/`, a
458
+ byte copy of the server's report schema from game_tracker commit 864027e,
459
+ which has to be refreshed by hand whenever the server's schema changes.
460
+
410
461
  ## Exit codes
411
462
 
412
463
  | Code | Meaning |
@@ -429,6 +480,7 @@ CI can treat 2 as a success with a warning.
429
480
  | `RAVENSIGHT_API_URL` | The API base URL. Defaults to `https://api.ravensight.io`. |
430
481
  | `RAVENSIGHT_DASHBOARD_URL` | Where the dashboard is, if it is not derivable from the API URL. |
431
482
  | `RAVENSIGHT_GAME_ID` | The game, when there is no linked repo. |
483
+ | `RAVENSIGHT_KEY_MODIFIER` | `meta` or `control`: the browser driver's modifier for editing shortcuts, as `--key-modifier`. The flag wins when both are set. |
432
484
  | `RAVENSIGHT_INGEST_KEY` | The game's ingest key, so a web run can emit its synthetic session into your event stream. `ingest_key` in `.ravensight/config.json` does the same. Without either the run still reports; it just sends no telemetry. |
433
485
  | `RAVENSIGHT_GODOT` | Path to the Godot binary. Tried first, then `$GODOT`, then `$GODOT_PATH`, then the PATH, then the platform's usual install locations. `--godot-path` beats all of them. |
434
486
  | `RAVENSIGHT_FFMPEG` | Path to ffmpeg. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "AI personas play your game and file reports to Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
package/src/cli.js CHANGED
@@ -63,6 +63,7 @@ Run options (parsed by the runner, see src/run/args.js):
63
63
  --upload-video upload session.webm as well as the reports
64
64
  --upload-transcript upload transcript.jsonl as well as the reports
65
65
  --dry-upload list every file and byte that would be sent, send nothing
66
+ --key-modifier <name> meta or control: the modifier for editing shortcuts in the browser, default the platform's own
66
67
  --headed show the browser
67
68
  --headless hide it
68
69
  --verbose print each turn
@@ -13,6 +13,7 @@ import { jobDir, runDir as runDirFor, JOB_FILES, RUN_FILES } from '../run/paths.
13
13
  import { readBridgeConfig } from "../run/drivers/stdio.js";
14
14
  import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
15
15
  import { runPersona } from '../run/personaLoop.js';
16
+ import { reconcileDroppedFindings } from '../run/dropped.js';
16
17
  import * as stateFile from '../run/state.js';
17
18
  import { emitRunEvents } from '../run/synthetic.js';
18
19
 
@@ -90,6 +91,8 @@ async function run(flags, injected, deps, log) {
90
91
  const { context, gameId, repoRoot, apiUrl, cliVersion, api } = setup;
91
92
 
92
93
  if (options.controller && !['standard', 'jev'].includes(options.controller)) { log('Choose --controller standard or --controller jev.'); return EXIT.ENVIRONMENT; }
94
+ const keyModifier = resolveKeyModifier(options.keyModifier);
95
+ if (keyModifier.error) { log(keyModifier.error); return EXIT.ENVIRONMENT; }
93
96
  const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
94
97
  const target = resolveTarget(options, context.config, driverName);
95
98
  if (!target) {
@@ -442,7 +445,10 @@ export async function playJob({
442
445
  const reportBudgetRefused = results.some(result => result.reportStepBudget === 'refused');
443
446
  const reportBudgetDelivered = results.some(result => result.reportStepBudget === 'delivered' || result.reportStepBudget === 'dry_upload');
444
447
  const canceled = results.some(result => result.state === 'canceled');
445
- const anySucceeded = results.some(result => result.state === 'succeeded');
448
+ // A run whose report the server refused played, but the server never
449
+ // completed it: it is not a success, and CI should hear about it.
450
+ const anySucceeded = results.some(result => result.state === 'succeeded' && !result.reportRefused);
451
+ const reportRefused = results.some(result => result.reportRefused);
446
452
 
447
453
  if (finish) {
448
454
  const finished = await api.jobs.finish(gameId, jobId, {
@@ -456,7 +462,7 @@ export async function playJob({
456
462
  log(`Reports: ${directory}`);
457
463
 
458
464
  if (canceled) return EXIT.CANCELED;
459
- if (reportBudgetRefused) return EXIT.FAILED;
465
+ if (reportBudgetRefused || reportRefused) return EXIT.FAILED;
460
466
  if (playBudgetHit) return EXIT.BUDGET;
461
467
  return anySucceeded || reportBudgetDelivered ? EXIT.OK : EXIT.FAILED;
462
468
  }
@@ -497,7 +503,7 @@ export async function playPersonaRun({
497
503
  // Headless is right for a browser and wrong for Godot: a headless Godot
498
504
  // routes no GUI input, so it is a smoke-test mode rather than a way to play.
499
505
  const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
500
- const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
506
+ const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId, keyModifier: options.keyModifier }));
501
507
  // The ledger is created here rather than inside the loop so the heartbeat
502
508
  // can feed it the server's own figure for what this run has spent. Prices
503
509
  // are server side only, so that answer is the only real one.
@@ -563,7 +569,7 @@ export async function playPersonaRun({
563
569
  const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
564
570
  await stateFile.patchRun(repoRoot, journal, runId, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
565
571
  const delivery = {};
566
- const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, delivery });
572
+ const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, repoRoot, delivery });
567
573
  if (isReportStepBudget(failed)) {
568
574
  // The money ran out while the report was being written. The partial
569
575
  // report went up and the run is completed as budget_exceeded from
@@ -617,7 +623,15 @@ export async function playPersonaRun({
617
623
  budget_warned: outcome.budgetWarned
618
624
  });
619
625
 
620
- await deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot });
626
+ const delivered = {};
627
+ await deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery: delivered });
628
+
629
+ if (delivered.reportInvalid) {
630
+ // No synthetic telemetry for findings the server does not hold, and no
631
+ // "succeeded" line for a run the server never completed.
632
+ log(`run ${runId} (${persona.slug}) played ${outcome.actionsTaken} actions (${outcome.quitReason}), but the server refused its report, so the run is not complete.`);
633
+ return { runId, persona: persona.slug, state: outcome.state, quitReason: outcome.quitReason, reportRefused: true };
634
+ }
621
635
 
622
636
  if (outcome.report) {
623
637
  const emitted = await emitRunEvents({
@@ -710,9 +724,10 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
710
724
  const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
711
725
  answer = await api.runs.complete(gameId, jobId, runId, withoutReport);
712
726
  }
713
- if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
727
+ if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true, report_refused: false });
714
728
  if (answer && answer.dropped_findings > 0) {
715
729
  log(`run ${runId}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
730
+ await markDropped({ directory, jobId, runId, repoRoot, outcome, droppedCount: answer.dropped_findings, droppedIds: answer.dropped_finding_ids, options, log });
716
731
  }
717
732
  if (answer && Array.isArray(answer.report_lint) && answer.report_lint.length > 0) {
718
733
  log(`run ${runId}: report lint: ${answer.report_lint.join('; ')}`);
@@ -724,11 +739,58 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
724
739
  delivery.status = error && error.status;
725
740
  delivery.code = error && error.code;
726
741
  }
742
+ if (error && error.status === 422 && error.code === 'invalid_report') {
743
+ // Every validation message first, one per line, before anything says
744
+ // the run did not complete: the summary line alone ("does not match
745
+ // the playtest report schema") is not something anyone can act on.
746
+ const details = refusalDetails(error);
747
+ log(`run ${runId}: the server refused report.json (${details.length} problem(s)):\n${details.length > 0 ? details.map(line => ` - ${line}`).join('\n') : ' - (the server named no problem)'}`);
748
+ if (delivery) delivery.reportInvalid = true;
749
+ if (journal) await stateFile.patchRun(repoRoot, journal, runId, { report_refused: true });
750
+ log(`run ${runId}: not completed, so the server keeps no findings from it; whether its unit is refunded is decided when the job settles. report.json and the screenshots stay in ${directory}.`);
751
+ return null;
752
+ }
727
753
  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.`);
728
754
  return null;
729
755
  }
730
756
  }
731
757
 
758
+ /**
759
+ * The validation messages a 422 carried, as `pointer: message` lines. Read
760
+ * from `details` (the API client's error) or `body.details` (the envelope).
761
+ *
762
+ * @param {any} error
763
+ * @returns {Array<string>}
764
+ */
765
+ export function refusalDetails(error) {
766
+ const list = Array.isArray(error && error.details) && error.details.length > 0
767
+ ? error.details
768
+ : (error && error.body && Array.isArray(error.body.details) ? error.body.details : []);
769
+ return list.map(detail => `${(detail && detail.instancePath) || '/'}: ${(detail && detail.message) || 'is invalid'}`);
770
+ }
771
+
772
+ /**
773
+ * Bring the local report into line with what the server kept, so report.md,
774
+ * report.json, the aggregate and the telemetry count what the dashboard
775
+ * counts. A failure here never fails the delivery: the run is already
776
+ * complete on the server.
777
+ */
778
+ async function markDropped({ directory, jobId, runId, repoRoot, outcome, droppedCount, droppedIds, options, log }) {
779
+ try {
780
+ const result = await reconcileDroppedFindings({ directory, droppedCount, droppedIds, options, journal: { jobId, runId, repoRoot } });
781
+ if (!result) return;
782
+ if (outcome) {
783
+ outcome.report = result.report;
784
+ outcome.reportMarkdown = result.markdown;
785
+ }
786
+ log(result.identified
787
+ ? `run ${runId}: marked ${result.dropped.join(', ')} as dropped in report.md and report.json, under "Dropped by the server".`
788
+ : `run ${runId}: could not tell which finding(s) the server dropped, so report.md says so and the dashboard is the record.`);
789
+ } catch (error) {
790
+ log(`run ${runId}: could not mark the dropped finding(s) in the local report (${String(error && error.message)}).`);
791
+ }
792
+ }
793
+
732
794
  /**
733
795
  * Whether a run ran out of money in the report step: it is budget_exceeded
734
796
  * and the server had already accepted `reporting` (or `uploading`) for it.
@@ -817,7 +879,7 @@ async function aggregatePhase({
817
879
 
818
880
  // The aggregate reads reports off disk, so a resumed job whose earlier runs
819
881
  // were played by a previous process is covered too.
820
- const reports = await loadRunReports(repoRoot, jobId, personaRuns);
882
+ const reports = await loadRunReports(repoRoot, jobId, personaRuns, journal);
821
883
  let aggregate;
822
884
  try {
823
885
  aggregate = await buildAggregate({
@@ -1040,9 +1102,12 @@ export async function resolveSetup(deps, options, log) {
1040
1102
  *
1041
1103
  * @returns {Object}
1042
1104
  */
1043
- export function driverOptions({ headless, driverName, target, directory, entry, jobId }) {
1105
+ export function driverOptions({ headless, driverName, target, directory, entry, jobId, keyModifier }) {
1106
+ // Only the browser driver sends key chords with a modifier.
1107
+ const modifier = driverName === 'playwright_web' ? resolveKeyModifier(keyModifier).modifier : null;
1044
1108
  return {
1045
1109
  headless,
1110
+ ...(modifier ? { modifier } : {}),
1046
1111
  playtest: {
1047
1112
  token: entry.playtest_token || null,
1048
1113
  runId: entry.run_id,
@@ -1054,14 +1119,40 @@ export function driverOptions({ headless, driverName, target, directory, entry,
1054
1119
  };
1055
1120
  }
1056
1121
 
1122
+ /**
1123
+ * The modifier the web driver uses for editing shortcuts, from
1124
+ * `--key-modifier meta|control`, else `RAVENSIGHT_KEY_MODIFIER`, else the
1125
+ * platform's own (Command on macOS, Control elsewhere, decided in the driver).
1126
+ * For a remote or containerised browser whose platform is not this machine's.
1127
+ *
1128
+ * @param {string} [value] the flag's value
1129
+ * @param {Object} [env]
1130
+ * @returns {{modifier: 'Meta'|'Control'|null, error?: string}}
1131
+ */
1132
+ export function resolveKeyModifier(value, env = process.env) {
1133
+ const raw = value !== undefined && value !== null && value !== '' ? value : env.RAVENSIGHT_KEY_MODIFIER;
1134
+ if (raw === undefined || raw === null || raw === '') return { modifier: null };
1135
+ const key = String(raw).trim().toLowerCase();
1136
+ if (['meta', 'cmd', 'command'].includes(key)) return { modifier: 'Meta' };
1137
+ if (['control', 'ctrl'].includes(key)) return { modifier: 'Control' };
1138
+ return { modifier: null, error: `Choose --key-modifier meta or --key-modifier control (or set RAVENSIGHT_KEY_MODIFIER), not "${raw}".` };
1139
+ }
1140
+
1057
1141
  /**
1058
1142
  * The persona reports on disk, for the aggregate. A partial report (written
1059
1143
  * from the notes when the money ran out) is loaded as a `budget_exceeded`
1060
1144
  * run: its findings count, and the aggregate says the job is partial.
1061
1145
  */
1062
- export async function loadRunReports(repoRoot, jobId, personaRuns) {
1146
+ export async function loadRunReports(repoRoot, jobId, personaRuns, journal = null) {
1063
1147
  const out = [];
1064
1148
  for (const entry of personaRuns) {
1149
+ // A report the server refused is on disk but not in the record: the
1150
+ // summary must not count findings the dashboard does not have.
1151
+ const recorded = journal && journal.runs ? journal.runs[entry.run_id] : null;
1152
+ if (recorded && recorded.report_refused === true) {
1153
+ out.push({ runId: entry.run_id, persona: entry.persona, state: 'failed', report: null, reportRefused: true });
1154
+ continue;
1155
+ }
1065
1156
  const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
1066
1157
  try {
1067
1158
  const report = JSON.parse(await readFile(file, 'utf8'));
@@ -8,6 +8,9 @@ import { pending } from '../upload/queue.js';
8
8
  import { uploadRunDir } from '../upload/index.js';
9
9
  import { JOB_UPLOAD_PATHS } from '../upload/allowlist.js';
10
10
  import { ui } from '../ui/index.js';
11
+
12
+ /** Report files never re-sent for a run the journal marks completed. */
13
+ const COMPLETED_RUN_REPORTS = Object.freeze(['report.md', 'report.json']);
11
14
  import { CliError, ExitCode } from '../errors.js';
12
15
 
13
16
  /**
@@ -73,11 +76,18 @@ async function drainJob(context, jobId, flags) {
73
76
  const results = [];
74
77
 
75
78
  for (const runId of runIds) {
79
+ // A completed run's report went to the server with `complete`, which
80
+ // validated it; the copy on disk may since have been marked locally
81
+ // (dropped findings), and sending it again would replace the validated
82
+ // artifact, or be refused once the job is closed.
83
+ const completed = Boolean(state && state.runs && state.runs[runId] && state.runs[runId].completed);
76
84
  const result = await uploadRunDir(paths.runDir(jobId, runId, { repoRoot }), {
77
85
  api: context.api,
78
86
  gameId,
79
87
  jobId,
80
88
  runId,
89
+ exclude: completed ? COMPLETED_RUN_REPORTS : null,
90
+ excludeReason: 'the run is complete; its report was delivered with complete',
81
91
  includeVideo,
82
92
  includeTranscript,
83
93
  dryRun: Boolean(flags.dryRun),
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { fillPlaceholders } from './placeholders.js';
2
3
  import { writeFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
4
5
  import { parseJsonArray, parseJsonObject } from './json.js';
@@ -331,7 +332,10 @@ export async function buildAggregate({
331
332
  persona: run.persona,
332
333
  state: run.state,
333
334
  report: run.report,
334
- findings: run.report.findings || []
335
+ // A finding the server dropped for want of evidence is marked on disk
336
+ // after delivery (./dropped.js) and kept out of the aggregate, so the
337
+ // job report counts what the dashboard counts.
338
+ findings: (run.report.findings || []).filter(finding => !(finding && finding.dropped === true))
335
339
  }));
336
340
  const personasRun = new Set(inputs.map(run => run.persona).filter(Boolean)).size;
337
341
 
@@ -624,7 +628,9 @@ export function renderAggregateMarkdown(json) {
624
628
  ? `Observations dropped for lack of evidence:\n${json.observations.map(o => `- ${o.persona}: ${o.text}`).join('\n')}`
625
629
  : 'No dropped observations.');
626
630
  lines.push('');
627
- return lines.join('\n');
631
+ // The synthesis writes the TL;DR, and a model can copy a template slot into
632
+ // it as easily as into a persona report.
633
+ return fillPlaceholders(lines.join('\n'), { generatedAt: json.generated_at, game: json.game_id });
628
634
  }
629
635
 
630
636
  /**
package/src/run/args.js CHANGED
@@ -22,7 +22,8 @@ const VALUED = new Map([
22
22
  ['--cli', 'cli'],
23
23
  ['--max-actions', 'maxActions'],
24
24
  ['--concurrency', 'concurrency'],
25
- ['--quality', 'quality']
25
+ ['--quality', 'quality'],
26
+ ['--key-modifier', 'keyModifier']
26
27
  ]);
27
28
 
28
29
  /** Options that are just present or absent. */
@@ -360,6 +360,11 @@ export class GodotDriver {
360
360
  if (!action || typeof action.kind !== 'string') {
361
361
  return { ok: false, error: 'action needs a kind', changed: false };
362
362
  }
363
+ // Typing more characters is not replacing a value, and claiming it was
364
+ // would join the old text to the new one without saying so.
365
+ if (action.kind === 'type' && (action.replace || action.target)) {
366
+ return { ok: false, error: 'replace (empty a field, then type) and a typing target are only available on playwright_web; this driver can only type more text into what has focus', changed: false };
367
+ }
363
368
  try {
364
369
  await this._perform(action);
365
370
  } catch (error) {
@@ -79,6 +79,7 @@ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000
79
79
  observe,
80
80
  async act(action) {
81
81
  if (!['type', 'key', 'click', 'wait'].includes(action.kind)) return { ok: false, changed: false, error: 'Unsupported bridge action.' };
82
+ if (action.kind === 'type' && (action.replace || action.target)) return { ok: false, changed: false, error: 'replace (empty a field, then type) and a typing target are only available on playwright_web; this driver can only type more text into what has focus' };
82
83
  const before = lastHash;
83
84
  const safe = action.kind === 'type' ? { kind: 'type', text: String(action.text || '').slice(0, 2000), submit: Boolean(action.submit) }
84
85
  : action.kind === 'key' ? { kind: 'key', key: String(action.key || '').slice(0, 64) }