tapline-host 1.4.0 → 1.5.0
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/dist/audit.d.ts +2 -0
- package/dist/audit.js +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -1
- package/dist/format.d.ts +4 -0
- package/dist/format.js +61 -20
- package/dist/format.js.map +1 -1
- package/dist/images.d.ts +27 -0
- package/dist/images.js +50 -0
- package/dist/images.js.map +1 -1
- package/dist/journey.d.ts +259 -0
- package/dist/journey.js +336 -0
- package/dist/journey.js.map +1 -0
- package/dist/journeyexport.d.ts +58 -0
- package/dist/journeyexport.js +318 -0
- package/dist/journeyexport.js.map +1 -0
- package/dist/journeygif.d.ts +62 -0
- package/dist/journeygif.js +192 -0
- package/dist/journeygif.js.map +1 -0
- package/dist/journeyreport.d.ts +202 -0
- package/dist/journeyreport.js +664 -0
- package/dist/journeyreport.js.map +1 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +27 -1
- package/dist/mcp.js.map +1 -1
- package/dist/media.d.ts +86 -0
- package/dist/media.js +244 -0
- package/dist/media.js.map +1 -0
- package/dist/protocol.d.ts +4 -0
- package/dist/protocol.js +2 -0
- package/dist/protocol.js.map +1 -1
- package/dist/run.js +1 -1
- package/dist/run.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.js +8 -0
- package/dist/server.js.map +1 -1
- package/dist/tool-text-data.d.ts +3 -0
- package/dist/tool-text-data.js +3 -0
- package/dist/tool-text-data.js.map +1 -1
- package/dist/tools.d.ts +213 -0
- package/dist/tools.js +1075 -28
- package/dist/tools.js.map +1 -1
- package/dist/uxcheck.d.ts +102 -0
- package/dist/uxcheck.js +128 -0
- package/dist/uxcheck.js.map +1 -0
- package/package.json +2 -1
- package/skills/tapline/SKILL.md +7 -0
- package/skills/tapline/references/personas.md +45 -0
package/dist/tools.js
CHANGED
|
@@ -12,9 +12,14 @@ import { AUDIT_TYPES, auditResultSchema, auditTitle, formatAudit } from "./audit
|
|
|
12
12
|
import { NO_CRASH_TEXT, crashNote, disconnectNote, formatCrash } from "./crash.js";
|
|
13
13
|
import { ActionLog, loggedTarget } from "./actionlog.js";
|
|
14
14
|
import { reportFor } from "./report.js";
|
|
15
|
+
import { EYES, IDS_HIDDEN, Journey, OPEN_URL, conditionsSchema, exportSchema, summaryLine, summaryNotes, stressSchema, survivesSchema, withoutIds, } from "./journey.js";
|
|
16
|
+
import { JOURNEYS_KEPT, JourneyRecorder, findingsLine, freshFolder, pruneJourneys, screenSignature, writeReport } from "./journeyreport.js";
|
|
17
|
+
import { exportRefusal, exportReport } from "./journeyexport.js";
|
|
15
18
|
import { randomBytes } from "node:crypto";
|
|
16
19
|
import { Confirmations } from "./confirm.js";
|
|
17
20
|
import { DEFAULT_POLICY, elementKey, listsFor, matchWord } from "./policy.js";
|
|
21
|
+
import { NO_UX_RULES, formatUxCheck, uxResultSchema } from "./uxcheck.js";
|
|
22
|
+
import { CONFIRM_ERROR, MediaState, mediaShape, runMedia } from "./media.js";
|
|
18
23
|
import { readScript, refStep, scriptSteps } from "./script.js";
|
|
19
24
|
import { badUnderKeys, checkUnder, formatPerfLines, perfFirstLine, perfResultSchema } from "./perf.js";
|
|
20
25
|
import { EXPECT_TIMEOUT_S, MAX_STEPS, RUN_TIMEOUT_MS, RUN_TOOLS, UNDER_KEYS, describeRunStep, expectFailure, parseError, stepNotes, stepSchema, } from "./run.js";
|
|
@@ -101,6 +106,7 @@ export const toolShapes = {
|
|
|
101
106
|
.describe("Draw each actionable element's box and ref (e12) on the screenshot, so whoever views it can name elements by ref"),
|
|
102
107
|
},
|
|
103
108
|
check_layout: {},
|
|
109
|
+
ux_check: {},
|
|
104
110
|
audit: {
|
|
105
111
|
types: z
|
|
106
112
|
.array(z.enum(AUDIT_TYPES))
|
|
@@ -252,9 +258,31 @@ export const toolShapes = {
|
|
|
252
258
|
tap: targetField.optional().describe("Tap this first, then measure what it sets off"),
|
|
253
259
|
},
|
|
254
260
|
wait: { seconds: z.number().min(0.1).max(30).describe("How long to wait, 0.1 to 30 seconds"), screenshot: screenshotField },
|
|
261
|
+
media: mediaShape,
|
|
255
262
|
session_report: {
|
|
256
263
|
since: z.number().min(0).optional().describe("Epoch milliseconds: report only calls from then on (default: the start of this session)"),
|
|
257
264
|
},
|
|
265
|
+
journey: {
|
|
266
|
+
start: z.boolean().optional().describe("Start a journey (needs goal); one journey at a time"),
|
|
267
|
+
goal: z.string().min(1).max(300).optional().describe('What the user wants to do, e.g. "log breakfast by scanning its barcode"'),
|
|
268
|
+
persona: z.string().max(500).optional().describe("Who is using the app: free text, or the name of an example persona"),
|
|
269
|
+
conditions: conditionsSchema
|
|
270
|
+
.optional()
|
|
271
|
+
.describe("language, region, text_size, network: applied at the start, undone at the stop"),
|
|
272
|
+
allow: z
|
|
273
|
+
.array(z.string().min(1).max(60))
|
|
274
|
+
.max(30)
|
|
275
|
+
.optional()
|
|
276
|
+
.describe('Shortcuts the journey may use: "open_url", and the names of app commands'),
|
|
277
|
+
eyes: z.enum(EYES).optional().describe('"user" (default): see the screen as a user does; "agent" turns that off for the rest of the journey'),
|
|
278
|
+
stop: z.boolean().optional().describe("End the journey (needs reached)"),
|
|
279
|
+
reached: z.boolean().optional().describe("With stop: whether the goal was reached"),
|
|
280
|
+
why: z.string().max(1000).optional().describe("With stop: why, in a line"),
|
|
281
|
+
export: exportSchema.optional().describe("With stop, only when your user asks: put the report in a GitHub PR or issue. Needs confirm: true; public: true for a public repository, fork: true for a PR from a fork"),
|
|
282
|
+
stress: stressSchema.optional().describe("A real-world moment during the journey: network (with mode), double_tap (with target), relaunch (a reload in development builds), background or rotate (a person does them, then call again with done)"),
|
|
283
|
+
survives: survivesSchema.optional().describe("With stress: what must still be there after, {text} or {goal_step}"),
|
|
284
|
+
done: z.boolean().optional().describe("With stress: the person has done the moment you asked them to (background or rotate)"),
|
|
285
|
+
},
|
|
258
286
|
run: {
|
|
259
287
|
steps: z
|
|
260
288
|
.array(stepSchema)
|
|
@@ -382,6 +410,9 @@ const scriptSchema = z.object({
|
|
|
382
410
|
/** A target that works again on a new run: testID, else label, else visible text; refs only work for the run they came from. */
|
|
383
411
|
function stableParams(params, result) {
|
|
384
412
|
const copy = typeof params === "object" && params !== null ? { ...params } : {};
|
|
413
|
+
// A journey's own flags are not part of a script.
|
|
414
|
+
delete copy["noScroll"];
|
|
415
|
+
delete copy["glass"];
|
|
385
416
|
const target = copy["target"];
|
|
386
417
|
if (typeof target !== "object" || target === null)
|
|
387
418
|
return { params: copy, fragile: false };
|
|
@@ -503,6 +534,7 @@ function deviceLabel(s) {
|
|
|
503
534
|
export const DEFAULT_SCREENSHOT_WIDTH = 600;
|
|
504
535
|
/** The app methods whose result may say `closedKeyboard: true` (the app closed the keyboard before acting). */
|
|
505
536
|
const KEYBOARD_METHODS = new Set(["press", "longPress", "toggle", "type"]);
|
|
537
|
+
const SECURE_QUIET_NOTE = "Note: no screenshot: a password field was typed into a moment ago and iOS may still show its last character; look again in a second.";
|
|
506
538
|
const NO_SCREENSHOT_NOTE = "Note: no screenshot: this app doesn't support it; add react-native-view-shot.";
|
|
507
539
|
function sleep(ms) {
|
|
508
540
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -536,6 +568,18 @@ function toWireTarget(target) {
|
|
|
536
568
|
return withIndex({ text: target.text });
|
|
537
569
|
return { x: target.x ?? 0, y: target.y ?? 0 };
|
|
538
570
|
}
|
|
571
|
+
/** The target of a call as the agent wrote it, in words (`"Save"`, `e3`, `120,300`); undefined when it gave none. */
|
|
572
|
+
function journeyTargetWords(input) {
|
|
573
|
+
const raw = input["target"] ?? input["tap"];
|
|
574
|
+
const parsed = typeof raw === "object" && raw !== null ? targetSchema.safeParse(raw) : null;
|
|
575
|
+
if (parsed?.success === true)
|
|
576
|
+
return targetWords(parsed.data);
|
|
577
|
+
if (typeof input["url"] === "string")
|
|
578
|
+
return input["url"];
|
|
579
|
+
if (typeof input["name"] === "string")
|
|
580
|
+
return input["name"];
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
539
583
|
/** A target as the agent wrote it, in words: `id=save`, `"Save"`, `e3`, `120,300`. */
|
|
540
584
|
function targetWords(target) {
|
|
541
585
|
if (target.id !== undefined)
|
|
@@ -580,6 +624,7 @@ function candidateLines(details) {
|
|
|
580
624
|
}
|
|
581
625
|
/** At most this many candidates are listed in an `ambiguous` answer (AGENT-TOOLS §4). */
|
|
582
626
|
const MAX_CANDIDATES = 5;
|
|
627
|
+
const NO_JOURNEY = "Error bad_params: no journey is running; start one with start: true and a goal.";
|
|
583
628
|
/**
|
|
584
629
|
* The `ambiguous` block (AGENT-TOOLS §4): the message, `Candidates:` with up to 5 lines indented two
|
|
585
630
|
* spaces, a `(+N more; …)` line when there are more, then the retry line. `total` is the number of
|
|
@@ -682,21 +727,37 @@ function candidatesFor(target, elements) {
|
|
|
682
727
|
// ---- tool handlers ----------------------------------------------------------
|
|
683
728
|
/** Every tool whose answer can end with a crash note (all but `crash`, which tells it in full). */
|
|
684
729
|
const CRASH_NOTED_TOOLS = [
|
|
685
|
-
"apps", "openApp", "pair", "look", "run", "inspect", "audit", "checkLayout", "viewer", "compareScreen", "filmstrip",
|
|
730
|
+
"apps", "openApp", "pair", "look", "run", "inspect", "audit", "uxCheck", "media", "checkLayout", "viewer", "compareScreen", "filmstrip",
|
|
686
731
|
"record", "replay", "tap", "longPress", "typeText", "scroll", "toggle", "appCommand", "waitFor", "swipe", "back",
|
|
687
732
|
"openUrl", "logs", "reload", "performance", "wait",
|
|
688
733
|
];
|
|
689
734
|
/** Every tool the agent calls, by handler name (the call log numbers these). */
|
|
690
735
|
const CALL_TOOLS = {
|
|
691
736
|
apps: "apps", openApp: "open_app", pair: "pair", look: "look", run: "run", inspect: "inspect", audit: "audit",
|
|
692
|
-
checkLayout: "check_layout", viewer: "viewer", compareScreen: "compare_screen", filmstrip: "filmstrip", record: "record",
|
|
737
|
+
uxCheck: "ux_check", media: "media", checkLayout: "check_layout", viewer: "viewer", compareScreen: "compare_screen", filmstrip: "filmstrip", record: "record",
|
|
693
738
|
replay: "replay", tap: "tap", longPress: "long_press", typeText: "type_text", scroll: "scroll", toggle: "toggle",
|
|
694
739
|
appCommand: "app_command", waitFor: "wait_for", swipe: "swipe", back: "back", openUrl: "open_url", logs: "logs",
|
|
695
|
-
crash: "crash", reload: "reload", performance: "performance", wait: "wait", sessionReport: "session_report",
|
|
740
|
+
crash: "crash", reload: "reload", performance: "performance", wait: "wait", sessionReport: "session_report", journey: "journey",
|
|
696
741
|
};
|
|
742
|
+
/** The calls a journey counts and measures (AGENT-TOOLS 1.12 §17.1): the agent's looks and actions. */
|
|
743
|
+
const JOURNEY_TOOLS = new Set([
|
|
744
|
+
"look", "run", "inspect", "audit", "ux_check", "check_layout", "compare_screen", "filmstrip", "tap", "long_press", "type_text", "scroll",
|
|
745
|
+
"toggle", "app_command", "wait_for", "swipe", "back", "open_url", "logs", "crash", "wait",
|
|
746
|
+
]);
|
|
747
|
+
/** A stress double tap's two taps, this many ms apart (AGENT-TOOLS 1.12 §17.4): the app makes the second. */
|
|
748
|
+
const DOUBLE_TAP_GAP_MS = 80;
|
|
749
|
+
/** What the app says of a double tap's second tap. */
|
|
750
|
+
const pressSecondSchema = z.union([
|
|
751
|
+
z.object({ done: z.literal(true), afterMs: z.number() }),
|
|
752
|
+
z.object({ done: z.literal(false), reason: z.string().regex(/^[a-z_]{1,40}$/), afterMs: z.number() }),
|
|
753
|
+
]);
|
|
754
|
+
/** The calls after which a journey keeps a small picture of the screen (§17.5); the rest only read text. */
|
|
755
|
+
const FRAME_TOOLS = new Set([
|
|
756
|
+
"look", "tap", "long_press", "type_text", "scroll", "toggle", "swipe", "back", "open_url", "app_command", "wait_for", "wait",
|
|
757
|
+
]);
|
|
697
758
|
/** Calls logged with `"kind": "read"` when they logged no action of their own. */
|
|
698
759
|
const READ_TOOLS = new Set([
|
|
699
|
-
"apps", "open_app", "pair", "look", "inspect", "audit", "check_layout", "viewer", "compare_screen", "filmstrip", "record",
|
|
760
|
+
"apps", "open_app", "pair", "look", "inspect", "audit", "ux_check", "check_layout", "viewer", "compare_screen", "filmstrip", "record",
|
|
700
761
|
"replay", "wait_for", "logs", "crash", "reload", "performance", "wait", "session_report",
|
|
701
762
|
]);
|
|
702
763
|
/** A look's picture arguments for the call log: booleans and numbers only; undefined when it gave none. */
|
|
@@ -746,6 +807,12 @@ export class TaplineTools {
|
|
|
746
807
|
scrollBase;
|
|
747
808
|
confirmations;
|
|
748
809
|
policy;
|
|
810
|
+
uxRules;
|
|
811
|
+
/**
|
|
812
|
+
* Hook for the journey (AGENT-TOOLS 1.12 §17): gives `ux_check` the action labels of earlier screens, and is told
|
|
813
|
+
* what each `ux_check` found. Null while no journey runs (naming is then not checked, and the answer says so).
|
|
814
|
+
*/
|
|
815
|
+
uxJourney = null;
|
|
749
816
|
reloadSettleMs;
|
|
750
817
|
stillGapMs;
|
|
751
818
|
stillCapMs;
|
|
@@ -786,12 +853,53 @@ export class TaplineTools {
|
|
|
786
853
|
crashBaseline;
|
|
787
854
|
/** Set inside a tool call (and everything it awaits), so a call within a call (`run`'s steps) adds no notes. */
|
|
788
855
|
noteScope = new AsyncLocalStorage();
|
|
856
|
+
/** The journey this agent has running (AGENT-TOOLS 1.12 §17.1); one at a time. */
|
|
857
|
+
journeyRun;
|
|
858
|
+
/** The app the running journey started on (its identity and name): its clean-up goes to that app only. */
|
|
859
|
+
journeyApp;
|
|
860
|
+
/** The journey's own app if it is connected (the current one first), else undefined: never another app (Codex review of 1.5.0). */
|
|
861
|
+
journeySession() {
|
|
862
|
+
const app = this.journeyApp;
|
|
863
|
+
const current = this.host.current();
|
|
864
|
+
if (app === undefined)
|
|
865
|
+
return current;
|
|
866
|
+
if (current !== undefined && identityOf(current) === app.identity)
|
|
867
|
+
return current;
|
|
868
|
+
return this.host.sessions().find((s) => identityOf(s) === app.identity);
|
|
869
|
+
}
|
|
870
|
+
/** Why a journey's clean-up didn't reach its app. */
|
|
871
|
+
journeyAppGone() {
|
|
872
|
+
return this.journeyApp === undefined ? "no app is connected" : `${this.journeyApp.name} isn't connected (Tapline changes no other app)`;
|
|
873
|
+
}
|
|
874
|
+
/** The state of the journey that stopped last, for the report writer and the stress moments. */
|
|
875
|
+
journeyDone;
|
|
876
|
+
/** Inside `run`: "run" for the batch's own checks (not counted), "step" for a step (counted like a call of its own). */
|
|
877
|
+
journeyScope = new AsyncLocalStorage();
|
|
878
|
+
/** The running journey's frames and report pictures (AGENT-TOOLS 1.12 §17.5); undefined without a `journeysDir`. */
|
|
879
|
+
recorder;
|
|
880
|
+
/** The hook this journey gave `ux_check`, to take back at the stop. */
|
|
881
|
+
journeyHook;
|
|
882
|
+
pickerOff;
|
|
883
|
+
/** Where the last touch of the call in flight was, for the frame's tap ring. */
|
|
884
|
+
lastTouch;
|
|
885
|
+
/** Stress moments that wait for a person (§17.4), by kind: what must survive. */
|
|
886
|
+
stressWaiting = new Map();
|
|
887
|
+
/** When the app last left the front or its link dropped, and when an app last connected (real time, for stress done). */
|
|
888
|
+
appLeftAt = new Map();
|
|
889
|
+
appConnectedAt = new Map();
|
|
890
|
+
/** Which app's picker event is being counted right now (set around the media state's own count). */
|
|
891
|
+
pickerFrom;
|
|
892
|
+
/** "starting" until a journey's set-up is done, "stopping" until its clean-up is done: no other start meanwhile. */
|
|
893
|
+
journeyBusy;
|
|
894
|
+
clock;
|
|
789
895
|
constructor(host, options) {
|
|
790
896
|
this.host = host;
|
|
791
897
|
this.options = options;
|
|
792
898
|
this.reloadWaitMs = options.reloadWaitMs ?? 20_000;
|
|
899
|
+
this.clock = options.clock ?? Date.now;
|
|
793
900
|
this.confirmations = options.confirmations ?? new Confirmations();
|
|
794
901
|
this.policy = options.policy ?? DEFAULT_POLICY;
|
|
902
|
+
this.uxRules = options.uxRules ?? NO_UX_RULES;
|
|
795
903
|
this.actionLog = options.logDir !== undefined ? new ActionLog(options.logDir) : null;
|
|
796
904
|
this.actionLog?.open();
|
|
797
905
|
this.reloadSettleMs = options.reloadSettleMs ?? 1500;
|
|
@@ -803,6 +911,28 @@ export class TaplineTools {
|
|
|
803
911
|
if (this.host.current()?.id === session.id)
|
|
804
912
|
this.markPersonUsedApp();
|
|
805
913
|
});
|
|
914
|
+
// What a stress moment a person does leaves behind, for `done` to check (AGENT-TOOLS 1.12 §17.4).
|
|
915
|
+
// Kept per app, so another connected app's events never count for the journey's app.
|
|
916
|
+
host.on("appState", (session, state) => {
|
|
917
|
+
if (state !== "active")
|
|
918
|
+
this.appLeftAt.set(identityOf(session), Date.now());
|
|
919
|
+
});
|
|
920
|
+
host.on("disconnect", (session) => {
|
|
921
|
+
this.appLeftAt.set(identityOf(session), Date.now());
|
|
922
|
+
});
|
|
923
|
+
host.on("connect", (session) => {
|
|
924
|
+
this.appConnectedAt.set(identityOf(session), Date.now());
|
|
925
|
+
});
|
|
926
|
+
// The app's picker used the armed test pictures: that is when a pick was "skipped" (1.5 review, finding 5).
|
|
927
|
+
host.on("pickerSkipped", (session, count) => {
|
|
928
|
+
this.pickerFrom = identityOf(session);
|
|
929
|
+
try {
|
|
930
|
+
this.mediaState.recordPickerSkipped(session.app.id, count);
|
|
931
|
+
}
|
|
932
|
+
finally {
|
|
933
|
+
this.pickerFrom = undefined;
|
|
934
|
+
}
|
|
935
|
+
});
|
|
806
936
|
// Crashes before this agent's session began are not its news.
|
|
807
937
|
this.crashBaseline = host.crashMark();
|
|
808
938
|
for (const name of CRASH_NOTED_TOOLS) {
|
|
@@ -820,6 +950,137 @@ export class TaplineTools {
|
|
|
820
950
|
const method = original;
|
|
821
951
|
Reflect.set(this, name, (...args) => this.loggedCall(tool, args[0], () => method.apply(this, args)));
|
|
822
952
|
}
|
|
953
|
+
// A running journey measures the agent's looks and actions, and shows its answers as a user would see them.
|
|
954
|
+
for (const [name, tool] of Object.entries(CALL_TOOLS)) {
|
|
955
|
+
if (!JOURNEY_TOOLS.has(tool))
|
|
956
|
+
continue;
|
|
957
|
+
const original = Reflect.get(this, name);
|
|
958
|
+
if (typeof original !== "function")
|
|
959
|
+
continue;
|
|
960
|
+
const method = original;
|
|
961
|
+
Reflect.set(this, name, (...args) => this.journeyCall(tool, args[0], () => method.apply(this, args)));
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
/** The journey running now, for the stress moments and the report writer (AGENT-TOOLS 1.12 §17). */
|
|
965
|
+
currentJourney() {
|
|
966
|
+
return this.journeyRun;
|
|
967
|
+
}
|
|
968
|
+
/** The state of the journey that stopped last. */
|
|
969
|
+
finishedJourney() {
|
|
970
|
+
return this.journeyDone;
|
|
971
|
+
}
|
|
972
|
+
/** Whether the agent sees the app as a user does: a journey is running and its eyes are the user's. */
|
|
973
|
+
userEyes() {
|
|
974
|
+
return this.journeyRun?.userEyes === true;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Measures one call of a running journey and shows its answer as a user would see it. Calls inside `run` count
|
|
978
|
+
* as steps (the batch itself and its expectation checks do not); a dry run counts as nothing.
|
|
979
|
+
*/
|
|
980
|
+
journeyCall(tool, args, run) {
|
|
981
|
+
const journey = this.journeyRun;
|
|
982
|
+
if (journey === undefined)
|
|
983
|
+
return run();
|
|
984
|
+
const input = typeof args === "object" && args !== null ? args : {};
|
|
985
|
+
const scope = this.journeyScope.getStore();
|
|
986
|
+
if (tool === "run") {
|
|
987
|
+
const answer = this.journeyScope.run("run", run);
|
|
988
|
+
return answer instanceof Promise ? answer.then((a) => this.shownToUser(a)) : this.shownToUser(answer);
|
|
989
|
+
}
|
|
990
|
+
if (scope === "run" || input["dry_run"] === true)
|
|
991
|
+
return run();
|
|
992
|
+
const at = this.clock();
|
|
993
|
+
const pre = journey.pre();
|
|
994
|
+
this.lastTouch = undefined;
|
|
995
|
+
const done = (answer) => {
|
|
996
|
+
// A step of `run` runs with no list, so the screen it ended on is read here (one cheap read), before the step is counted.
|
|
997
|
+
if (scope !== "step")
|
|
998
|
+
return counted(answer);
|
|
999
|
+
return this.currentRoute().then((route) => {
|
|
1000
|
+
if (route !== null)
|
|
1001
|
+
journey.noteFullList({ route, text: "", fromAction: false });
|
|
1002
|
+
return counted(answer);
|
|
1003
|
+
});
|
|
1004
|
+
};
|
|
1005
|
+
const counted = (answer) => {
|
|
1006
|
+
const said = answer.content.map((block) => (block.type === "text" ? block.text : "")).join("\n");
|
|
1007
|
+
const code = answer.isError === true ? /^Error (\w+):/.exec(said)?.[1] : undefined;
|
|
1008
|
+
const target = journeyTargetWords(input);
|
|
1009
|
+
journey.record({ tool, ok: answer.isError !== true, ...(code !== undefined ? { code } : {}), ...(target !== undefined ? { target } : {}), text: said, at, ms: this.clock() - at }, pre);
|
|
1010
|
+
const shown = this.shownToUser(answer);
|
|
1011
|
+
const recorder = this.recorder;
|
|
1012
|
+
if (recorder === undefined || !FRAME_TOOLS.has(tool))
|
|
1013
|
+
return shown;
|
|
1014
|
+
// The window itself is opened where the answer is made (typeText); here it only keeps the picture away.
|
|
1015
|
+
if (this.secureWindow())
|
|
1016
|
+
return shown;
|
|
1017
|
+
// A small picture of the screen after the step, kept in the report's folder and never in the answer.
|
|
1018
|
+
const touch = this.lastTouch;
|
|
1019
|
+
this.lastTouch = undefined;
|
|
1020
|
+
const animating = /^Note: the screen was still changing/m.test(said);
|
|
1021
|
+
return recorder.frame(journey.state.steps.length, journey.state.currentScreen, touch, animating).then(() => shown);
|
|
1022
|
+
};
|
|
1023
|
+
const result = run();
|
|
1024
|
+
return result instanceof Promise ? result.then(done) : done(result);
|
|
1025
|
+
}
|
|
1026
|
+
/** When a password field was last typed into, and whether that was within the second iOS still shows its last character. */
|
|
1027
|
+
secureTypedAt;
|
|
1028
|
+
secureWindow() {
|
|
1029
|
+
return this.secureTypedAt !== undefined && this.clock() - this.secureTypedAt < 1500;
|
|
1030
|
+
}
|
|
1031
|
+
/** In a user's eyes no id reaches the agent, not even inside an error the app composed. */
|
|
1032
|
+
shownToUser(answer) {
|
|
1033
|
+
if (!this.userEyes())
|
|
1034
|
+
return answer;
|
|
1035
|
+
return { ...answer, content: answer.content.map((block) => (block.type === "text" ? { ...block, text: withoutIds(block.text) } : block)) };
|
|
1036
|
+
}
|
|
1037
|
+
/** `id` and `label` answer `bad_params` in a journey's user's eyes (§17.1). */
|
|
1038
|
+
hiddenTarget(target) {
|
|
1039
|
+
if (!this.userEyes() || target === undefined)
|
|
1040
|
+
return null;
|
|
1041
|
+
return target.id !== undefined || target.label !== undefined ? fail(IDS_HIDDEN) : null;
|
|
1042
|
+
}
|
|
1043
|
+
/** Whether the journey's `allow` lets this shortcut through (always, outside a user's eyes). */
|
|
1044
|
+
shortcutAllowed(tool, what) {
|
|
1045
|
+
const journey = this.journeyRun;
|
|
1046
|
+
return journey === undefined || !journey.userEyes || journey.allows(tool === "open_url" ? OPEN_URL : what);
|
|
1047
|
+
}
|
|
1048
|
+
shortcutWords(tool, what) {
|
|
1049
|
+
return tool === "open_url"
|
|
1050
|
+
? "Error not_allowed: in a journey, open_url is a shortcut a user doesn't have, and this journey's allow doesn't name it; reach the screen the way a user would."
|
|
1051
|
+
: `Error not_allowed: in a journey, app_command ${JSON.stringify(what)} is a shortcut a user doesn't have, and this journey's allow doesn't name it.`;
|
|
1052
|
+
}
|
|
1053
|
+
/** A shortcut in a user's eyes: refused unless `allow` names it, and noted for the report when it goes through. */
|
|
1054
|
+
shortcutRefusal(tool, what) {
|
|
1055
|
+
const journey = this.journeyRun;
|
|
1056
|
+
if (journey === undefined || !journey.userEyes)
|
|
1057
|
+
return null;
|
|
1058
|
+
if (!this.shortcutAllowed(tool, what))
|
|
1059
|
+
return fail(this.shortcutWords(tool, what));
|
|
1060
|
+
journey.noteShortcut({ tool, what });
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
static SCROLL_TO_TARGET_OFF = 'Error not_allowed: in a journey, scroll to: "target" is off: a person scrolls until they see it. Scroll down or up, then look.';
|
|
1064
|
+
/** What a journey's user's eyes refuse in a step of `run`, before anything runs (a step number first); null when it is fine. */
|
|
1065
|
+
journeyStepRefusal(step, index) {
|
|
1066
|
+
if (!this.userEyes())
|
|
1067
|
+
return null;
|
|
1068
|
+
const s = step;
|
|
1069
|
+
const hidden = (value) => typeof value === "object" && value !== null && ("id" in value || "label" in value);
|
|
1070
|
+
const expect = s["expect"];
|
|
1071
|
+
if (hidden(s["target"]) || hidden(s["tap"]) || hidden(expect) || (step.tool === "wait_for" && ("id" in s || "label" in s))) {
|
|
1072
|
+
return IDS_HIDDEN.replace("Error bad_params: ", `Error bad_params: step ${index + 1}: `);
|
|
1073
|
+
}
|
|
1074
|
+
if (step.tool === "scroll" && s["to"] === "target")
|
|
1075
|
+
return TaplineTools.SCROLL_TO_TARGET_OFF.replace("Error not_allowed: ", `Error not_allowed: step ${index + 1}: `);
|
|
1076
|
+
if (step.tool === "open_url" && !this.shortcutAllowed("open_url", String(s["url"] ?? ""))) {
|
|
1077
|
+
return this.shortcutWords("open_url", "").replace("Error not_allowed: ", `Error not_allowed: step ${index + 1}: `);
|
|
1078
|
+
}
|
|
1079
|
+
const name = String(s["name"] ?? "");
|
|
1080
|
+
if (step.tool === "app_command" && name !== "" && !this.shortcutAllowed("app_command", name)) {
|
|
1081
|
+
return this.shortcutWords("app_command", name).replace("Error not_allowed: ", `Error not_allowed: step ${index + 1}: `);
|
|
1082
|
+
}
|
|
1083
|
+
return null;
|
|
823
1084
|
}
|
|
824
1085
|
/** The agent's client id for the call log; the MCP server passes the client's name once it is known. */
|
|
825
1086
|
setClientId(read) {
|
|
@@ -1276,6 +1537,11 @@ export class TaplineTools {
|
|
|
1276
1537
|
}
|
|
1277
1538
|
}
|
|
1278
1539
|
}
|
|
1540
|
+
for (const [i, step] of steps.entries()) {
|
|
1541
|
+
const refused = this.journeyStepRefusal(step, i);
|
|
1542
|
+
if (refused !== null)
|
|
1543
|
+
return fail(refused);
|
|
1544
|
+
}
|
|
1279
1545
|
this.diffSaved = null;
|
|
1280
1546
|
if (args.dry_run === true)
|
|
1281
1547
|
return this.dryRunBatch(steps);
|
|
@@ -1435,6 +1701,9 @@ export class TaplineTools {
|
|
|
1435
1701
|
}
|
|
1436
1702
|
/** One step of `run`, as its own tool call without a list. */
|
|
1437
1703
|
runStep(step) {
|
|
1704
|
+
return this.journeyScope.run("step", () => this.runStepNow(step));
|
|
1705
|
+
}
|
|
1706
|
+
runStepNow(step) {
|
|
1438
1707
|
const { tool, expect: _expect, route: _route, ...rest } = step;
|
|
1439
1708
|
const quiet = { ...rest, look: false, screenshot: false };
|
|
1440
1709
|
switch (tool) {
|
|
@@ -1517,6 +1786,9 @@ export class TaplineTools {
|
|
|
1517
1786
|
}
|
|
1518
1787
|
// -- inspect (Tapline 1.1): from the screen to the code
|
|
1519
1788
|
async inspect(args) {
|
|
1789
|
+
const hiddenInspect = this.hiddenTarget(args.target);
|
|
1790
|
+
if (hiddenInspect !== null)
|
|
1791
|
+
return hiddenInspect;
|
|
1520
1792
|
const session = this.host.current();
|
|
1521
1793
|
if (session === undefined)
|
|
1522
1794
|
return this.notConnected();
|
|
@@ -1553,6 +1825,72 @@ export class TaplineTools {
|
|
|
1553
1825
|
const title = auditTitle(await this.currentRoute(), session.app.name);
|
|
1554
1826
|
return text([formatAudit(title, parsed.data, args.types), ...this.takePersonNote()].join("\n"));
|
|
1555
1827
|
}
|
|
1828
|
+
// -- ux_check (AGENT-TOOLS 1.12 §17.3)
|
|
1829
|
+
/** The house rule `max_taps_to_goal`, for the journey to check at stop; undefined when the user set none. */
|
|
1830
|
+
get maxTapsToGoal() {
|
|
1831
|
+
return this.uxRules.maxTapsToGoal;
|
|
1832
|
+
}
|
|
1833
|
+
async uxCheck(_args = {}) {
|
|
1834
|
+
const session = this.host.current();
|
|
1835
|
+
if (session === undefined)
|
|
1836
|
+
return this.notConnected();
|
|
1837
|
+
if (!session.capabilities.includes("uxCheck")) {
|
|
1838
|
+
return fail("Error unsupported: this app's Tapline is too old for ux_check; update react-native-tapline (audit and check_layout work meanwhile).");
|
|
1839
|
+
}
|
|
1840
|
+
const earlier = this.uxJourney?.earlier() ?? null;
|
|
1841
|
+
let raw;
|
|
1842
|
+
try {
|
|
1843
|
+
raw = await this.host.request("uxCheck", { ...(this.userEyes() ? { glass: true } : {}), ...(this.uxRules.wayBack ? { wayBack: true } : {}), ...(earlier !== null ? { earlier } : {}) }, { sessionId: session.id });
|
|
1844
|
+
}
|
|
1845
|
+
catch (error) {
|
|
1846
|
+
return fail(describeError(error));
|
|
1847
|
+
}
|
|
1848
|
+
const parsed = uxResultSchema.safeParse(raw);
|
|
1849
|
+
if (!parsed.success)
|
|
1850
|
+
return fail("Error internal: the app returned a UX check the host could not read.");
|
|
1851
|
+
const title = auditTitle(await this.currentRoute(), session.app.name);
|
|
1852
|
+
this.uxJourney?.seen?.(title, parsed.data.actions, parsed.data.issues.map((i) => {
|
|
1853
|
+
const frame = i.element?.frame ?? i.where?.frame;
|
|
1854
|
+
return {
|
|
1855
|
+
kind: i.kind,
|
|
1856
|
+
problem: i.problem,
|
|
1857
|
+
basis: i.basis,
|
|
1858
|
+
...(i.element ? { ref: i.element.ref } : {}),
|
|
1859
|
+
...(frame ? { frame } : {}),
|
|
1860
|
+
};
|
|
1861
|
+
}));
|
|
1862
|
+
const body = formatUxCheck(title, parsed.data, { textRules: this.uxRules.text, journeyRunning: this.uxJourney !== null, eyes: this.userEyes() });
|
|
1863
|
+
return text([body, ...this.takePersonNote()].join("\n"));
|
|
1864
|
+
}
|
|
1865
|
+
// -- media (AGENT-TOOLS 1.12 §17.6): the logic is in media.ts
|
|
1866
|
+
/** What `media` remembers per app: the asset ids it added, and the picker skips a journey report reads. */
|
|
1867
|
+
mediaState = new MediaState();
|
|
1868
|
+
async media(args = {}) {
|
|
1869
|
+
if (args.remove === true && (args.add !== undefined || args.picker === true)) {
|
|
1870
|
+
return fail("Error bad_params: media with remove: true takes nothing else; add and picker are separate calls.");
|
|
1871
|
+
}
|
|
1872
|
+
if (args.remove !== true && args.add === undefined) {
|
|
1873
|
+
return fail("Error bad_params: media needs add (picture paths), or remove: true.");
|
|
1874
|
+
}
|
|
1875
|
+
if (args.picker === true && args.add === undefined)
|
|
1876
|
+
return fail("Error bad_params: media with picker: true needs add (picture paths).");
|
|
1877
|
+
// Adding needs the user's yes, and so does picker: true (its pictures go into the app as if the person picked them;
|
|
1878
|
+
// AGENT-TOOLS 1.12 §17.6: media with add needs confirm; 1.5 review, finding 10).
|
|
1879
|
+
if (args.remove !== true && args.confirm !== true) {
|
|
1880
|
+
return fail(args.picker === true
|
|
1881
|
+
? "Error bad_params: media with picker hands pictures to the app as if the person picked them; ask your user first, then pass confirm: true."
|
|
1882
|
+
: CONFIRM_ERROR);
|
|
1883
|
+
}
|
|
1884
|
+
const session = this.host.current();
|
|
1885
|
+
if (session === undefined)
|
|
1886
|
+
return this.notConnected();
|
|
1887
|
+
return runMedia(args, {
|
|
1888
|
+
state: this.mediaState,
|
|
1889
|
+
session,
|
|
1890
|
+
request: (params) => this.host.request("media", params, { sessionId: session.id, timeoutMs: 60_000 }),
|
|
1891
|
+
describeError,
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1556
1894
|
// -- check_layout
|
|
1557
1895
|
async checkLayout(_args = {}) {
|
|
1558
1896
|
const session = this.host.current();
|
|
@@ -1597,11 +1935,16 @@ export class TaplineTools {
|
|
|
1597
1935
|
/** Text lines plus an optional screenshot (an image, or a file path in file mode) for the current screen. */
|
|
1598
1936
|
async lookParts(session, wantScreenshot, width = DEFAULT_SCREENSHOT_WIDTH, onlyChanges = false, labels = false) {
|
|
1599
1937
|
const sid = session.id;
|
|
1938
|
+
// Right after typing into a password field iOS may still show its last character: no picture then, and the note says why.
|
|
1939
|
+
const secureQuiet = wantScreenshot && this.secureWindow();
|
|
1940
|
+
if (secureQuiet)
|
|
1941
|
+
wantScreenshot = false;
|
|
1600
1942
|
// Changes only make sense against a list this agent has already been given in this session.
|
|
1601
1943
|
const askChanges = onlyChanges && this.listed.has(sid);
|
|
1602
1944
|
// `onlyChanges` is the after-an-action call. With `changes: true` the app keeps the refs and
|
|
1603
1945
|
// counts on from the highest one; `look` (onlyChanges false) always restarts at e1.
|
|
1604
|
-
const
|
|
1946
|
+
const eyes = this.userEyes();
|
|
1947
|
+
const treeParams = { ...(askChanges ? { changes: true } : {}), ...(eyes ? { glass: true } : {}) };
|
|
1605
1948
|
const treePromise = this.host.request("tree", treeParams, { sessionId: sid });
|
|
1606
1949
|
const shotPromise = wantScreenshot && !this.noScreenshot.has(sid)
|
|
1607
1950
|
? this.host
|
|
@@ -1632,7 +1975,7 @@ export class TaplineTools {
|
|
|
1632
1975
|
this.alertOpen.add(sid);
|
|
1633
1976
|
else
|
|
1634
1977
|
this.alertOpen.delete(sid);
|
|
1635
|
-
const changesText = askChanges ? formatChanges(tree, { alertGone }) : null;
|
|
1978
|
+
const changesText = askChanges ? formatChanges(tree, { alertGone, eyes }) : null;
|
|
1636
1979
|
// For the call log: the list the agent now has (a change block leaves it as it was, but no longer certain).
|
|
1637
1980
|
if (changesText === null)
|
|
1638
1981
|
this.listBase = { elements: tree.elements, stale: false, unseen: false };
|
|
@@ -1641,9 +1984,12 @@ export class TaplineTools {
|
|
|
1641
1984
|
// A full list gives every ref a fresh meaning, so refs are trustworthy again.
|
|
1642
1985
|
if (changesText === null)
|
|
1643
1986
|
this.personActed = false;
|
|
1644
|
-
const lines = [changesText ?? formatTree(tree, { name: session.app.name, id: session.app.id })];
|
|
1987
|
+
const lines = [changesText ?? formatTree(tree, { name: session.app.name, id: session.app.id }, { eyes })];
|
|
1988
|
+
// A journey keeps the first full list of each screen (and counts the screens entered) for its report.
|
|
1989
|
+
if (changesText === null)
|
|
1990
|
+
this.journeyRun?.noteFullList({ route: tree.route?.names ?? [], text: lines[0] ?? "", fromAction: onlyChanges });
|
|
1645
1991
|
// A full list in place of the changes (most of the screen changed) still says what changed out of view.
|
|
1646
|
-
if (askChanges && changesText === null)
|
|
1992
|
+
if (askChanges && changesText === null && !eyes)
|
|
1647
1993
|
lines.push(...formatOffScreenAfterList(tree));
|
|
1648
1994
|
const content = [];
|
|
1649
1995
|
// Notes come last (AGENT-TOOLS §3.6), after the list and any `Screenshot:` path line.
|
|
@@ -1661,7 +2007,9 @@ export class TaplineTools {
|
|
|
1661
2007
|
}
|
|
1662
2008
|
const shot = await shotPromise;
|
|
1663
2009
|
if (shot === undefined) {
|
|
1664
|
-
if (
|
|
2010
|
+
if (secureQuiet)
|
|
2011
|
+
notes.push(SECURE_QUIET_NOTE);
|
|
2012
|
+
else if (wantScreenshot)
|
|
1665
2013
|
notes.push(NO_SCREENSHOT_NOTE);
|
|
1666
2014
|
}
|
|
1667
2015
|
else if (shot.ok) {
|
|
@@ -1712,6 +2060,19 @@ export class TaplineTools {
|
|
|
1712
2060
|
if (this.listBase !== undefined)
|
|
1713
2061
|
this.listBase = { ...this.listBase, unseen: true };
|
|
1714
2062
|
}
|
|
2063
|
+
/** Remembers where a touch landed (points, and the touched element's frame) for the journey's frame and GIF. */
|
|
2064
|
+
noteTouch(method, params, result) {
|
|
2065
|
+
if (this.recorder === undefined || (method !== "press" && method !== "longPress" && method !== "toggle"))
|
|
2066
|
+
return;
|
|
2067
|
+
const element = typeof result === "object" && result !== null && "element" in result ? elementSchema.safeParse(result.element) : null;
|
|
2068
|
+
const frame = element?.success ? element.data.frame : undefined;
|
|
2069
|
+
const target = typeof params === "object" && params !== null ? params.target : undefined;
|
|
2070
|
+
const t = typeof target === "object" && target !== null ? target : undefined;
|
|
2071
|
+
const point = t !== undefined && typeof t.x === "number" && typeof t.y === "number"
|
|
2072
|
+
? { x: t.x, y: t.y }
|
|
2073
|
+
: frame ? { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 } : undefined;
|
|
2074
|
+
this.lastTouch = { ...(point !== undefined ? { point } : {}), ...(frame ? { frame } : {}) };
|
|
2075
|
+
}
|
|
1715
2076
|
/** Runs a request, then (unless disabled) appends the fresh element list and how long the app took to settle. */
|
|
1716
2077
|
async act(method, params, describe, withLook, screenshot, extraNotes,
|
|
1717
2078
|
/** True when the result means nothing was done: the first line is then the whole answer. */
|
|
@@ -1747,6 +2108,7 @@ export class TaplineTools {
|
|
|
1747
2108
|
return fail(describeError(error));
|
|
1748
2109
|
}
|
|
1749
2110
|
const line = describe(result);
|
|
2111
|
+
this.noteTouch(method, params, result);
|
|
1750
2112
|
if (nothingDone?.(result) === true) {
|
|
1751
2113
|
this.logExtra = undefined;
|
|
1752
2114
|
return text([line, ...this.takePersonNote()].join("\n"));
|
|
@@ -2547,6 +2909,9 @@ export class TaplineTools {
|
|
|
2547
2909
|
}
|
|
2548
2910
|
}
|
|
2549
2911
|
async filmstrip(args) {
|
|
2912
|
+
const hiddenFilm = this.hiddenTarget(args.tap);
|
|
2913
|
+
if (hiddenFilm !== null)
|
|
2914
|
+
return hiddenFilm;
|
|
2550
2915
|
const session = this.host.current();
|
|
2551
2916
|
if (session === undefined)
|
|
2552
2917
|
return this.notConnected();
|
|
@@ -2560,7 +2925,7 @@ export class TaplineTools {
|
|
|
2560
2925
|
if (args.tap !== undefined) {
|
|
2561
2926
|
// A screenshot takes a few hundred ms on a phone, so frame 1 is the "before".
|
|
2562
2927
|
// The tap is a touch like any other: the safety policy decides first.
|
|
2563
|
-
const pressParams = { target: toWireTarget(args.tap) };
|
|
2928
|
+
const pressParams = { target: toWireTarget(args.tap), ...(this.userEyes() ? { noScroll: true } : {}) };
|
|
2564
2929
|
// The policy check, the "before" frame and the tap are one turn of the live view's queue: a pause refuses
|
|
2565
2930
|
// it, a person's input goes first, and the screen can't change between the check and the tap (PR #4 review).
|
|
2566
2931
|
const turn = async () => {
|
|
@@ -2803,7 +3168,7 @@ export class TaplineTools {
|
|
|
2803
3168
|
const element = elementSchema.safeParse(result[key]);
|
|
2804
3169
|
if (element.success) {
|
|
2805
3170
|
const shown = withoutValue ? { ...element.data, value: null, placeholder: null } : element.data;
|
|
2806
|
-
return formatElement(shown, { actions: false, frame: false });
|
|
3171
|
+
return formatElement(shown, { actions: false, frame: false, eyes: this.userEyes() });
|
|
2807
3172
|
}
|
|
2808
3173
|
}
|
|
2809
3174
|
return "element";
|
|
@@ -3044,7 +3409,7 @@ export class TaplineTools {
|
|
|
3044
3409
|
label: element.label ?? null,
|
|
3045
3410
|
text: element.text ?? null,
|
|
3046
3411
|
textMore: element.textMore ?? null,
|
|
3047
|
-
}, { actions: false });
|
|
3412
|
+
}, { actions: false, eyes: this.userEyes() });
|
|
3048
3413
|
}
|
|
3049
3414
|
/** A `{ref}` target after a person used the app: refs may point elsewhere. */
|
|
3050
3415
|
staleRef(params) {
|
|
@@ -3059,19 +3424,29 @@ export class TaplineTools {
|
|
|
3059
3424
|
return null;
|
|
3060
3425
|
}
|
|
3061
3426
|
tap(args) {
|
|
3062
|
-
const
|
|
3427
|
+
const hiddenTap = this.hiddenTarget(args.target);
|
|
3428
|
+
if (hiddenTap !== null)
|
|
3429
|
+
return Promise.resolve(hiddenTap);
|
|
3430
|
+
const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}), ...(this.userEyes() ? { noScroll: true } : {}) };
|
|
3063
3431
|
return this.guarded("tap", "press", params, args.dry_run === true, (preflight) => this.act("press", params, (r) => afterScroll(r, `Tapped ${this.elementName(r)}.`), args.look, args.screenshot, undefined, undefined, preflight));
|
|
3064
3432
|
}
|
|
3065
3433
|
/** `seconds` is accepted for the shared interface; Tapline calls the handler, so it doesn't hold. */
|
|
3066
3434
|
longPress(args) {
|
|
3067
|
-
const
|
|
3435
|
+
const hiddenPress = this.hiddenTarget(args.target);
|
|
3436
|
+
if (hiddenPress !== null)
|
|
3437
|
+
return Promise.resolve(hiddenPress);
|
|
3438
|
+
const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}), ...(this.userEyes() ? { noScroll: true } : {}) };
|
|
3068
3439
|
return this.guarded("long_press", "longPress", params, args.dry_run === true, (preflight) => this.act("longPress", params, (r) => afterScroll(r, `Long-pressed ${this.elementName(r)}.`), args.look, args.screenshot, undefined, undefined, preflight));
|
|
3069
3440
|
}
|
|
3070
3441
|
typeText(args) {
|
|
3442
|
+
const hiddenField = this.hiddenTarget(args.target);
|
|
3443
|
+
if (hiddenField !== null)
|
|
3444
|
+
return Promise.resolve(hiddenField);
|
|
3071
3445
|
const params = {
|
|
3072
3446
|
text: args.text,
|
|
3073
3447
|
replace: args.replace ?? true,
|
|
3074
3448
|
submit: args.submit ?? false,
|
|
3449
|
+
...(this.userEyes() ? { noScroll: true } : {}),
|
|
3075
3450
|
};
|
|
3076
3451
|
if (args.target !== undefined)
|
|
3077
3452
|
params["target"] = toWireTarget(withIndex(args.target, args.index));
|
|
@@ -3084,6 +3459,9 @@ export class TaplineTools {
|
|
|
3084
3459
|
const value = typeof r === "object" && r !== null && "value" in r ? r.value : undefined;
|
|
3085
3460
|
// AGENT-TOOLS 1.8 §3.3: a secure field's value is never shown, not even as bullets in this line
|
|
3086
3461
|
const secure = typeof r === "object" && r !== null && "secure" in r && r.secure === true;
|
|
3462
|
+
// Journey or not, iOS shows the last character of a password for about a second: remember when (finding 4).
|
|
3463
|
+
if (secure)
|
|
3464
|
+
this.secureTypedAt = this.clock();
|
|
3087
3465
|
const shown = secure ? " The value is hidden (secure field)." : typeof value === "string" ? ` Value is now ${JSON.stringify(value)}.` : "";
|
|
3088
3466
|
return afterScroll(r, `Typed into ${this.elementName(r, "element", shown !== "")}${args.submit ? " and submitted" : ""}.${shown}`);
|
|
3089
3467
|
}, args.look, args.screenshot);
|
|
@@ -3092,7 +3470,12 @@ export class TaplineTools {
|
|
|
3092
3470
|
if (args.to !== undefined && (args.direction !== undefined || args.amount !== undefined)) {
|
|
3093
3471
|
return Promise.resolve(fail("Error bad_params: use either `to` or `direction`/`amount`, not both."));
|
|
3094
3472
|
}
|
|
3473
|
+
const hiddenScroll = this.hiddenTarget(args.target);
|
|
3474
|
+
if (hiddenScroll !== null)
|
|
3475
|
+
return Promise.resolve(hiddenScroll);
|
|
3095
3476
|
const toTarget = args.to === "target";
|
|
3477
|
+
if (toTarget && this.userEyes())
|
|
3478
|
+
return Promise.resolve(fail(TaplineTools.SCROLL_TO_TARGET_OFF));
|
|
3096
3479
|
if (toTarget && args.target === undefined) {
|
|
3097
3480
|
return Promise.resolve(fail('Error bad_params: to: "target" needs a target: the element to bring onto the screen.'));
|
|
3098
3481
|
}
|
|
@@ -3144,7 +3527,10 @@ export class TaplineTools {
|
|
|
3144
3527
|
});
|
|
3145
3528
|
}
|
|
3146
3529
|
toggle(args) {
|
|
3147
|
-
const
|
|
3530
|
+
const hiddenSwitch = this.hiddenTarget(args.target);
|
|
3531
|
+
if (hiddenSwitch !== null)
|
|
3532
|
+
return Promise.resolve(hiddenSwitch);
|
|
3533
|
+
const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(this.userEyes() ? { noScroll: true } : {}) };
|
|
3148
3534
|
if (args.value !== undefined)
|
|
3149
3535
|
params["value"] = args.value;
|
|
3150
3536
|
if (args.force === true)
|
|
@@ -3179,17 +3565,12 @@ export class TaplineTools {
|
|
|
3179
3565
|
: `${session.app.name} offers no commands.`);
|
|
3180
3566
|
}
|
|
3181
3567
|
const name = args.name.trim();
|
|
3182
|
-
|
|
3183
|
-
if (
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
if (mode !== "normal" && names(listsFor(this.policy, app).block)) {
|
|
3189
|
-
const where = own?.block !== undefined && app !== undefined ? `for ${app.name}` : "here";
|
|
3190
|
-
return fail(`Error not_allowed: blocked: the safety policy blocks network ${where}.`);
|
|
3191
|
-
}
|
|
3192
|
-
}
|
|
3568
|
+
const shortcut = this.shortcutRefusal("app_command", name);
|
|
3569
|
+
if (shortcut !== null)
|
|
3570
|
+
return shortcut;
|
|
3571
|
+
const blocked = name === "network" ? this.networkRefusal(args.args?.mode) : null;
|
|
3572
|
+
if (blocked !== null)
|
|
3573
|
+
return blocked;
|
|
3193
3574
|
return this.act("command", { name, args: args.args ?? {} }, (r) => {
|
|
3194
3575
|
const result = typeof r === "object" && r !== null && "result" in r ? r.result : null;
|
|
3195
3576
|
// Built in `network`: the same line every header carries while a condition is on (AGENT-TOOLS §16.4),
|
|
@@ -3204,8 +3585,22 @@ export class TaplineTools {
|
|
|
3204
3585
|
return result === null || result === undefined ? `Ran ${name}.` : `Ran ${name}: ${JSON.stringify(result)}`;
|
|
3205
3586
|
}, args.look, args.screenshot);
|
|
3206
3587
|
}
|
|
3588
|
+
/** AGENT-TOOLS 1.11 §16.4: a policy whose block list holds `network` refuses a condition; `normal` always goes. */
|
|
3589
|
+
networkRefusal(mode, app = this.host.current()?.app) {
|
|
3590
|
+
const names = (words) => (words ?? []).some((word) => word.trim().toLowerCase() === "network");
|
|
3591
|
+
const own = app === undefined ? undefined : (this.policy.apps?.[app.id ?? ""] ?? this.policy.apps?.[app.name]);
|
|
3592
|
+
if (mode !== "normal" && names(listsFor(this.policy, app).block)) {
|
|
3593
|
+
const where = own?.block !== undefined && app !== undefined ? `for ${app.name}` : "here";
|
|
3594
|
+
return fail(`Error not_allowed: blocked: the safety policy blocks network ${where}.`);
|
|
3595
|
+
}
|
|
3596
|
+
return null;
|
|
3597
|
+
}
|
|
3207
3598
|
waitFor(args) {
|
|
3208
|
-
const
|
|
3599
|
+
const hiddenWait = this.hiddenTarget(args);
|
|
3600
|
+
if (hiddenWait !== null)
|
|
3601
|
+
return Promise.resolve(hiddenWait);
|
|
3602
|
+
// In a user's eyes it matches what is on the glass only (§17.1).
|
|
3603
|
+
const params = this.userEyes() ? { glass: true } : {};
|
|
3209
3604
|
if (args.text !== undefined)
|
|
3210
3605
|
params["text"] = args.text;
|
|
3211
3606
|
if (args.id !== undefined)
|
|
@@ -3261,6 +3656,9 @@ export class TaplineTools {
|
|
|
3261
3656
|
}, args.look, args.screenshot);
|
|
3262
3657
|
}
|
|
3263
3658
|
async openUrl(args) {
|
|
3659
|
+
const refused = this.shortcutRefusal("open_url", args.url);
|
|
3660
|
+
if (refused !== null)
|
|
3661
|
+
return refused;
|
|
3264
3662
|
const answer = await this.act("openURL", { url: args.url }, () => `Opened ${args.url}.`, args.look, args.screenshot);
|
|
3265
3663
|
// The app's own forget link unpairs it, so it drops the connection before answering: that is the link working.
|
|
3266
3664
|
if (answer.isError === true && /(^|\/)tapline\/forget\/?$/.test(args.url.replace(/[?#].*$/, ""))) {
|
|
@@ -3443,6 +3841,9 @@ export class TaplineTools {
|
|
|
3443
3841
|
if (args.launch === true && args.tap !== undefined) {
|
|
3444
3842
|
return fail("Error bad_params: launch reloads the app and tap taps in it; give one of them.");
|
|
3445
3843
|
}
|
|
3844
|
+
const hiddenPerf = this.hiddenTarget(args.tap);
|
|
3845
|
+
if (hiddenPerf !== null)
|
|
3846
|
+
return hiddenPerf;
|
|
3446
3847
|
const seconds = args.seconds ?? 3;
|
|
3447
3848
|
try {
|
|
3448
3849
|
let measure;
|
|
@@ -3456,7 +3857,7 @@ export class TaplineTools {
|
|
|
3456
3857
|
let press;
|
|
3457
3858
|
if (args.tap !== undefined) {
|
|
3458
3859
|
// The tap is a touch like any other: the safety policy decides first (as for filmstrip).
|
|
3459
|
-
press = { target: toWireTarget(args.tap) };
|
|
3860
|
+
press = { target: toWireTarget(args.tap), ...(this.userEyes() ? { noScroll: true } : {}) };
|
|
3460
3861
|
const stale = this.staleRef(press);
|
|
3461
3862
|
if (stale !== null)
|
|
3462
3863
|
return stale;
|
|
@@ -3496,6 +3897,652 @@ export class TaplineTools {
|
|
|
3496
3897
|
return fail(describeError(error));
|
|
3497
3898
|
}
|
|
3498
3899
|
}
|
|
3900
|
+
// -- journey (AGENT-TOOLS 1.12 §17.1)
|
|
3901
|
+
async journey(args = {}) {
|
|
3902
|
+
if (args.start === true && args.stop === true)
|
|
3903
|
+
return fail("Error bad_params: give start or stop, not both.");
|
|
3904
|
+
if (args.start === true)
|
|
3905
|
+
return this.journeyStart(args);
|
|
3906
|
+
const startOnly = ["goal", "persona", "conditions", "allow"].filter((key) => args[key] !== undefined);
|
|
3907
|
+
if (startOnly.length > 0)
|
|
3908
|
+
return fail(`Error bad_params: ${startOnly.join(", ")} go with start: true.`);
|
|
3909
|
+
if (args.stop === true)
|
|
3910
|
+
return this.journeyStop(args);
|
|
3911
|
+
const stopOnly = ["reached", "why", "export"].filter((key) => args[key] !== undefined);
|
|
3912
|
+
if (stopOnly.length > 0)
|
|
3913
|
+
return fail(`Error bad_params: ${stopOnly.join(", ")} go with stop: true.`);
|
|
3914
|
+
const running = this.journeyRun;
|
|
3915
|
+
if (args.stress !== undefined || args.survives !== undefined || args.done !== undefined) {
|
|
3916
|
+
if (running === undefined)
|
|
3917
|
+
return fail(NO_JOURNEY);
|
|
3918
|
+
return this.journeyStress(running, args);
|
|
3919
|
+
}
|
|
3920
|
+
if (args.eyes !== undefined) {
|
|
3921
|
+
if (running === undefined)
|
|
3922
|
+
return fail(NO_JOURNEY);
|
|
3923
|
+
if (args.eyes === "user") {
|
|
3924
|
+
return running.userEyes
|
|
3925
|
+
? text("Already in a user's eyes.")
|
|
3926
|
+
: fail("Error bad_params: eyes: \"agent\" can't be turned back to a user's during a journey; start a new one.");
|
|
3927
|
+
}
|
|
3928
|
+
if (!running.userEyes)
|
|
3929
|
+
return text("Already in the agent's eyes.");
|
|
3930
|
+
running.switchEyes();
|
|
3931
|
+
// The next list is a full one: what the agent can see just changed.
|
|
3932
|
+
const session = this.host.current();
|
|
3933
|
+
if (session !== undefined)
|
|
3934
|
+
this.listed.delete(session.id);
|
|
3935
|
+
return text("Eyes: the agent's from now on (ids, labels, everything in the tree; jumps and shortcuts allowed). The report says so.");
|
|
3936
|
+
}
|
|
3937
|
+
if (running !== undefined) {
|
|
3938
|
+
const m = running.state.measures;
|
|
3939
|
+
return text(`Journey running: ${JSON.stringify(running.state.goal)}, ${running.userEyes ? "in a user's eyes" : "in the agent's eyes"}; ${m.taps} taps, ${m.screensEntered} screens so far.`);
|
|
3940
|
+
}
|
|
3941
|
+
return fail("Error bad_params: give start: true with a goal, or stop: true with reached.");
|
|
3942
|
+
}
|
|
3943
|
+
async journeyStart(args) {
|
|
3944
|
+
const running = this.journeyRun;
|
|
3945
|
+
if (running !== undefined) {
|
|
3946
|
+
return fail(`Error bad_params: a journey is already running (${JSON.stringify(running.state.goal)}); stop it first with stop: true and reached.`);
|
|
3947
|
+
}
|
|
3948
|
+
if (this.journeyBusy !== undefined) {
|
|
3949
|
+
return fail(`Error busy: a journey is still ${this.journeyBusy === "starting" ? "starting" : "stopping"}; try again in a moment.`);
|
|
3950
|
+
}
|
|
3951
|
+
const goal = args.goal?.trim() ?? "";
|
|
3952
|
+
if (goal === "")
|
|
3953
|
+
return fail("Error bad_params: start needs a goal: what the user wants to do.");
|
|
3954
|
+
if (args.reached !== undefined || args.why !== undefined || args.export !== undefined) {
|
|
3955
|
+
return fail("Error bad_params: reached, why and export go with stop: true.");
|
|
3956
|
+
}
|
|
3957
|
+
const session = this.host.current();
|
|
3958
|
+
if (session === undefined)
|
|
3959
|
+
return this.notConnected();
|
|
3960
|
+
// Taken before the first await: a second start (or one during a stop's clean-up) can't overlap this one.
|
|
3961
|
+
this.journeyBusy = "starting";
|
|
3962
|
+
try {
|
|
3963
|
+
return await this.journeyStarted(args, goal, session);
|
|
3964
|
+
}
|
|
3965
|
+
finally {
|
|
3966
|
+
// Only the start releases its own hold (a stop never takes it over: it answers busy while this runs).
|
|
3967
|
+
if (this.journeyBusy === "starting")
|
|
3968
|
+
this.journeyBusy = undefined;
|
|
3969
|
+
if (this.journeyRun === undefined)
|
|
3970
|
+
this.journeyApp = undefined;
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
async journeyStarted(args, goal, session) {
|
|
3974
|
+
this.journeyApp = { identity: identityOf(session), name: session.app.name };
|
|
3975
|
+
const journey = new Journey({
|
|
3976
|
+
goal,
|
|
3977
|
+
...(args.persona !== undefined && args.persona.trim() !== "" ? { persona: args.persona.trim() } : {}),
|
|
3978
|
+
...(args.conditions !== undefined ? { conditions: args.conditions } : {}),
|
|
3979
|
+
...(args.allow !== undefined ? { allow: args.allow } : {}),
|
|
3980
|
+
...(args.eyes !== undefined ? { eyes: args.eyes } : {}),
|
|
3981
|
+
}, this.clock);
|
|
3982
|
+
const notes = [];
|
|
3983
|
+
const conditions = args.conditions ?? {};
|
|
3984
|
+
// Network first: the others then run under it. Each goes through the app's own commands (AGENT-TOOLS §16).
|
|
3985
|
+
const order = ["network", "language", "region", "text_size"];
|
|
3986
|
+
for (const name of order) {
|
|
3987
|
+
const value = conditions[name];
|
|
3988
|
+
if (value === undefined)
|
|
3989
|
+
continue;
|
|
3990
|
+
const record = await this.applyCondition(session, name, value);
|
|
3991
|
+
journey.state.conditionRecords.push(record);
|
|
3992
|
+
if (!record.applied)
|
|
3993
|
+
notes.push(`Note: condition ${name} ${value} was not applied: ${record.failure ?? "the app refused"}.`);
|
|
3994
|
+
}
|
|
3995
|
+
this.journeyRun = journey;
|
|
3996
|
+
this.journeyDone = undefined;
|
|
3997
|
+
this.stressWaiting.clear();
|
|
3998
|
+
// The naming memory of an earlier journey (or a hook someone left) is not this journey's.
|
|
3999
|
+
this.uxJourney = null;
|
|
4000
|
+
this.journeyHook = undefined;
|
|
4001
|
+
this.startRecorder(journey, session, notes);
|
|
4002
|
+
const live = this.host.current();
|
|
4003
|
+
const persona = journey.state.persona ?? "no persona";
|
|
4004
|
+
const eyesWords = journey.userEyes ? "in a user's eyes" : "in the agent's eyes";
|
|
4005
|
+
const first = `Journey started: "${goal}" as ${persona}, ${eyesWords}.`;
|
|
4006
|
+
const applied = journey.state.conditionRecords.filter((r) => r.applied).map((r) => `${r.name.replace("_", " ")} ${r.value}`);
|
|
4007
|
+
const head = [first, ...(applied.length > 0 ? [`Conditions: ${applied.join(", ")}.`] : []), ...notes];
|
|
4008
|
+
if (live === undefined) {
|
|
4009
|
+
return text([...head, "Note: the app is reconnecting, so there's no screen yet; look in a few seconds."].join("\n"));
|
|
4010
|
+
}
|
|
4011
|
+
const parts = await this.lookParts(live, false, DEFAULT_SCREENSHOT_WIDTH, false);
|
|
4012
|
+
const [top, ...rest] = parts.content;
|
|
4013
|
+
const list = top !== undefined && top.type === "text" ? top.text : "";
|
|
4014
|
+
// The screen the journey starts on is the GIF's first frame.
|
|
4015
|
+
await this.recorder?.frame(0, journey.state.currentScreen, undefined, false);
|
|
4016
|
+
return this.shownToUser({ content: [{ type: "text", text: [...head, list].join("\n") }, ...rest], ...(parts.isError ? { isError: true } : {}) });
|
|
4017
|
+
}
|
|
4018
|
+
/** Opens the report folder for a journey that has somewhere to keep one, and hands `ux_check` the hook that feeds it. */
|
|
4019
|
+
startRecorder(journey, first, notes) {
|
|
4020
|
+
const root = this.options.journeysDir;
|
|
4021
|
+
if (root === undefined)
|
|
4022
|
+
return;
|
|
4023
|
+
try {
|
|
4024
|
+
mkdirSync(root, { recursive: true });
|
|
4025
|
+
// The newest reports stay; older folders go, so a long-lived host doesn't fill the disk (1.5 review, finding 6).
|
|
4026
|
+
pruneJourneys(root, JOURNEYS_KEPT - 1);
|
|
4027
|
+
// Frames and naming memory belong to the journey's own app, and only while it is connected and in front.
|
|
4028
|
+
const app = identityOf(first);
|
|
4029
|
+
const recorder = new JourneyRecorder(freshFolder(root, journey.state.goal, this.clock()), this.frameSource(app), this.clock);
|
|
4030
|
+
this.recorder = recorder;
|
|
4031
|
+
const mine = () => {
|
|
4032
|
+
const now = this.host.current();
|
|
4033
|
+
return now !== undefined && identityOf(now) === app;
|
|
4034
|
+
};
|
|
4035
|
+
const hook = {
|
|
4036
|
+
earlier: () => (mine() ? recorder.earlier(journey.state.currentScreen) : null),
|
|
4037
|
+
seen: (title, actions, findings) => {
|
|
4038
|
+
if (mine())
|
|
4039
|
+
void recorder.uxSeen(journey.state.currentScreen, title, actions, findings);
|
|
4040
|
+
},
|
|
4041
|
+
};
|
|
4042
|
+
this.journeyHook = hook;
|
|
4043
|
+
this.uxJourney = hook;
|
|
4044
|
+
// Only this journey's app: another connected app using its armed pictures is not this journey's skip.
|
|
4045
|
+
this.pickerOff = this.mediaState.onPickerSkipped((appId, count) => {
|
|
4046
|
+
if (this.pickerFrom !== undefined ? this.pickerFrom !== app : appId !== first.app.id)
|
|
4047
|
+
return;
|
|
4048
|
+
recorder.pickerSkips++;
|
|
4049
|
+
recorder.pickerPictures += count;
|
|
4050
|
+
});
|
|
4051
|
+
}
|
|
4052
|
+
catch (error) {
|
|
4053
|
+
notes.push(`Note: no report will be written: ${error instanceof Error ? error.message : String(error)}`);
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
/** The recorder's eyes: a read that touches nothing, and a small picture. */
|
|
4057
|
+
frameSource(app) {
|
|
4058
|
+
// Rechecked before each request: the journey's own app or nothing, whatever `open_app` chose meanwhile.
|
|
4059
|
+
const bound = () => {
|
|
4060
|
+
const session = this.journeyApp === undefined ? undefined : this.journeySession();
|
|
4061
|
+
return session !== undefined && identityOf(session) === app ? session : undefined;
|
|
4062
|
+
};
|
|
4063
|
+
return {
|
|
4064
|
+
where: () => {
|
|
4065
|
+
const now = this.host.current();
|
|
4066
|
+
if (now === undefined)
|
|
4067
|
+
return "disconnected";
|
|
4068
|
+
if (identityOf(now) !== app)
|
|
4069
|
+
return "other";
|
|
4070
|
+
const state = now.appState?.state;
|
|
4071
|
+
return state === "background" || state === "inactive" ? "background" : "front";
|
|
4072
|
+
},
|
|
4073
|
+
peek: async () => {
|
|
4074
|
+
const session = bound();
|
|
4075
|
+
if (session === undefined)
|
|
4076
|
+
return undefined;
|
|
4077
|
+
const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
|
|
4078
|
+
if (!tree.success)
|
|
4079
|
+
return undefined;
|
|
4080
|
+
const t = tree.data;
|
|
4081
|
+
return {
|
|
4082
|
+
signature: screenSignature(t.elements, t.route?.names ?? [], (t.alerts ?? []).length),
|
|
4083
|
+
...(t.screen ? { screen: { w: t.screen.width, h: t.screen.height } } : {}),
|
|
4084
|
+
route: t.route?.names ?? [],
|
|
4085
|
+
};
|
|
4086
|
+
},
|
|
4087
|
+
shot: async (width, quality) => {
|
|
4088
|
+
const session = bound();
|
|
4089
|
+
if (session === undefined || this.noScreenshot.has(session.id))
|
|
4090
|
+
return undefined;
|
|
4091
|
+
// Right after a password was typed iOS may still show its last character: no picture then, from any path (a ux_check's too).
|
|
4092
|
+
if (this.secureWindow())
|
|
4093
|
+
return undefined;
|
|
4094
|
+
try {
|
|
4095
|
+
return await this.takeShot(session.id, width, quality);
|
|
4096
|
+
}
|
|
4097
|
+
catch {
|
|
4098
|
+
return undefined;
|
|
4099
|
+
}
|
|
4100
|
+
},
|
|
4101
|
+
};
|
|
4102
|
+
}
|
|
4103
|
+
async journeyStop(args) {
|
|
4104
|
+
// A stop while the start still reads its first screen would let the start's end clear the stop's hold.
|
|
4105
|
+
if (this.journeyBusy === "starting")
|
|
4106
|
+
return fail("Error busy: the journey is still starting; stop it in a moment.");
|
|
4107
|
+
const running = this.journeyRun;
|
|
4108
|
+
if (running === undefined)
|
|
4109
|
+
return fail(NO_JOURNEY);
|
|
4110
|
+
if (args.reached === undefined)
|
|
4111
|
+
return fail("Error bad_params: stop needs reached: true or false.");
|
|
4112
|
+
// An export writes to the user's GitHub: the agent must say its user asked. Refused before the journey ends, so the call can be made again.
|
|
4113
|
+
if (args.export !== undefined && args.export.confirm !== true) {
|
|
4114
|
+
return fail("Error bad_params: export writes to your user's GitHub, so it needs confirm: true inside export (only when your user asked for it). The journey is still running.");
|
|
4115
|
+
}
|
|
4116
|
+
if (this.journeyBusy === "stopping")
|
|
4117
|
+
return fail("Error busy: this journey is already stopping.");
|
|
4118
|
+
// Held until the clean-up is done, so a new journey can't start while the old one still changes the app.
|
|
4119
|
+
this.journeyBusy = "stopping";
|
|
4120
|
+
try {
|
|
4121
|
+
// The public-repository check also runs before the journey ends, so a refusal can be answered with public: true on a second stop.
|
|
4122
|
+
const run = this.options.exportRunner;
|
|
4123
|
+
if (args.export !== undefined && run !== undefined) {
|
|
4124
|
+
const refused = await exportRefusal(args.export, run);
|
|
4125
|
+
if (refused !== null)
|
|
4126
|
+
return fail(`Error not_allowed: ${refused}. The journey is still running; stop again with the option, or without export.`);
|
|
4127
|
+
}
|
|
4128
|
+
return await this.journeyStopped(args, running, args.reached);
|
|
4129
|
+
}
|
|
4130
|
+
finally {
|
|
4131
|
+
if (this.journeyBusy === "stopping")
|
|
4132
|
+
this.journeyBusy = undefined;
|
|
4133
|
+
}
|
|
4134
|
+
}
|
|
4135
|
+
async journeyStopped(args, running, reached) {
|
|
4136
|
+
const state = running.finish(reached, args.why);
|
|
4137
|
+
this.journeyRun = undefined;
|
|
4138
|
+
this.journeyDone = state;
|
|
4139
|
+
const recorder = this.recorder;
|
|
4140
|
+
this.recorder = undefined;
|
|
4141
|
+
this.pickerOff?.();
|
|
4142
|
+
this.pickerOff = undefined;
|
|
4143
|
+
this.uxJourney = null;
|
|
4144
|
+
this.journeyHook = undefined;
|
|
4145
|
+
this.stressWaiting.clear();
|
|
4146
|
+
// From here nothing fails the stop: what goes wrong is a Note line, and the local report stays.
|
|
4147
|
+
const failures = [];
|
|
4148
|
+
const attempt = async (what, work) => {
|
|
4149
|
+
try {
|
|
4150
|
+
return await work();
|
|
4151
|
+
}
|
|
4152
|
+
catch (error) {
|
|
4153
|
+
failures.push(`Note: ${what}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4154
|
+
return undefined;
|
|
4155
|
+
}
|
|
4156
|
+
};
|
|
4157
|
+
const lines = [summaryLine(state)];
|
|
4158
|
+
const content = [];
|
|
4159
|
+
try {
|
|
4160
|
+
await attempt("conditions could not be undone", () => this.undoConditions(state));
|
|
4161
|
+
// A picker stub the journey armed and nobody used goes back, so a person's next pick is their own (finding 5).
|
|
4162
|
+
await attempt("the photo picker could not be put back", async () => {
|
|
4163
|
+
const live = this.journeySession();
|
|
4164
|
+
if (live !== undefined && live.capabilities.includes("media"))
|
|
4165
|
+
await this.host.request("media", { op: "disarm" }, { sessionId: live.id });
|
|
4166
|
+
});
|
|
4167
|
+
const undoLines = [];
|
|
4168
|
+
// A network condition a stress moment turned on mid-flow goes back too (one the journey began with is undone above).
|
|
4169
|
+
if (state.networkStress && !state.conditionRecords.some((r) => r.name === "network" && r.applied)) {
|
|
4170
|
+
const done = await attempt("the network condition a stress moment set was not undone", async () => {
|
|
4171
|
+
const live = this.journeySession();
|
|
4172
|
+
return live === undefined ? undefined : this.runCondition(live, "network", "normal");
|
|
4173
|
+
});
|
|
4174
|
+
undoLines.push(done?.ok === true ? "Undone: network is normal again." : "Note: the network condition a stress moment set was not undone; set it back with app_command network normal.");
|
|
4175
|
+
}
|
|
4176
|
+
this.journeyApp = undefined;
|
|
4177
|
+
const session = this.host.current();
|
|
4178
|
+
// The agent's next list is a full one: the journey's lists were of the glass only.
|
|
4179
|
+
if (session !== undefined)
|
|
4180
|
+
this.listed.delete(session.id);
|
|
4181
|
+
const report = recorder === undefined
|
|
4182
|
+
? undefined
|
|
4183
|
+
: await attempt("the report could not be written", () => writeReport({
|
|
4184
|
+
state,
|
|
4185
|
+
rec: recorder,
|
|
4186
|
+
maxTaps: this.maxTapsToGoal,
|
|
4187
|
+
textRules: this.uxRules.text,
|
|
4188
|
+
...(this.options.gifMaxBytes !== undefined ? { gifMaxBytes: this.options.gifMaxBytes } : {}),
|
|
4189
|
+
}));
|
|
4190
|
+
if (report !== undefined)
|
|
4191
|
+
lines.push(`${findingsLine(report.counts)} Report: ${report.folder}`);
|
|
4192
|
+
lines.push(...summaryNotes(state), ...undoLines, ...(report?.notes ?? []));
|
|
4193
|
+
if (report !== undefined) {
|
|
4194
|
+
// At most two small key pictures: where the journey ended, and the worst finding's screen.
|
|
4195
|
+
if (this.options.screenshots === "file") {
|
|
4196
|
+
if (report.keys.length > 0)
|
|
4197
|
+
lines.push(`Pictures: ${report.keys.map((k) => join(report.folder, k)).join(", ")}`);
|
|
4198
|
+
}
|
|
4199
|
+
else {
|
|
4200
|
+
for (const key of report.keys.slice(0, 2)) {
|
|
4201
|
+
const data = await attempt("a key picture could not be read", () => readFileSync(join(report.folder, key)).toString("base64"));
|
|
4202
|
+
if (data !== undefined)
|
|
4203
|
+
content.push({ type: "image", data, mimeType: "image/jpeg" });
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
const target = args.export;
|
|
4208
|
+
if (target !== undefined) {
|
|
4209
|
+
if (recorder === undefined)
|
|
4210
|
+
lines.push("Export needs the report folder, which this host isn't set up with; nothing was exported.");
|
|
4211
|
+
else if (report === undefined)
|
|
4212
|
+
lines.push("Export skipped: there is no report to export.");
|
|
4213
|
+
else {
|
|
4214
|
+
const exported = await attempt("the export failed", () => this.exportReport(target, state, report, recorder));
|
|
4215
|
+
lines.push(...(exported ?? [`Export failed. The report stays local: ${report.folder}`]));
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
catch (error) {
|
|
4220
|
+
failures.push(`Note: the stop did not finish everything: ${error instanceof Error ? error.message : String(error)}`);
|
|
4221
|
+
}
|
|
4222
|
+
lines.push(...failures);
|
|
4223
|
+
if (this.options.onJourneyStop !== undefined) {
|
|
4224
|
+
try {
|
|
4225
|
+
lines.push(...(await this.options.onJourneyStop(state)));
|
|
4226
|
+
}
|
|
4227
|
+
catch (error) {
|
|
4228
|
+
lines.push(`Note: the report could not be written: ${error instanceof Error ? error.message : String(error)}`);
|
|
4229
|
+
}
|
|
4230
|
+
}
|
|
4231
|
+
return { content: [{ type: "text", text: lines.join("\n") }, ...content] };
|
|
4232
|
+
}
|
|
4233
|
+
/** `export` of `stop`: the report to GitHub through the user's own gh and git; the report stays local when it fails. */
|
|
4234
|
+
async exportReport(target, state, report, recorder) {
|
|
4235
|
+
const run = this.options.exportRunner;
|
|
4236
|
+
if (run === undefined)
|
|
4237
|
+
return [`Export isn't available here (this host has no gh runner); the report stays local: ${report.folder}`];
|
|
4238
|
+
const outcome = await exportReport({
|
|
4239
|
+
target,
|
|
4240
|
+
folder: recorder.folder,
|
|
4241
|
+
name: report.name,
|
|
4242
|
+
goal: state.goal,
|
|
4243
|
+
summary: summaryLine(state),
|
|
4244
|
+
findingsLine: findingsLine(report.counts),
|
|
4245
|
+
findings: report.findings,
|
|
4246
|
+
files: report.exportFiles,
|
|
4247
|
+
headline: report.gif !== null ? "journey.gif" : report.firstPicture,
|
|
4248
|
+
}, run);
|
|
4249
|
+
return outcome.ok ? outcome.lines : [`Export failed: ${outcome.message}. The report stays local: ${report.folder}`];
|
|
4250
|
+
}
|
|
4251
|
+
// -- stress moments (AGENT-TOOLS 1.12 §17.4)
|
|
4252
|
+
async journeyStress(running, args) {
|
|
4253
|
+
const stress = args.stress;
|
|
4254
|
+
if (stress === undefined)
|
|
4255
|
+
return fail("Error bad_params: survives and done go with stress.");
|
|
4256
|
+
const kind = stress.kind;
|
|
4257
|
+
if (stress.mode !== undefined && kind !== "network")
|
|
4258
|
+
return fail("Error bad_params: mode goes with stress network.");
|
|
4259
|
+
if (stress.target !== undefined && kind !== "double_tap")
|
|
4260
|
+
return fail("Error bad_params: target goes with stress double_tap.");
|
|
4261
|
+
const survives = args.survives;
|
|
4262
|
+
if (args.done === true) {
|
|
4263
|
+
const asked = this.stressWaiting.get(kind);
|
|
4264
|
+
if (asked === undefined) {
|
|
4265
|
+
return fail(`Error bad_params: no ${kind} moment is waiting for a person; ask for it first with journey { stress: { kind: "${kind}" } }.`);
|
|
4266
|
+
}
|
|
4267
|
+
await this.reconnected();
|
|
4268
|
+
// Done is checked, not taken on the agent's word: the app must show that the moment happened.
|
|
4269
|
+
// An app that is gone by then is judged by survives (it did not survive), not by what it left behind.
|
|
4270
|
+
const unseen = this.host.current() === undefined ? null : await this.stressUnseen(kind, asked);
|
|
4271
|
+
if (unseen !== null)
|
|
4272
|
+
return fail(`Error bad_params: ${unseen} Ask the person again, then say done.`);
|
|
4273
|
+
this.stressWaiting.delete(kind);
|
|
4274
|
+
return this.stressDone(running, kind, "", await this.survivalOf(running, survives ?? asked.survives));
|
|
4275
|
+
}
|
|
4276
|
+
if (this.host.current() === undefined)
|
|
4277
|
+
return this.notConnected();
|
|
4278
|
+
switch (kind) {
|
|
4279
|
+
case "network":
|
|
4280
|
+
return this.stressNetwork(running, stress.mode, survives);
|
|
4281
|
+
case "double_tap":
|
|
4282
|
+
return this.stressDoubleTap(running, stress.target, survives);
|
|
4283
|
+
case "relaunch": {
|
|
4284
|
+
// A development build reloads; anything else needs a person. Said as what it is.
|
|
4285
|
+
const before = this.host.current()?.id;
|
|
4286
|
+
const answer = await this.reload({ look: true });
|
|
4287
|
+
const said = answer.content.map((block) => (block.type === "text" ? block.text : "")).join("\n");
|
|
4288
|
+
if (answer.isError === true && /^Error unsupported/.test(said))
|
|
4289
|
+
return this.waitForPerson(kind, "close the app and open it again", survives);
|
|
4290
|
+
if (answer.isError === true)
|
|
4291
|
+
return answer;
|
|
4292
|
+
await this.reconnected();
|
|
4293
|
+
// A reload that timed out leaves the old session: that is no relaunch to record.
|
|
4294
|
+
const after = this.host.current();
|
|
4295
|
+
if (after === undefined || after.id === before) {
|
|
4296
|
+
return fail("Error not_allowed: the app has not come back as a new session after the reload, so the moment can't be counted.");
|
|
4297
|
+
}
|
|
4298
|
+
return this.stressDone(running, kind, " (reload, not a relaunch)", await this.survivalOf(running, survives));
|
|
4299
|
+
}
|
|
4300
|
+
case "background":
|
|
4301
|
+
return this.waitForPerson(kind, "switch to another app and back", survives);
|
|
4302
|
+
case "rotate":
|
|
4303
|
+
return this.waitForPerson(kind, "turn the phone and keep it turned", survives);
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
4306
|
+
async waitForPerson(kind, what, survives) {
|
|
4307
|
+
this.stressWaiting.set(kind, { survives, askedAt: Date.now(), size: kind === "rotate" ? await this.freshScreenSize() : null });
|
|
4308
|
+
return text([
|
|
4309
|
+
`Stress: ${kind} needs a person: ${what}, then say done.`,
|
|
4310
|
+
`When they have, call journey { stress: { kind: "${kind}" }, done: true }${survives !== undefined ? " (it checks what you asked to survive then)" : ", with survives to say what must still be there"}.`,
|
|
4311
|
+
].join("\n"));
|
|
4312
|
+
}
|
|
4313
|
+
/** The screen's size in points from a read that touches nothing; null when the app doesn't answer. */
|
|
4314
|
+
async freshScreenSize() {
|
|
4315
|
+
const session = this.host.current();
|
|
4316
|
+
if (session === undefined)
|
|
4317
|
+
return null;
|
|
4318
|
+
const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true }, { sessionId: session.id }).catch(() => null));
|
|
4319
|
+
const screen = tree.success ? tree.data.screen : null;
|
|
4320
|
+
return screen ? { width: screen.width, height: screen.height } : null;
|
|
4321
|
+
}
|
|
4322
|
+
/**
|
|
4323
|
+
* Why a person's stress moment can't be taken as done, or null when the app shows it happened: background left the
|
|
4324
|
+
* front (or the link dropped) since it was asked; relaunch connected anew; rotate flipped the screen's size.
|
|
4325
|
+
*/
|
|
4326
|
+
async stressUnseen(kind, asked) {
|
|
4327
|
+
const mine = this.journeyApp?.identity ?? "";
|
|
4328
|
+
switch (kind) {
|
|
4329
|
+
case "background":
|
|
4330
|
+
return (this.appLeftAt.get(mine) ?? 0) > asked.askedAt ? null : "The app never left the front since the moment was asked, so it can't be counted.";
|
|
4331
|
+
case "relaunch":
|
|
4332
|
+
return (this.appConnectedAt.get(mine) ?? 0) > asked.askedAt ? null : "The app hasn't started again since the moment was asked (no new connection), so it can't be counted.";
|
|
4333
|
+
case "rotate": {
|
|
4334
|
+
const now = await this.freshScreenSize();
|
|
4335
|
+
const was = asked.size;
|
|
4336
|
+
if (now === null || was === null)
|
|
4337
|
+
return "The screen's size couldn't be read, so the turn can't be confirmed.";
|
|
4338
|
+
const flipped = Math.round(now.width) === Math.round(was.height) && Math.round(now.height) === Math.round(was.width) && Math.round(now.width) !== Math.round(now.height);
|
|
4339
|
+
return flipped ? null : `The screen is still ${Math.round(now.width)}x${Math.round(now.height)} pt, as when the moment was asked: say done while the phone is turned.`;
|
|
4340
|
+
}
|
|
4341
|
+
default:
|
|
4342
|
+
return null;
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
/** Waits for an app after a reload or a person leaving it (a new session comes up), a few seconds at most. */
|
|
4346
|
+
async reconnected() {
|
|
4347
|
+
const deadline = Date.now() + Math.min(this.reloadWaitMs, 8000);
|
|
4348
|
+
while (this.host.current() === undefined && Date.now() < deadline)
|
|
4349
|
+
await sleep(100);
|
|
4350
|
+
if (this.host.current() !== undefined)
|
|
4351
|
+
await sleep(Math.min(this.reloadSettleMs, 500));
|
|
4352
|
+
}
|
|
4353
|
+
async stressNetwork(running, mode, survives) {
|
|
4354
|
+
if (mode === undefined)
|
|
4355
|
+
return fail('Error bad_params: stress network needs mode: "offline", "3g", "lossy" or "normal".');
|
|
4356
|
+
// On the journey's own app, the one the stop puts back to normal; never another app selected since.
|
|
4357
|
+
const session = this.journeySession();
|
|
4358
|
+
if (session === undefined)
|
|
4359
|
+
return fail(`Error not_connected: ${this.journeyAppGone()}.`);
|
|
4360
|
+
const done = await this.runCondition(session, "network", mode);
|
|
4361
|
+
if (!done.ok)
|
|
4362
|
+
return fail(`Error not_allowed: network ${mode} wasn't applied: ${done.failure}.`);
|
|
4363
|
+
running.state.networkStress = mode !== "normal";
|
|
4364
|
+
return this.stressDone(running, "network", ` ${mode}`, await this.survivalOf(running, survives));
|
|
4365
|
+
}
|
|
4366
|
+
/** The same touch twice, 80 ms apart, each through the usual checks and the policy. */
|
|
4367
|
+
async stressDoubleTap(running, raw, survives) {
|
|
4368
|
+
const parsed = raw === undefined ? null : targetSchema.safeParse(raw);
|
|
4369
|
+
if (parsed === null || !parsed.success)
|
|
4370
|
+
return fail("Error bad_params: stress double_tap needs a target: exactly one of ref, text or x and y.");
|
|
4371
|
+
const target = parsed.data;
|
|
4372
|
+
// One OK never covers two touches: a target the policy flags (a word that asks a person, or blocks) is refused whole.
|
|
4373
|
+
const session = this.host.current();
|
|
4374
|
+
if (session === undefined)
|
|
4375
|
+
return this.notConnected();
|
|
4376
|
+
const lists = listsFor(this.policy, session.app);
|
|
4377
|
+
// Resolved inside the agent's turn, right before the press, as a guarded tap does: a queued person's action can't change
|
|
4378
|
+
// what is under the target between the check and the touch.
|
|
4379
|
+
const refusal = { answer: null };
|
|
4380
|
+
const preflight = async () => {
|
|
4381
|
+
if (lists.confirm.length === 0 && lists.block.length === 0)
|
|
4382
|
+
return null;
|
|
4383
|
+
const resolved = await this.resolveForPolicy(session, "press", { target: toWireTarget(target) });
|
|
4384
|
+
if (resolved.kind === "error")
|
|
4385
|
+
refusal.answer = fail(resolved.text);
|
|
4386
|
+
else {
|
|
4387
|
+
const verdict = this.policyWords(lists, resolved.elements);
|
|
4388
|
+
if (verdict.kind === "blocked")
|
|
4389
|
+
refusal.answer = fail(this.blockedLine(verdict.element, verdict.word));
|
|
4390
|
+
else if (verdict.kind === "confirm") {
|
|
4391
|
+
refusal.answer = fail(`Error not_allowed: needs_confirmation: double_tap on ${this.shortName(verdict.element, false)} is refused: it needs a person's OK (policy word ${JSON.stringify(verdict.word)}), and one OK never covers two touches. Tap it once instead (a person is asked), or choose another target.`);
|
|
4392
|
+
}
|
|
4393
|
+
}
|
|
4394
|
+
return refusal.answer;
|
|
4395
|
+
};
|
|
4396
|
+
// Both taps go to the app in one request, in one turn of the agent: the app makes the second DOUBLE_TAP_GAP_MS after
|
|
4397
|
+
// the first, so nothing (the control queue, the app's own request queue, a settle) can stretch the gap. The moment is
|
|
4398
|
+
// one stress, not two taps of the journey.
|
|
4399
|
+
const params = { target: toWireTarget(target), again: DOUBLE_TAP_GAP_MS, ...(this.userEyes() ? { noScroll: true } : {}) };
|
|
4400
|
+
let pressed;
|
|
4401
|
+
const answer = await this.act("press", params, (r) => {
|
|
4402
|
+
pressed = r;
|
|
4403
|
+
return `Tapped ${this.elementName(r)}.`;
|
|
4404
|
+
}, false, undefined, undefined, undefined, preflight);
|
|
4405
|
+
if (refusal.answer !== null)
|
|
4406
|
+
return refusal.answer;
|
|
4407
|
+
const line = (r) => (r.content.map((c) => (c.type === "text" ? c.text : "")).join("\n").split("\n")[0] ?? "");
|
|
4408
|
+
if (answer.isError === true) {
|
|
4409
|
+
return fail(`Stress: double_tap on ${targetWords(target)} didn't happen as asked: ${line(answer)}`);
|
|
4410
|
+
}
|
|
4411
|
+
const second = pressSecondSchema.safeParse(typeof pressed === "object" && pressed !== null && "second" in pressed ? pressed.second : undefined);
|
|
4412
|
+
if (!second.success) {
|
|
4413
|
+
return fail(`Stress: double_tap on ${targetWords(target)} didn't happen as asked: the app made only the first tap (a double tap in one request needs react-native-tapline 1.5 or later in the app).`);
|
|
4414
|
+
}
|
|
4415
|
+
const checked = await this.survivalOf(running, survives);
|
|
4416
|
+
const done = this.stressDone(running, "double_tap", ` on ${targetWords(target)}`, checked);
|
|
4417
|
+
const s = second.data;
|
|
4418
|
+
const how = s.done
|
|
4419
|
+
? `Both taps went through, ${s.afterMs} ms apart.`
|
|
4420
|
+
: `The first tap went through; the second, ${s.afterMs} ms later, didn't land (${s.reason}).`;
|
|
4421
|
+
return text(`${line(done)}\n${how}`);
|
|
4422
|
+
}
|
|
4423
|
+
/** Records the moment and answers it: `Stress: <kind>; it survived: …` or `…; it didn't survive: …`. */
|
|
4424
|
+
stressDone(running, kind, via, check) {
|
|
4425
|
+
const verdict = check.survived === null ? `; ${check.why}` : check.survived ? `; it survived: ${check.why}` : `; it didn't survive: ${check.why}`;
|
|
4426
|
+
const said = `Stress: ${kind}${via}${verdict}`;
|
|
4427
|
+
running.state.stress.push({ kind, said, survived: check.survived, afterStep: running.state.steps.length, at: this.clock() });
|
|
4428
|
+
return text(said);
|
|
4429
|
+
}
|
|
4430
|
+
/** Whether what had to survive is still there, from a read that touches nothing. */
|
|
4431
|
+
async survivalOf(running, survives) {
|
|
4432
|
+
if (survives === undefined || (survives.text === undefined && survives.goal_step === undefined)) {
|
|
4433
|
+
return { survived: null, why: "nothing was checked (no survives given)." };
|
|
4434
|
+
}
|
|
4435
|
+
// The journey's own app, the one the moment happened to; never another app selected since.
|
|
4436
|
+
const session = this.journeySession();
|
|
4437
|
+
if (session === undefined)
|
|
4438
|
+
return { survived: false, why: "the app isn't connected any more." };
|
|
4439
|
+
// What a person sees (AGENT-TOOLS 1.12 §17.4): in a user's eyes the glass only; a label a screen reader hears is no proof.
|
|
4440
|
+
const tree = treeResultSchema.safeParse(await this.host.request("tree", { peek: true, ...(this.userEyes() ? { glass: true } : {}) }, { sessionId: session.id }).catch(() => null));
|
|
4441
|
+
if (!tree.success)
|
|
4442
|
+
return { survived: false, why: "the app didn't answer when asked what is on screen." };
|
|
4443
|
+
const parts = [];
|
|
4444
|
+
if (survives.text !== undefined) {
|
|
4445
|
+
const wanted = survives.text;
|
|
4446
|
+
const holds = (value) => (typeof value === "string" || typeof value === "number") && String(value).includes(wanted);
|
|
4447
|
+
const found = tree.data.elements.find((e) => holds(e.text) || holds(e.value));
|
|
4448
|
+
if (found === undefined)
|
|
4449
|
+
parts.push({ ok: false, why: `text ${JSON.stringify(wanted)} is not on screen any more.` });
|
|
4450
|
+
else {
|
|
4451
|
+
const field = found.role === "textfield" || found.role === "input" || (found.actions ?? []).includes("type");
|
|
4452
|
+
parts.push({ ok: true, why: `text ${JSON.stringify(wanted)} is still ${field ? "in the field" : "on screen"}.` });
|
|
4453
|
+
}
|
|
4454
|
+
}
|
|
4455
|
+
if (survives.goal_step !== undefined)
|
|
4456
|
+
parts.push(this.goalStepHolds(running, survives.goal_step, tree.data.route?.names ?? []));
|
|
4457
|
+
// A check that couldn't be made (ok null) is no pass: survived only when every check held, null when one was unknown and none failed.
|
|
4458
|
+
const survived = parts.some((p) => p.ok === false) ? false : parts.every((p) => p.ok === true) ? true : null;
|
|
4459
|
+
return { survived, why: parts.map((p) => p.why).join(" ") };
|
|
4460
|
+
}
|
|
4461
|
+
/** `goal_step`: a step number (the screen that step ended on) or a screen's name (part of its route). */
|
|
4462
|
+
goalStepHolds(running, goalStep, route) {
|
|
4463
|
+
const now = route.join(" › ");
|
|
4464
|
+
if (/^\d+$/.test(goalStep.trim())) {
|
|
4465
|
+
const n = Number(goalStep);
|
|
4466
|
+
const step = running.state.steps[n - 1];
|
|
4467
|
+
if (step === undefined)
|
|
4468
|
+
return { ok: false, why: `the journey has no step ${n}.` };
|
|
4469
|
+
const screen = step.screen === undefined ? undefined : running.state.screens[step.screen - 1];
|
|
4470
|
+
if (screen === undefined || screen.route.length === 0 || route.length === 0) {
|
|
4471
|
+
return { ok: null, why: `step ${n}'s screen can't be told apart (the app has no routes); only text can be checked.` };
|
|
4472
|
+
}
|
|
4473
|
+
const same = screen.route.join(" › ") === now;
|
|
4474
|
+
return same ? { ok: true, why: `the app is still on step ${n}'s screen (${now}).` } : { ok: false, why: `the app is on ${now}, not step ${n}'s screen (${screen.route.join(" › ")}).` };
|
|
4475
|
+
}
|
|
4476
|
+
const named = now.toLowerCase().includes(goalStep.trim().toLowerCase());
|
|
4477
|
+
return named ? { ok: true, why: `the app is still on ${now}.` } : { ok: false, why: `the app is on ${now === "" ? "a screen with no route" : now}, not ${JSON.stringify(goalStep)}.` };
|
|
4478
|
+
}
|
|
4479
|
+
/** The command and arguments that set one of a journey's conditions. */
|
|
4480
|
+
conditionCall(name, value) {
|
|
4481
|
+
switch (name) {
|
|
4482
|
+
case "network":
|
|
4483
|
+
return { command: "network", args: { mode: value } };
|
|
4484
|
+
case "language":
|
|
4485
|
+
return { command: "language", args: { language: value } };
|
|
4486
|
+
case "region":
|
|
4487
|
+
return { command: "region", args: { region: value } };
|
|
4488
|
+
case "text_size":
|
|
4489
|
+
return { command: "text_size", args: { size: value } };
|
|
4490
|
+
}
|
|
4491
|
+
}
|
|
4492
|
+
/** Runs one condition command straight on the app (a journey's own, not the agent's shortcut); the app's answer may say what it was before. */
|
|
4493
|
+
async runCondition(session, name, value) {
|
|
4494
|
+
const call = this.conditionCall(name, value);
|
|
4495
|
+
if (name === "network") {
|
|
4496
|
+
// The app that receives the condition (a journey's own, maybe not the selected one) is the one the policy is read for.
|
|
4497
|
+
const refused = this.networkRefusal(value, session.app);
|
|
4498
|
+
if (refused !== null) {
|
|
4499
|
+
const said = refused.content.map((block) => (block.type === "text" ? block.text : "")).join(" ");
|
|
4500
|
+
return { ok: false, failure: said.replace(/^Error \w+: /, "").replace(/\.$/, "") };
|
|
4501
|
+
}
|
|
4502
|
+
}
|
|
4503
|
+
try {
|
|
4504
|
+
const raw = await this.host.request("command", { name: call.command, args: call.args }, { sessionId: session.id });
|
|
4505
|
+
const inner = typeof raw === "object" && raw !== null && "result" in raw ? raw.result : undefined;
|
|
4506
|
+
const previous = typeof inner === "object" && inner !== null && "previous" in inner ? inner.previous : undefined;
|
|
4507
|
+
return { ok: true, ...(typeof previous === "string" && previous !== "" ? { previous } : {}) };
|
|
4508
|
+
}
|
|
4509
|
+
catch (error) {
|
|
4510
|
+
return { ok: false, failure: describeError(error).replace(/^Error \w+: /, "").replace(/\.$/, "") };
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4513
|
+
async applyCondition(session, name, value) {
|
|
4514
|
+
const done = await this.runCondition(session, name, value);
|
|
4515
|
+
if (!done.ok)
|
|
4516
|
+
return { name, value, applied: false, failure: done.failure };
|
|
4517
|
+
return { name, value, applied: true, ...(done.previous !== undefined ? { previous: done.previous } : {}) };
|
|
4518
|
+
}
|
|
4519
|
+
/** At the stop: network goes back to normal, text size to the phone's own, language and region to what the app said they were. */
|
|
4520
|
+
async undoConditions(state) {
|
|
4521
|
+
const session = this.journeySession();
|
|
4522
|
+
for (const record of state.conditionRecords) {
|
|
4523
|
+
if (!record.applied)
|
|
4524
|
+
continue;
|
|
4525
|
+
const words = record.name.replace("_", " ");
|
|
4526
|
+
if ((record.name === "language" || record.name === "region") && record.previous === undefined) {
|
|
4527
|
+
record.undone = false;
|
|
4528
|
+
record.undo = `Note: ${words} ${record.value} was not restored: the app doesn't report what it was before.`;
|
|
4529
|
+
continue;
|
|
4530
|
+
}
|
|
4531
|
+
if (session === undefined) {
|
|
4532
|
+
record.undone = false;
|
|
4533
|
+
record.undo = `Note: ${words} ${record.value} was not undone: ${this.journeyAppGone()}.`;
|
|
4534
|
+
continue;
|
|
4535
|
+
}
|
|
4536
|
+
const back = record.name === "network" ? "normal" : record.name === "text_size" ? "system" : (record.previous ?? "");
|
|
4537
|
+
const done = await this.runCondition(session, record.name, back);
|
|
4538
|
+
record.undone = done.ok;
|
|
4539
|
+
record.undo = done.ok
|
|
4540
|
+
? record.name === "text_size"
|
|
4541
|
+
? "Undone: text size is the phone's own again."
|
|
4542
|
+
: `Undone: ${words} is ${back} again.`
|
|
4543
|
+
: `Note: ${words} ${record.value} was not undone: ${done.failure}.`;
|
|
4544
|
+
}
|
|
4545
|
+
}
|
|
3499
4546
|
async wait(args) {
|
|
3500
4547
|
const session = this.host.current();
|
|
3501
4548
|
if (session === undefined)
|