@reticlehq/server 3.8.0 → 3.8.2
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/SKILL.md +2 -2
- package/dist/command/cli/answers/gate-hook-message.d.ts +2 -0
- package/dist/command/cli/answers/gate-hook-message.js +5 -1
- package/dist/command/cli/auth/cloud-session.d.ts +1 -19
- package/dist/command/cli/auth/device-flow.d.ts +3 -37
- package/dist/command/cli/cli-doctor.js +30 -23
- package/dist/command/cli/cli-flow-commands.js +7 -1
- package/dist/command/cli/cli-parse-serve.d.ts +24 -0
- package/dist/command/cli/cli-parse-serve.js +124 -0
- package/dist/command/cli/cli-parse.d.ts +1 -10
- package/dist/command/cli/cli-parse.js +62 -121
- package/dist/command/cli/cli-usage.js +5 -2
- package/dist/command/cli/cloud-link.d.ts +1 -20
- package/dist/command/cli/doctor/doctor-agent-link.d.ts +23 -0
- package/dist/command/cli/doctor/doctor-agent-link.js +28 -0
- package/dist/command/cli/ports/port-holder.js +6 -1
- package/dist/command/cli/project-name.d.ts +1 -7
- package/dist/command/cli/project-name.js +1 -0
- package/dist/command/daemon/daemon.d.ts +6 -0
- package/dist/command/daemon/daemon.js +52 -2
- package/dist/command/daemon/win32-escape.d.ts +32 -0
- package/dist/command/daemon/win32-escape.js +119 -0
- package/dist/command/setup/bringup/dev-server-files.d.ts +1 -9
- package/dist/command/setup/bringup/dev-server-wait.js +5 -1
- package/dist/command/version/version-skew.d.ts +2 -0
- package/dist/command/version/version-skew.js +7 -3
- package/dist/features/crawl/crawl-tools.js +2 -2
- package/dist/features/exhaust/ledger.d.ts +15 -96
- package/dist/features/exhaust/ledger.js +3 -3
- package/dist/features/license/license.d.ts +2 -14
- package/dist/hooks/hook-commands.js +19 -9
- package/dist/judgement/capsule/capsule-store.d.ts +2 -39
- package/dist/language/flows/annotate-tools.js +1 -1
- package/dist/language/flows/fields/flow-secret-field.js +1 -1
- package/dist/language/flows/flow-expect-grammar.js +14 -6
- package/dist/language/flows/flow-lease.d.ts +27 -0
- package/dist/language/flows/flow-lease.js +59 -0
- package/dist/language/flows/flow-replay-run.d.ts +1 -87
- package/dist/language/flows/flow-replay-run.js +88 -283
- package/dist/language/flows/flow-replayable.d.ts +1 -1
- package/dist/language/flows/flow-start-path.d.ts +108 -0
- package/dist/language/flows/flow-start-path.js +313 -0
- package/dist/language/flows/flow-tools.d.ts +2 -23
- package/dist/language/flows/flow-tools.js +34 -66
- package/dist/language/flows/recording/flake.d.ts +3 -27
- package/dist/language/flows/recording/flake.js +1 -1
- package/dist/language/flows/stores/assertion-tiers-store.js +1 -1
- package/dist/machine/git-tracks.js +1 -0
- package/dist/memory/cloud/cloud-sync.d.ts +2 -24
- package/dist/memory/intent/intent-shard.d.ts +41 -266
- package/dist/memory/journal/envelope-store.js +1 -1
- package/dist/memory/journal/envelope.d.ts +6 -154
- package/dist/memory/journal/on-disk/ambient-file.d.ts +1 -7
- package/dist/memory/journal/on-disk/ambient-file.js +1 -1
- package/dist/memory/journal/self-observation.js +3 -1
- package/dist/memory/journal/session-journal.js +39 -20
- package/dist/portal/input/drive-error.d.ts +6 -0
- package/dist/portal/input/drive-error.js +9 -0
- package/dist/portal/input/real-input-press.d.ts +34 -0
- package/dist/portal/input/real-input-press.js +150 -0
- package/dist/portal/input/real-input.d.ts +6 -23
- package/dist/portal/input/real-input.js +95 -192
- package/dist/portal/input/viewport-tools.d.ts +19 -15
- package/dist/portal/pool/browser-pool.d.ts +5 -55
- package/dist/portal/pool/browser-pool.js +121 -100
- package/dist/portal/pool/pool-lease.d.ts +128 -0
- package/dist/portal/pool/pool-lease.js +49 -0
- package/dist/portal/production/production-activation.d.ts +5 -27
- package/dist/portal/session/control-envelope.d.ts +1 -1
- package/dist/portal/session/zero-install-first-call.d.ts +3 -0
- package/dist/portal/session/zero-install-first-call.js +33 -12
- package/dist/surface/mcp/mcp.d.ts +3 -3
- package/dist/surface/mcp/mcp.js +4 -6
- package/dist/surface/tools/act/act-danger.js +32 -17
- package/dist/surface/tools/act/durable.js +1 -1
- package/dist/surface/tools/act-sequence-tool.js +3 -3
- package/dist/surface/tools/act-tools.js +15 -16
- package/dist/surface/tools/agent/compact-envelope.d.ts +2 -2
- package/dist/surface/tools/coverage-tools.js +1 -1
- package/dist/surface/tools/dynamic-tools.d.ts +8 -0
- package/dist/surface/tools/dynamic-tools.js +36 -16
- package/dist/surface/tools/empty-session-list.d.ts +5 -29
- package/dist/surface/tools/feedback-tools.js +1 -1
- package/dist/surface/tools/gap-novelty.d.ts +1 -1
- package/dist/surface/tools/gaps/capability-absences.js +4 -3
- package/dist/surface/tools/harness-gate.d.ts +1 -1
- package/dist/surface/tools/harness-toolset.js +3 -4
- package/dist/surface/tools/lease-permissions.d.ts +1 -1
- package/dist/surface/tools/lease-readiness.d.ts +23 -1
- package/dist/surface/tools/lease-readiness.js +75 -9
- package/dist/surface/tools/lease-tools.d.ts +1 -52
- package/dist/surface/tools/lease-tools.js +79 -142
- package/dist/surface/tools/observe-tools.js +3 -1
- package/dist/surface/tools/read-tools.js +5 -5
- package/dist/surface/tools/real-input-attempt.js +70 -31
- package/dist/surface/tools/session-ports.d.ts +57 -0
- package/dist/surface/tools/session-ports.js +102 -0
- package/dist/surface/tools/tool-kit.d.ts +5 -3
- package/dist/surface/tools/tools.js +1 -1
- package/dist/telemetry/daemon-telemetry.d.ts +3 -1
- package/dist/telemetry/daemon-telemetry.js +9 -2
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +2 -0
- package/docs/agent-cheatsheet.md +12 -0
- package/docs/cli/gate.mdx +1 -1
- package/docs/cli/verify.mdx +8 -1
- package/docs/first-drive.md +7 -0
- package/docs/multi-agent-testing.md +2 -0
- package/docs/packages/react.mdx +14 -2
- package/docs/packages/vite-plugin.mdx +1 -0
- package/docs/predicates.mdx +6 -0
- package/docs/skill.md +3 -3
- package/docs/troubleshooting.mdx +10 -0
- package/docs/usage.md +1 -1
- package/package.json +8 -9
|
@@ -46,6 +46,7 @@ const NET_TYPES = new Set([
|
|
|
46
46
|
EventType.NET_REQUEST,
|
|
47
47
|
EventType.NET_PENDING,
|
|
48
48
|
EventType.NET_DETAIL,
|
|
49
|
+
EventType.NET_RESPONSE_BODY,
|
|
49
50
|
EventType.NET_STREAM,
|
|
50
51
|
]);
|
|
51
52
|
/** A base for parsing relative URLs. Never dereferenced — only the path is read. */
|
|
@@ -60,8 +61,9 @@ function pathOf(url) {
|
|
|
60
61
|
}
|
|
61
62
|
/** True when this event is the page fetching Reticle's own code rather than doing anything. */
|
|
62
63
|
export function isSelfObservation(event) {
|
|
63
|
-
if (!NET_TYPES.has(event.type))
|
|
64
|
+
if (!NET_TYPES.has(event.type)) {
|
|
64
65
|
return false;
|
|
66
|
+
}
|
|
65
67
|
const path = pathOf(urlForMatch(event.data));
|
|
66
68
|
return path.includes(SCOPE_PATH) || path.includes(SCOPE_FLATTENED);
|
|
67
69
|
}
|
|
@@ -49,8 +49,9 @@ const CACHE_COMPACT_AT = 1024;
|
|
|
49
49
|
* bound is used in and silently disable it, which is the one failure mode a bound must not have.
|
|
50
50
|
*/
|
|
51
51
|
function boundedOption(value, fallback, name) {
|
|
52
|
-
if (value === undefined)
|
|
52
|
+
if (value === undefined) {
|
|
53
53
|
return fallback;
|
|
54
|
+
}
|
|
54
55
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
55
56
|
throw new Error(`refusing a journal ${name} that is not a positive whole number: ${String(value)}`);
|
|
56
57
|
}
|
|
@@ -184,11 +185,13 @@ export class SessionJournal {
|
|
|
184
185
|
* already on disk is deleted, and the loss is at the end where it can be declared.
|
|
185
186
|
*/
|
|
186
187
|
async appendEvents(events) {
|
|
187
|
-
if (0 === events.length)
|
|
188
|
+
if (0 === events.length) {
|
|
188
189
|
return;
|
|
190
|
+
}
|
|
189
191
|
// Durable, not a field: see #writeLoss. A closed ledger is closed for a one-event batch too.
|
|
190
|
-
if ((await this.#closureReport()) !== undefined)
|
|
192
|
+
if ((await this.#closureReport()) !== undefined) {
|
|
191
193
|
return;
|
|
194
|
+
}
|
|
192
195
|
await this.#ensureDir();
|
|
193
196
|
const path = journalEventsPath(this.#root, this.#sessionId);
|
|
194
197
|
const written = await this.#eventBytesOnDisk(path);
|
|
@@ -221,8 +224,9 @@ export class SessionJournal {
|
|
|
221
224
|
*/
|
|
222
225
|
async #eventBytesOnDisk(path) {
|
|
223
226
|
const known = this.#eventBytes;
|
|
224
|
-
if (known !== undefined)
|
|
227
|
+
if (known !== undefined) {
|
|
225
228
|
return known;
|
|
229
|
+
}
|
|
226
230
|
let size;
|
|
227
231
|
try {
|
|
228
232
|
size = (await this.#fs.stat(path)).size;
|
|
@@ -235,8 +239,9 @@ export class SessionJournal {
|
|
|
235
239
|
// by exactly the conditions most likely to accompany a failing disk. Thrown before anything is
|
|
236
240
|
// appended; `JournalRecorder` swallows sink errors, so a live session is never taken down by
|
|
237
241
|
// one.
|
|
238
|
-
if (!this.#fs.isNotFound(error))
|
|
242
|
+
if (!this.#fs.isNotFound(error)) {
|
|
239
243
|
throw error;
|
|
244
|
+
}
|
|
240
245
|
size = 0;
|
|
241
246
|
}
|
|
242
247
|
this.#eventBytes = size;
|
|
@@ -252,16 +257,18 @@ export class SessionJournal {
|
|
|
252
257
|
* necessarily the ceiling that closed the file.
|
|
253
258
|
*/
|
|
254
259
|
async #closureReport() {
|
|
255
|
-
if (this.#lossLoaded)
|
|
260
|
+
if (this.#lossLoaded) {
|
|
256
261
|
return this.#writeLoss;
|
|
262
|
+
}
|
|
257
263
|
let text;
|
|
258
264
|
try {
|
|
259
265
|
text = await this.#fs.readFile(journalClosedPath(this.#root, this.#sessionId));
|
|
260
266
|
}
|
|
261
267
|
catch (error) {
|
|
262
268
|
// Same rule as the size read above, and for the same reason: only ENOENT is "never closed".
|
|
263
|
-
if (!this.#fs.isNotFound(error))
|
|
269
|
+
if (!this.#fs.isNotFound(error)) {
|
|
264
270
|
throw error;
|
|
271
|
+
}
|
|
265
272
|
this.#lossLoaded = true;
|
|
266
273
|
return undefined;
|
|
267
274
|
}
|
|
@@ -371,8 +378,9 @@ export class SessionJournal {
|
|
|
371
378
|
await this.#fs.appendFile(path, text);
|
|
372
379
|
}
|
|
373
380
|
catch (error) {
|
|
374
|
-
if (!this.#fs.isNotFound(error))
|
|
381
|
+
if (!this.#fs.isNotFound(error)) {
|
|
375
382
|
throw error;
|
|
383
|
+
}
|
|
376
384
|
this.#dirEnsured = false;
|
|
377
385
|
await this.#ensureDir();
|
|
378
386
|
await this.#fs.appendFile(path, text);
|
|
@@ -390,8 +398,9 @@ export class SessionJournal {
|
|
|
390
398
|
chunk = await readFrom(path, this.#eventBytesConsumed, this.#maxReadBytes);
|
|
391
399
|
}
|
|
392
400
|
catch (error) {
|
|
393
|
-
if (this.#fs.isNotFound(error))
|
|
401
|
+
if (this.#fs.isNotFound(error)) {
|
|
394
402
|
return this.#liveEvents();
|
|
403
|
+
}
|
|
395
404
|
throw error;
|
|
396
405
|
}
|
|
397
406
|
// Shrink guard: if the file is smaller than what we consumed, the offset is meaningless — reset
|
|
@@ -408,8 +417,9 @@ export class SessionJournal {
|
|
|
408
417
|
const from = chunk.from ?? this.#eventBytesConsumed;
|
|
409
418
|
const skipped = Math.max(0, from - this.#eventBytesConsumed);
|
|
410
419
|
this.#ingestTail(chunk.text);
|
|
411
|
-
if (skipped > 0)
|
|
420
|
+
if (skipped > 0) {
|
|
412
421
|
this.#noteCut(skipped);
|
|
422
|
+
}
|
|
413
423
|
// Resume at the end of the last COMPLETE line, counted forward over the bytes that actually
|
|
414
424
|
// ARRIVED. Not from `chunk.size`: that is the file's end as `fstat` reported it, and a read
|
|
415
425
|
// truncated or cut short returns less — a cursor there would skip every unread byte, silently
|
|
@@ -424,8 +434,9 @@ export class SessionJournal {
|
|
|
424
434
|
text = await this.#fs.readFile(path);
|
|
425
435
|
}
|
|
426
436
|
catch (error) {
|
|
427
|
-
if (this.#fs.isNotFound(error))
|
|
437
|
+
if (this.#fs.isNotFound(error)) {
|
|
428
438
|
return this.#liveEvents();
|
|
439
|
+
}
|
|
429
440
|
throw error;
|
|
430
441
|
}
|
|
431
442
|
// Fallback (whole-file) path: #eventCharsConsumed is a UTF-16 offset into the whole text. Shrink
|
|
@@ -467,11 +478,13 @@ export class SessionJournal {
|
|
|
467
478
|
*/
|
|
468
479
|
#ingestTail(tail) {
|
|
469
480
|
const end = tail.lastIndexOf('\n') + 1;
|
|
470
|
-
if (0 === end)
|
|
471
|
-
return;
|
|
481
|
+
if (0 === end) {
|
|
482
|
+
return;
|
|
483
|
+
} // no complete line yet
|
|
472
484
|
for (const line of tail.slice(0, end).split('\n')) {
|
|
473
|
-
if (0 === line.length)
|
|
485
|
+
if (0 === line.length) {
|
|
474
486
|
continue;
|
|
487
|
+
}
|
|
475
488
|
let parsed;
|
|
476
489
|
try {
|
|
477
490
|
parsed = JSON.parse(line);
|
|
@@ -515,8 +528,9 @@ export class SessionJournal {
|
|
|
515
528
|
this.#droppedBytes += cost;
|
|
516
529
|
this.#droppedEvents += 1;
|
|
517
530
|
// Exact, unlike a byte cut: this record was parsed, so the millisecond it was lost at is known.
|
|
518
|
-
if (victim !== undefined)
|
|
531
|
+
if (victim !== undefined) {
|
|
519
532
|
this.#lostThroughT = Math.max(this.#lostThroughT, victim.t);
|
|
533
|
+
}
|
|
520
534
|
// Release the payload now. Waiting for slot compaction keeps evicted snapshots strongly
|
|
521
535
|
// reachable, so a short returned list alone would not bound retained evidence in memory.
|
|
522
536
|
this.#eventCache[this.#cacheHead] = undefined;
|
|
@@ -545,8 +559,9 @@ export class SessionJournal {
|
|
|
545
559
|
* is what carries this to a verdict.
|
|
546
560
|
*/
|
|
547
561
|
readLoss() {
|
|
548
|
-
if (0 === this.#droppedBytes && 0 === this.#droppedEvents)
|
|
562
|
+
if (0 === this.#droppedBytes && 0 === this.#droppedEvents) {
|
|
549
563
|
return undefined;
|
|
564
|
+
}
|
|
550
565
|
const bounded = Number.isFinite(this.#lostThroughT);
|
|
551
566
|
return {
|
|
552
567
|
droppedBytes: this.#droppedBytes,
|
|
@@ -568,8 +583,9 @@ export class SessionJournal {
|
|
|
568
583
|
return this.#readLines(journalActionsPath(this.#root, this.#sessionId), JournalActionSchema);
|
|
569
584
|
}
|
|
570
585
|
async #ensureDir() {
|
|
571
|
-
if (this.#dirEnsured)
|
|
586
|
+
if (this.#dirEnsured) {
|
|
572
587
|
return;
|
|
588
|
+
}
|
|
573
589
|
await this.#fs.mkdir(sessionDirPath(this.#root, this.#sessionId));
|
|
574
590
|
this.#dirEnsured = true;
|
|
575
591
|
}
|
|
@@ -579,14 +595,16 @@ export class SessionJournal {
|
|
|
579
595
|
text = await this.#fs.readFile(path);
|
|
580
596
|
}
|
|
581
597
|
catch (error) {
|
|
582
|
-
if (this.#fs.isNotFound(error))
|
|
598
|
+
if (this.#fs.isNotFound(error)) {
|
|
583
599
|
return [];
|
|
600
|
+
}
|
|
584
601
|
throw error;
|
|
585
602
|
}
|
|
586
603
|
const out = [];
|
|
587
604
|
for (const line of text.split('\n')) {
|
|
588
|
-
if (0 === line.length)
|
|
605
|
+
if (0 === line.length) {
|
|
589
606
|
continue;
|
|
607
|
+
}
|
|
590
608
|
let parsed;
|
|
591
609
|
try {
|
|
592
610
|
parsed = JSON.parse(line);
|
|
@@ -596,8 +614,9 @@ export class SessionJournal {
|
|
|
596
614
|
}
|
|
597
615
|
const result = schema.safeParse(parsed);
|
|
598
616
|
// Validated at this boundary; ZodTypeAny widens `.data` to any, so re-narrow to the schema output.
|
|
599
|
-
if (result.success)
|
|
617
|
+
if (result.success) {
|
|
600
618
|
out.push(result.data);
|
|
619
|
+
}
|
|
601
620
|
}
|
|
602
621
|
return out;
|
|
603
622
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DriveErrorCode } from '@reticlehq/core';
|
|
2
|
+
/** Structured, code-tagged failure so callers branch on cause, not message text. */
|
|
3
|
+
export declare class DriveError extends Error {
|
|
4
|
+
readonly code: DriveErrorCode;
|
|
5
|
+
constructor(code: DriveErrorCode, message: string);
|
|
6
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import { InputModeReason } from '@reticlehq/core';
|
|
3
|
+
import type { RealInputArgs } from './real-input.js';
|
|
4
|
+
type SleepFn = (ms: number) => Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* Drive a `press` with a real keyboard — coordinate-free; the key goes to whatever holds focus.
|
|
7
|
+
* Key and modifiers come from the same core helpers the synthetic dispatcher uses, so the two paths
|
|
8
|
+
* cannot read the same args differently.
|
|
9
|
+
*
|
|
10
|
+
* A `holdMs` splits the chord into down / wait / up, the same shape the page uses for a held key.
|
|
11
|
+
* `keyboard.press` would send it instantly instead, reporting a hold-to-confirm that never held.
|
|
12
|
+
* The one thing this cannot reproduce is the auto-repeat a held key emits: the driver sends a single
|
|
13
|
+
* keydown, where the in-page path sends the repeats a browser would. Named rather than papered over
|
|
14
|
+
* — an app that counts repeats needs the synthetic path, and a caller that sees a held key do
|
|
15
|
+
* nothing now has a reason to try it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function pressViaKeyboard(page: Page, args: RealInputArgs, sleep: SleepFn, now: () => number): Promise<number>;
|
|
18
|
+
/**
|
|
19
|
+
* The `press`es this module will NOT drive: a chord that only the in-page dispatcher can express.
|
|
20
|
+
*
|
|
21
|
+
* `args.keys` asks for several keys held down TOGETHER, pressed in order and released in reverse.
|
|
22
|
+
* That is not a `page.keyboard.press` chord — a non-modifier key cannot appear before the last `+`
|
|
23
|
+
* — and a real keyboard cannot aim a key at a named element. `args.code` is the third: the driver
|
|
24
|
+
* presses by KEY name and offers no way to send a physical `code` that disagrees with it, so
|
|
25
|
+
* `{ text: 'z', code: 'KeyY' }` would strike a different key than the one asked for. All three are
|
|
26
|
+
* routed to the synthetic path by the caller, which reports the reason rather than pretending a
|
|
27
|
+
* different gesture happened.
|
|
28
|
+
*
|
|
29
|
+
* Ordered by how specifically the caller asked: `keys` then `code` name the KEY, `ref` names only
|
|
30
|
+
* the target. Two can apply at once, and the earlier one is the more unusual request — the reason
|
|
31
|
+
* should name that, not the generic "you passed a ref".
|
|
32
|
+
*/
|
|
33
|
+
export declare function unspellablePressReason(ref: string, args: Record<string, unknown>): InputModeReason | undefined;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { clampHoldMs, DriveErrorCode, explicitCodeFromArgs, InputModeReason, pressKeyFromArgs, pressKeysFromArgs, pressModifiersFromArgs, } from '@reticlehq/core';
|
|
2
|
+
import { DriveError } from './drive-error.js';
|
|
3
|
+
/**
|
|
4
|
+
* Drive a `press` with a real keyboard — coordinate-free; the key goes to whatever holds focus.
|
|
5
|
+
* Key and modifiers come from the same core helpers the synthetic dispatcher uses, so the two paths
|
|
6
|
+
* cannot read the same args differently.
|
|
7
|
+
*
|
|
8
|
+
* A `holdMs` splits the chord into down / wait / up, the same shape the page uses for a held key.
|
|
9
|
+
* `keyboard.press` would send it instantly instead, reporting a hold-to-confirm that never held.
|
|
10
|
+
* The one thing this cannot reproduce is the auto-repeat a held key emits: the driver sends a single
|
|
11
|
+
* keydown, where the in-page path sends the repeats a browser would. Named rather than papered over
|
|
12
|
+
* — an app that counts repeats needs the synthetic path, and a caller that sees a held key do
|
|
13
|
+
* nothing now has a reason to try it.
|
|
14
|
+
*/
|
|
15
|
+
export async function pressViaKeyboard(page, args, sleep, now) {
|
|
16
|
+
const key = pressKeyFromArgs(args);
|
|
17
|
+
const modifiers = pressModifiersFromArgs(args);
|
|
18
|
+
const chord = 0 < modifiers.length ? `${modifiers.join('+')}+${key}` : key;
|
|
19
|
+
const holdMs = clampHoldMs(args.holdMs);
|
|
20
|
+
if (0 === holdMs) {
|
|
21
|
+
// `press` DOES take a chord string ("Control+k"), so the tap path needs no splitting.
|
|
22
|
+
await page.keyboard.press(chord);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
/*
|
|
26
|
+
* `down`/`up` take ONE key each, unlike `press`: `down('Control+k')` throws
|
|
27
|
+
* `Unknown key: "Control+k"` — measured on a real Chromium, not assumed. The chord string the
|
|
28
|
+
* tap path uses therefore cannot express a hold, so the chord is split here: modifiers down in
|
|
29
|
+
* chord order, the key last, and released in reverse (key first, modifiers reversed), which is
|
|
30
|
+
* what a hand does and what an app's keyup bookkeeping expects.
|
|
31
|
+
*
|
|
32
|
+
* Without the split a held shortcut threw before a single key moved, the caller fell back to
|
|
33
|
+
* the synthetic path, and a real hold was impossible for any press with modifiers — the exact
|
|
34
|
+
* gesture `holdMs` exists for.
|
|
35
|
+
*
|
|
36
|
+
* ## Why every key that went down is tracked, and every exit path cleans up
|
|
37
|
+
*
|
|
38
|
+
* The down calls are the ones that MOVE the keyboard, and they can fail partway: `Control` goes
|
|
39
|
+
* down, the key after it rejects, and the driver's copy of `Control` is still held. That path
|
|
40
|
+
* used to fall straight to the caller's synthetic replay — which presses the same chord again on
|
|
41
|
+
* a keyboard that is already holding a modifier. Tracking what actually went down is what makes
|
|
42
|
+
* the cleanup below able to release the right keys, and only those: releasing a key that never
|
|
43
|
+
* went down is its own lie, and it would turn a clean failure into an unnecessary refusal.
|
|
44
|
+
*/
|
|
45
|
+
const pressed = [];
|
|
46
|
+
const pressDown = async (k) => {
|
|
47
|
+
await page.keyboard.down(k);
|
|
48
|
+
pressed.push(k);
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
for (const modifier of modifiers) {
|
|
52
|
+
await pressDown(modifier);
|
|
53
|
+
}
|
|
54
|
+
await pressDown(key);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
// The chord is PARTIALLY down. Release what is held before reporting the failure; if that
|
|
58
|
+
// cannot be proved, `releaseKeys` throws RELEASE_FAILED and the caller refuses the replay —
|
|
59
|
+
// the same rule as a failed release on the ordinary path, for the same reason. Only when the
|
|
60
|
+
// cleanup DOES succeed is the original error safe to hand back.
|
|
61
|
+
await releaseKeys(page, pressed);
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
const startedAt = now();
|
|
65
|
+
try {
|
|
66
|
+
await sleep(holdMs);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
// The wait failed, and the caller is about to run the synthetic path — which presses this SAME
|
|
70
|
+
// key a second time. If the driver's copy stays down, a held modifier then colours every later
|
|
71
|
+
// action for the rest of the run. Best-effort on purpose: whatever went wrong with the wait is
|
|
72
|
+
// the error worth reporting. A cleanup that cannot be proved is the exception — that one is a
|
|
73
|
+
// stuck key, and it must reach the caller as a refusal rather than be swallowed.
|
|
74
|
+
try {
|
|
75
|
+
await releaseKeys(page, pressed);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new DriveError(DriveErrorCode.RELEASE_FAILED, 'the wait failed and the keys it pressed could not be released, so they may still be held');
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
// NOT best-effort: on the ordinary path a failed release is a real failure the caller must hear
|
|
83
|
+
// about, and swallowing it would report a press whose key is still down.
|
|
84
|
+
await releaseKeys(page, pressed);
|
|
85
|
+
return now() - startedAt;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Release the given keys, in reverse press order, and report a failure as one that leaves the
|
|
89
|
+
* keyboard UNKNOWN rather than as an ordinary provider error.
|
|
90
|
+
*
|
|
91
|
+
* Every release is attempted even after one fails: a stuck `Control` is worse than a stuck letter,
|
|
92
|
+
* so a failure on one key must not skip the rest. The first failure is what is thrown, since it is
|
|
93
|
+
* the one that happened first, and it is wrapped in `DriveError` so the caller can tell "this
|
|
94
|
+
* gesture failed" from "this gesture may have left a key down" — the difference between a safe
|
|
95
|
+
* synthetic replay and an unsafe one.
|
|
96
|
+
*/
|
|
97
|
+
async function releaseKeys(page, pressed) {
|
|
98
|
+
let failure;
|
|
99
|
+
for (const k of [...pressed].reverse()) {
|
|
100
|
+
try {
|
|
101
|
+
await page.keyboard.up(k);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
failure ??= error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (failure !== undefined) {
|
|
108
|
+
throw new DriveError(DriveErrorCode.RELEASE_FAILED, `the key was pressed but could not be released, so it may still be held: ${describeFailure(failure)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A thrown value as a line a reader can act on.
|
|
113
|
+
*
|
|
114
|
+
* Not `String(error)`: a non-Error thrown value (a rejected plain object, an SDK that throws a
|
|
115
|
+
* record) stringifies to `[object Object]`, which names nothing and hides the one fact the message
|
|
116
|
+
* exists to carry. Anything that is neither an Error nor a string says so rather than pretending.
|
|
117
|
+
*/
|
|
118
|
+
function describeFailure(error) {
|
|
119
|
+
if (error instanceof Error) {
|
|
120
|
+
return error.message;
|
|
121
|
+
}
|
|
122
|
+
return 'string' === typeof error ? error : 'an unknown error';
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The `press`es this module will NOT drive: a chord that only the in-page dispatcher can express.
|
|
126
|
+
*
|
|
127
|
+
* `args.keys` asks for several keys held down TOGETHER, pressed in order and released in reverse.
|
|
128
|
+
* That is not a `page.keyboard.press` chord — a non-modifier key cannot appear before the last `+`
|
|
129
|
+
* — and a real keyboard cannot aim a key at a named element. `args.code` is the third: the driver
|
|
130
|
+
* presses by KEY name and offers no way to send a physical `code` that disagrees with it, so
|
|
131
|
+
* `{ text: 'z', code: 'KeyY' }` would strike a different key than the one asked for. All three are
|
|
132
|
+
* routed to the synthetic path by the caller, which reports the reason rather than pretending a
|
|
133
|
+
* different gesture happened.
|
|
134
|
+
*
|
|
135
|
+
* Ordered by how specifically the caller asked: `keys` then `code` name the KEY, `ref` names only
|
|
136
|
+
* the target. Two can apply at once, and the earlier one is the more unusual request — the reason
|
|
137
|
+
* should name that, not the generic "you passed a ref".
|
|
138
|
+
*/
|
|
139
|
+
export function unspellablePressReason(ref, args) {
|
|
140
|
+
if (0 < pressKeysFromArgs(args).length) {
|
|
141
|
+
return InputModeReason.SYNTHETIC_MULTI_KEY_PRESS_PREFERRED;
|
|
142
|
+
}
|
|
143
|
+
if (explicitCodeFromArgs(args) !== undefined) {
|
|
144
|
+
return InputModeReason.SYNTHETIC_KEY_CODE_PRESS_PREFERRED;
|
|
145
|
+
}
|
|
146
|
+
if (0 < ref.length) {
|
|
147
|
+
return InputModeReason.SYNTHETIC_ELEMENT_PRESS_PREFERRED;
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { type ScriptCoverage } from './js-coverage.js';
|
|
13
13
|
import type { Browser, Page } from 'playwright';
|
|
14
|
-
import {
|
|
14
|
+
import { DriveError } from './drive-error.js';
|
|
15
|
+
import { ActionType, InputMode } from '@reticlehq/core';
|
|
15
16
|
import { type MockRule } from './network-mock.js';
|
|
16
17
|
import { type NetworkDetail } from './network-detail.js';
|
|
17
18
|
/** Viewport CSS-px box as returned by the INSPECT command (getBoundingClientRect). */
|
|
@@ -34,6 +35,8 @@ export type RealInputArgs = {
|
|
|
34
35
|
key?: string;
|
|
35
36
|
/** For press: modifier names (Meta/Control/Shift/Alt; aliases like `cmd` accepted). */
|
|
36
37
|
modifiers?: string[];
|
|
38
|
+
/** For click: which mouse button to press (left, middle or right). Absent means left. */
|
|
39
|
+
button?: string;
|
|
37
40
|
/**
|
|
38
41
|
* For press: several keys held down TOGETHER, pressed in order and released in reverse. Read but
|
|
39
42
|
* never forwarded — a chord with a non-modifier key has no `page.keyboard.press` spelling, so the
|
|
@@ -152,11 +155,8 @@ export interface OwnedRealInputProvider extends RealInputProvider {
|
|
|
152
155
|
/** Close the owned browser. Idempotent. */
|
|
153
156
|
dispose(): Promise<void>;
|
|
154
157
|
}
|
|
155
|
-
|
|
156
|
-
export
|
|
157
|
-
readonly code: DriveErrorCode;
|
|
158
|
-
constructor(code: DriveErrorCode, message: string);
|
|
159
|
-
}
|
|
158
|
+
export { DriveError };
|
|
159
|
+
export { unspellablePressReason } from './real-input-press.js';
|
|
160
160
|
/** Center of a viewport box in CSS px. Pure — unit-tested directly. */
|
|
161
161
|
export declare function boxCenter(box: ElementBox): {
|
|
162
162
|
cx: number;
|
|
@@ -174,22 +174,6 @@ export declare function isPointerAction(action: ActionType): boolean;
|
|
|
174
174
|
export declare function isRealInputAction(action: ActionType): boolean;
|
|
175
175
|
type SleepFn = (ms: number) => Promise<void>;
|
|
176
176
|
type ConnectFn = (url: string) => Promise<Browser>;
|
|
177
|
-
/**
|
|
178
|
-
* The `press`es this module will NOT drive: a chord that only the in-page dispatcher can express.
|
|
179
|
-
*
|
|
180
|
-
* `args.keys` asks for several keys held down TOGETHER, pressed in order and released in reverse.
|
|
181
|
-
* That is not a `page.keyboard.press` chord — a non-modifier key cannot appear before the last `+`
|
|
182
|
-
* — and a real keyboard cannot aim a key at a named element. `args.code` is the third: the driver
|
|
183
|
-
* presses by KEY name and offers no way to send a physical `code` that disagrees with it, so
|
|
184
|
-
* `{ text: 'z', code: 'KeyY' }` would strike a different key than the one asked for. All three are
|
|
185
|
-
* routed to the synthetic path by the caller, which reports the reason rather than pretending a
|
|
186
|
-
* different gesture happened.
|
|
187
|
-
*
|
|
188
|
-
* Ordered by how specifically the caller asked: `keys` then `code` name the KEY, `ref` names only
|
|
189
|
-
* the target. Two can apply at once, and the earlier one is the more unusual request — the reason
|
|
190
|
-
* should name that, not the generic "you passed a ref".
|
|
191
|
-
*/
|
|
192
|
-
export declare function unspellablePressReason(ref: string, args: Record<string, unknown>): InputModeReason | undefined;
|
|
193
177
|
/**
|
|
194
178
|
* Shared gesture executor: drive a native gesture on an already-resolved Page. Used by both the
|
|
195
179
|
* CDP-attached and the launched (drive) providers so the pointer logic lives in one place.
|
|
@@ -342,4 +326,3 @@ export declare class LaunchedRealInputProvider implements OwnedRealInputProvider
|
|
|
342
326
|
export declare function selectPage<T extends {
|
|
343
327
|
url(): string;
|
|
344
328
|
}>(pages: readonly T[], sessionUrl: string): T | undefined;
|
|
345
|
-
export {};
|