@validate.qa/runner 1.0.37 → 1.0.39

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.
Files changed (3) hide show
  1. package/dist/cli.js +1341 -706
  2. package/dist/cli.mjs +1341 -706
  3. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -246,6 +246,9 @@ var require_ai_provider = __commonJS({
246
246
  exports.getAvailableModels = getAvailableModels;
247
247
  exports.getAvailableOrchestratorModels = getAvailableOrchestratorModels;
248
248
  exports.isAvailableModelId = isAvailableModelId;
249
+ exports.resolveWhisperProvider = resolveWhisperProvider;
250
+ exports.isWhisperConfigured = isWhisperConfigured;
251
+ exports.getWhisperModel = getWhisperModel;
249
252
  exports.getOpenAIWhisperClient = getOpenAIWhisperClient;
250
253
  exports.resetAIClient = resetAIClient;
251
254
  exports.getServerProxiedAIClient = getServerProxiedAIClient;
@@ -575,20 +578,70 @@ var require_ai_provider = __commonJS({
575
578
  function isAvailableModelId(modelId) {
576
579
  return getAvailableModels().some((model) => model.id === modelId);
577
580
  }
581
+ var GROQ_API_BASE_DEFAULT = "https://api.groq.com/openai/v1";
582
+ var GROQ_WHISPER_DEFAULT = "whisper-large-v3-turbo";
583
+ var OPENAI_WHISPER_DEFAULT = "whisper-1";
584
+ function isUsableApiKey(value) {
585
+ if (!value)
586
+ return false;
587
+ const trimmed = value.trim();
588
+ if (!trimmed)
589
+ return false;
590
+ if (/your[-_]?.*(key|api|secret)/i.test(trimmed))
591
+ return false;
592
+ if (trimmed === "sk-..." || trimmed === "gsk_...")
593
+ return false;
594
+ return true;
595
+ }
596
+ function resolveWhisperProvider() {
597
+ const forced = (process.env.STT_PROVIDER ?? process.env.WHISPER_PROVIDER ?? "").trim().toLowerCase();
598
+ const hasGroq = isUsableApiKey(process.env.GROQ_API_KEY);
599
+ const hasOpenAI = isUsableApiKey(process.env.OPENAI_API_KEY);
600
+ if (forced === "groq")
601
+ return hasGroq ? "groq" : null;
602
+ if (forced === "openai")
603
+ return hasOpenAI ? "openai" : null;
604
+ if (hasGroq)
605
+ return "groq";
606
+ if (hasOpenAI)
607
+ return "openai";
608
+ return null;
609
+ }
610
+ function isWhisperConfigured() {
611
+ return resolveWhisperProvider() !== null;
612
+ }
613
+ function getWhisperModel(provider) {
614
+ const p = provider ?? resolveWhisperProvider();
615
+ if (p === "groq") {
616
+ return process.env.GROQ_WHISPER_MODEL?.trim() || process.env.OPENAI_WHISPER_MODEL?.trim() || GROQ_WHISPER_DEFAULT;
617
+ }
618
+ return process.env.OPENAI_WHISPER_MODEL?.trim() || OPENAI_WHISPER_DEFAULT;
619
+ }
578
620
  var _whisperClient = null;
621
+ var _whisperClientProvider = null;
579
622
  function getOpenAIWhisperClient() {
580
- if (_whisperClient)
623
+ const provider = resolveWhisperProvider();
624
+ if (!provider) {
625
+ throw new Error("Speech transcription is not configured (set GROQ_API_KEY or OPENAI_API_KEY)");
626
+ }
627
+ if (_whisperClient && _whisperClientProvider === provider) {
581
628
  return _whisperClient;
582
- const apiKey = process.env.OPENAI_API_KEY;
583
- if (!apiKey)
584
- throw new Error("OPENAI_API_KEY is required for Whisper transcription");
585
- _whisperClient = new openai_1.default({ apiKey });
629
+ }
630
+ if (provider === "groq") {
631
+ const apiKey = process.env.GROQ_API_KEY;
632
+ const baseURL = process.env.GROQ_API_BASE?.trim() || GROQ_API_BASE_DEFAULT;
633
+ _whisperClient = new openai_1.default({ apiKey, baseURL });
634
+ } else {
635
+ _whisperClient = new openai_1.default({ apiKey: process.env.OPENAI_API_KEY });
636
+ }
637
+ _whisperClientProvider = provider;
586
638
  return _whisperClient;
587
639
  }
588
640
  var _serverProxiedClient = null;
589
641
  function resetAIClient() {
590
642
  _clientCache.clear();
591
643
  _whisperClient = null;
644
+ _whisperClientProvider = null;
592
645
  _serverProxiedClient = null;
593
646
  }
594
647
  function buildServerProxiedClient() {
@@ -1687,6 +1740,7 @@ var init_constants = __esm({
1687
1740
  "DATABASE_URL",
1688
1741
  "JWT_SECRET",
1689
1742
  "OPENAI_API_KEY",
1743
+ "GROQ_API_KEY",
1690
1744
  "GEMINI_API_KEY",
1691
1745
  "XAI_API_KEY",
1692
1746
  "GROK_API_KEY",
@@ -3253,8 +3307,10 @@ var init_audit_pricing = __esm({
3253
3307
  "MiniMax-M2.7-highspeed": { input: 0.6, output: 2.4 },
3254
3308
  "MiniMax-M2": { input: 0.3, output: 1.2 },
3255
3309
  "deepseek-v4-flash": { input: 0.14, output: 0.28 },
3256
- // Whisper bills per-minute, not per-token
3310
+ // Whisper bills per-minute, not per-token (OpenAI + Groq)
3257
3311
  "whisper-1": null,
3312
+ "whisper-large-v3": null,
3313
+ "whisper-large-v3-turbo": null,
3258
3314
  // Historical / deprecated (kept so old audit rows still show a cost).
3259
3315
  // Deprecated Grok variants priced at the legacy authoritative Grok rate
3260
3316
  // ($0.20/$0.80) those rows were generated under.
@@ -3865,6 +3921,43 @@ function assertedText(stmt) {
3865
3921
  }
3866
3922
  return out.join(" ").toLowerCase();
3867
3923
  }
3924
+ function clickedAdditiveAffordance(lines) {
3925
+ const nameRe = /name\s*:\s*(?:['"`]([^'"`]+)['"`]|\/([^/]+)\/)/g;
3926
+ for (const ln of lines) {
3927
+ if (!/\.click\s*\(/.test(ln))
3928
+ continue;
3929
+ nameRe.lastIndex = 0;
3930
+ let m;
3931
+ while (m = nameRe.exec(ln)) {
3932
+ const name = (m[1] ?? m[2]).toLowerCase();
3933
+ if (EXCLUDE_AFFORDANCE.test(name))
3934
+ continue;
3935
+ if (ADDITIVE_AFFORDANCE.test(name))
3936
+ return true;
3937
+ }
3938
+ }
3939
+ return false;
3940
+ }
3941
+ function matcherText(stmt) {
3942
+ const out = [];
3943
+ const re = /\.(?:toContainText|toHaveText)\s*\(\s*(?:['"`]([^'"`]+)['"`]|\/([^/]+)\/)/g;
3944
+ let m;
3945
+ while (m = re.exec(stmt))
3946
+ out.push(m[1] ?? m[2]);
3947
+ return out.join(" ").toLowerCase();
3948
+ }
3949
+ function isEmptyStateProof(stmt) {
3950
+ if (NEGATED_ASSERT.test(stmt))
3951
+ return false;
3952
+ if (/\.toHaveCount\s*\(\s*0\s*\)/.test(stmt))
3953
+ return true;
3954
+ if (/\.toBe\s*\(\s*0\s*\)/.test(stmt) && !isChromeTarget(stmt))
3955
+ return true;
3956
+ if (VISIBILITY_MATCHER.test(stmt) || TEXT_CONTENT_MATCHER.test(stmt)) {
3957
+ return EMPTY_STATE_TEXT.test(`${assertedText(stmt)} ${matcherText(stmt)}`);
3958
+ }
3959
+ return false;
3960
+ }
3868
3961
  function hasAttributeTransition(stmts) {
3869
3962
  const byAttr = /* @__PURE__ */ new Map();
3870
3963
  for (const { stmt } of stmts) {
@@ -3971,6 +4064,21 @@ function validateAssertionStrength(code, opts = {}) {
3971
4064
  const interacted = mutated || navClicks > 0;
3972
4065
  navOnly = !mutated && navClicks > 0;
3973
4066
  const reloaded = RELOAD.test(body);
4067
+ if (opts.flagContradictedAdditions && mutated && clickedAdditiveAffordance(interactionStmts)) {
4068
+ const empties = stmts.filter(({ stmt }) => isEmptyStateProof(stmt));
4069
+ if (empties.length > 0) {
4070
+ const others = stmts.filter(({ stmt }) => !isEmptyStateProof(stmt));
4071
+ const provesSomethingElse = detectOutcomeProof(others, mutated, false, clickedControlNames(interactionStmts), typedValues(interactionStmts));
4072
+ if (!provesSomethingElse) {
4073
+ return {
4074
+ ok: false,
4075
+ severity: "block",
4076
+ reason: "additive action (add/create/save/favourite) proves no positive outcome \u2014 its only asserted result is that the target collection is still empty; hold @needs-review, do not ship as a passing regression",
4077
+ signals: base2({ interacted, mutated, terminal })
4078
+ };
4079
+ }
4080
+ }
4081
+ }
3974
4082
  const hasOutcomeProof = detectOutcomeProof(stmts, mutated, reloaded, clickedControlNames(interactionStmts), typedValues(interactionStmts));
3975
4083
  const signals = base2({ interacted, mutated, navOnly, hasOutcomeProof, terminal });
3976
4084
  if (hasOutcomeProof) {
@@ -4025,7 +4133,7 @@ function validateAssertionStrength(code, opts = {}) {
4025
4133
  signals
4026
4134
  };
4027
4135
  }
4028
- var ERROR_BANNER, OUTCOME_ALWAYS, FORM_INPUT, ANY_CLICK, NAV_LOCATOR, URL_PROOF, RELOAD, EXPECT_STMT, BLOCK_SCAFFOLD, LOCATOR_BINDING, STATE_MATCHER, CHROME_LOCATOR, CONTENT_ROLE, GET_BY_TEXT, HAS_TEXT_FILTER, OUTCOME_TEXT, MESSAGE_SURFACE, TEXT_CONTENT_MATCHER, VISIBILITY_MATCHER, AUTH_START, AUTH_END, LOGIN_CALL_LINE, NEGATED_ASSERT, TYPED_INPUT, TITLE_LINE, ROLE_NAME_RE, INTERACT_VERB, ROLE_SIG_RE, TRANSIENT_CONTROL_ROLES;
4136
+ var ERROR_BANNER, OUTCOME_ALWAYS, FORM_INPUT, ANY_CLICK, NAV_LOCATOR, URL_PROOF, RELOAD, EXPECT_STMT, BLOCK_SCAFFOLD, LOCATOR_BINDING, STATE_MATCHER, CHROME_LOCATOR, CONTENT_ROLE, GET_BY_TEXT, HAS_TEXT_FILTER, OUTCOME_TEXT, MESSAGE_SURFACE, TEXT_CONTENT_MATCHER, VISIBILITY_MATCHER, ADDITIVE_AFFORDANCE, EXCLUDE_AFFORDANCE, EMPTY_STATE_TEXT, AUTH_START, AUTH_END, LOGIN_CALL_LINE, NEGATED_ASSERT, TYPED_INPUT, TITLE_LINE, ROLE_NAME_RE, INTERACT_VERB, ROLE_SIG_RE, TRANSIENT_CONTROL_ROLES;
4029
4137
  var init_assertion_strength = __esm({
4030
4138
  "../shared/dist/assertion-strength.js"() {
4031
4139
  "use strict";
@@ -4069,6 +4177,9 @@ var init_assertion_strength = __esm({
4069
4177
  MESSAGE_SURFACE = /getByText\s*\(|\.filter\s*\(\s*\{\s*hasText\s*:|getByRole\s*\(\s*['"`](?:alert|alertdialog|status|heading|dialog)['"`]/i;
4070
4178
  TEXT_CONTENT_MATCHER = /\.(toHaveText|toContainText)\s*\(/;
4071
4179
  VISIBILITY_MATCHER = /\.toBeVisible\s*\(/;
4180
+ ADDITIVE_AFFORDANCE = /\b(add|adds|added|create|creates|save|saves|favou?rite|favou?rites|invite|invites|upload|uploads|insert|append|bookmark|follow|subscribe|post|posts|new)\b/i;
4181
+ EXCLUDE_AFFORDANCE = /\b(filter|filters|search|searches|sort|view|views|tab|tabs|window|windows|toggle|expand|collapse|column|columns|zoom|layout)\b/i;
4182
+ EMPTY_STATE_TEXT = /\bno\b[\s\S]{0,24}?\b(yet|found|results?|items?|favou?rites?|entries|records?|matches|posts?|comments?)\b|nothing (?:here|yet|to show|found)|\bempty\b|\(\s*0\s*\)|\b0\s+(?:favou?rites?|results?|items?|entries|records?|matches|posts?|comments?)\b/i;
4072
4183
  AUTH_START = /^\s*\/\/\s*>>>\s*VALIDATE_QA_AUTH/;
4073
4184
  AUTH_END = /^\s*\/\/\s*<<<\s*VALIDATE_QA_AUTH/;
4074
4185
  LOGIN_CALL_LINE = /^\s*await\s+__vqaLogin\s*\(\s*page\s*\)\s*;?\s*$/;
@@ -4091,6 +4202,24 @@ var init_assertion_strength = __esm({
4091
4202
  });
4092
4203
 
4093
4204
  // ../shared/dist/run-classification.js
4205
+ function pickLatestHealthRun(runs) {
4206
+ for (const run2 of runs) {
4207
+ if (run2.mode === "auth-setup")
4208
+ continue;
4209
+ if (run2.status === "PASSED" || run2.status === "FAILED" || run2.status === "ERROR") {
4210
+ return run2;
4211
+ }
4212
+ }
4213
+ return null;
4214
+ }
4215
+ function pickDisplayRun(runs) {
4216
+ if (runs.length === 0)
4217
+ return null;
4218
+ const latest = runs[0];
4219
+ if (latest.status === "PENDING" || latest.status === "RUNNING")
4220
+ return latest;
4221
+ return pickLatestHealthRun(runs) ?? latest;
4222
+ }
4094
4223
  function isEnvironmentalError(run2) {
4095
4224
  if (!run2)
4096
4225
  return false;
@@ -4552,6 +4681,262 @@ var init_origin_domain = __esm({
4552
4681
  }
4553
4682
  });
4554
4683
 
4684
+ // ../shared/dist/voice-config.js
4685
+ function normalizeTtsVoiceId(voiceId) {
4686
+ if (!voiceId)
4687
+ return null;
4688
+ const trimmed = voiceId.trim();
4689
+ if (!trimmed)
4690
+ return null;
4691
+ return LEGACY_TTS_VOICE_ID_MAP[trimmed] ?? trimmed;
4692
+ }
4693
+ function sttModelsForProvider(provider) {
4694
+ if (provider === "openai") {
4695
+ return STT_MODEL_OPTIONS.filter((m) => m.provider === "openai");
4696
+ }
4697
+ if (provider === "groq") {
4698
+ return STT_MODEL_OPTIONS.filter((m) => m.provider === "groq");
4699
+ }
4700
+ return STT_MODEL_OPTIONS;
4701
+ }
4702
+ function isValidSttProvider(value) {
4703
+ return value === "groq" || value === "openai";
4704
+ }
4705
+ function isSttModelForProvider(modelId, provider) {
4706
+ if (!modelId)
4707
+ return false;
4708
+ return STT_MODEL_OPTIONS.some((m) => m.id === modelId && m.provider === provider);
4709
+ }
4710
+ function resolveSttModelForProvider(provider, configuredModel) {
4711
+ if (configuredModel && isSttModelForProvider(configuredModel, provider)) {
4712
+ return configuredModel;
4713
+ }
4714
+ return DEFAULT_STT_MODEL_BY_PROVIDER[provider];
4715
+ }
4716
+ function sanitizeSttErrorMessage(raw) {
4717
+ if (!raw)
4718
+ return "Speech transcription failed.";
4719
+ let msg = String(raw).replace(/\b(sk-proj-|sk-|gsk_|sk-svcacct-)[A-Za-z0-9_-]{8,}/gi, "$1***").replace(/Incorrect API key provided:\s*\S+/gi, "Incorrect API key provided").replace(/You can find your API key at https?:\/\/\S+/gi, "").replace(/\s+/g, " ").trim();
4720
+ if (/401|incorrect api key|invalid.?api.?key|authentication|unauthorized/i.test(msg)) {
4721
+ return "Speech transcription failed: the speech API key was rejected. Prefer GROQ_API_KEY (same as Copilot voice) or a valid OPENAI_API_KEY on the org server.";
4722
+ }
4723
+ if (/429|rate.?limit|too many requests/i.test(msg)) {
4724
+ return "Speech transcription failed: the speech provider rate-limited the request. Try again in a moment.";
4725
+ }
4726
+ if (/not configured|GROQ_API_KEY or OPENAI_API_KEY/i.test(msg)) {
4727
+ return "Speech transcription is not configured. Set GROQ_API_KEY (preferred) or OPENAI_API_KEY on the org server.";
4728
+ }
4729
+ if (msg.length > 280)
4730
+ msg = `${msg.slice(0, 277)}\u2026`;
4731
+ return msg || "Speech transcription failed.";
4732
+ }
4733
+ function isValidTtsVoiceId(value) {
4734
+ const normalized = normalizeTtsVoiceId(value);
4735
+ if (!normalized)
4736
+ return false;
4737
+ return TTS_VOICE_OPTIONS.some((v) => v.id === normalized);
4738
+ }
4739
+ function isValidTtsModel(value) {
4740
+ if (!value)
4741
+ return false;
4742
+ return TTS_MODEL_OPTIONS.some((m) => m.id === value);
4743
+ }
4744
+ var VOICE_PREVIEW_SAMPLE, DEFAULT_TTS_MODEL, DEFAULT_TTS_VOICE_ID, DEFAULT_TTS_SPEED, LEGACY_TTS_VOICE_ID_MAP, DEFAULT_STT_MODEL_BY_PROVIDER, STT_PROVIDER_OPTIONS, STT_MODEL_OPTIONS, TTS_MODEL_OPTIONS, TTS_VOICE_OPTIONS;
4745
+ var init_voice_config = __esm({
4746
+ "../shared/dist/voice-config.js"() {
4747
+ "use strict";
4748
+ VOICE_PREVIEW_SAMPLE = "Hello! I am your Validate Copilot assistant. I can help you run tests, review failures, and generate new coverage for your project.";
4749
+ DEFAULT_TTS_MODEL = "speech-2.6-hd";
4750
+ DEFAULT_TTS_VOICE_ID = "English_Graceful_Lady";
4751
+ DEFAULT_TTS_SPEED = 1;
4752
+ LEGACY_TTS_VOICE_ID_MAP = {
4753
+ English_LivelyGirl: "English_PlayfulGirl",
4754
+ English_Insightful_Man: "English_Insightful_Speaker",
4755
+ English_Deep_Voice_Man: "English_ManWithDeepVoice",
4756
+ English_CasualMan: "English_Jovialman"
4757
+ };
4758
+ DEFAULT_STT_MODEL_BY_PROVIDER = {
4759
+ groq: "whisper-large-v3-turbo",
4760
+ openai: "whisper-1"
4761
+ };
4762
+ STT_PROVIDER_OPTIONS = [
4763
+ {
4764
+ id: "auto",
4765
+ label: "Auto (prefer Groq)",
4766
+ description: "Use Groq when GROQ_API_KEY is set, otherwise OpenAI Whisper."
4767
+ },
4768
+ {
4769
+ id: "groq",
4770
+ label: "Groq Whisper",
4771
+ description: "Free-tier OpenAI-compatible Whisper via Groq. Requires GROQ_API_KEY on org servers."
4772
+ },
4773
+ {
4774
+ id: "openai",
4775
+ label: "OpenAI Whisper",
4776
+ description: "OpenAI whisper-1 API. Requires OPENAI_API_KEY on org servers."
4777
+ }
4778
+ ];
4779
+ STT_MODEL_OPTIONS = [
4780
+ {
4781
+ id: "whisper-large-v3-turbo",
4782
+ label: "Whisper Large V3 Turbo",
4783
+ provider: "groq",
4784
+ description: "Fast, accurate default for Groq (recommended)."
4785
+ },
4786
+ {
4787
+ id: "whisper-large-v3",
4788
+ label: "Whisper Large V3",
4789
+ provider: "groq",
4790
+ description: "Full-size Groq Whisper model \u2014 higher quality, slower."
4791
+ },
4792
+ {
4793
+ id: "distil-whisper-large-v3-en",
4794
+ label: "Distil Whisper Large V3 (EN)",
4795
+ provider: "groq",
4796
+ description: "English-optimized distilled model \u2014 fastest Groq option."
4797
+ },
4798
+ {
4799
+ id: "whisper-1",
4800
+ label: "Whisper 1",
4801
+ provider: "openai",
4802
+ description: "OpenAI hosted Whisper model."
4803
+ }
4804
+ ];
4805
+ TTS_MODEL_OPTIONS = [
4806
+ {
4807
+ id: "speech-2.8-hd",
4808
+ label: "Speech 2.8 HD",
4809
+ provider: "minimax",
4810
+ description: "Latest high-definition model with interjection tags (recommended)."
4811
+ },
4812
+ {
4813
+ id: "speech-2.8-turbo",
4814
+ label: "Speech 2.8 Turbo",
4815
+ provider: "minimax",
4816
+ description: "Latest low-latency model with interjection tags."
4817
+ },
4818
+ {
4819
+ id: "speech-2.6-hd",
4820
+ label: "Speech 2.6 HD",
4821
+ provider: "minimax",
4822
+ description: "High-definition MiniMax voice model."
4823
+ },
4824
+ {
4825
+ id: "speech-2.6-turbo",
4826
+ label: "Speech 2.6 Turbo",
4827
+ provider: "minimax",
4828
+ description: "Lower-latency Speech 2.6 variant."
4829
+ },
4830
+ {
4831
+ id: "speech-02-hd",
4832
+ label: "Speech 02 HD",
4833
+ provider: "minimax",
4834
+ description: "Previous-generation HD model."
4835
+ },
4836
+ {
4837
+ id: "speech-02-turbo",
4838
+ label: "Speech 02 Turbo",
4839
+ provider: "minimax",
4840
+ description: "Lower-latency Speech 02 variant."
4841
+ },
4842
+ {
4843
+ id: "speech-01-hd",
4844
+ label: "Speech 01 HD",
4845
+ provider: "minimax",
4846
+ description: "Legacy HD model."
4847
+ },
4848
+ {
4849
+ id: "speech-01-turbo",
4850
+ label: "Speech 01 Turbo",
4851
+ provider: "minimax",
4852
+ description: "Legacy fast model."
4853
+ }
4854
+ ];
4855
+ TTS_VOICE_OPTIONS = [
4856
+ {
4857
+ id: "English_Graceful_Lady",
4858
+ label: "Graceful Lady",
4859
+ description: "Warm, polished professional tone.",
4860
+ gender: "female",
4861
+ accent: "English",
4862
+ sampleText: VOICE_PREVIEW_SAMPLE
4863
+ },
4864
+ {
4865
+ id: "English_ConfidentWoman",
4866
+ label: "Confident Woman",
4867
+ description: "Assured and direct \u2014 good for technical guidance.",
4868
+ gender: "female",
4869
+ accent: "English",
4870
+ sampleText: VOICE_PREVIEW_SAMPLE
4871
+ },
4872
+ {
4873
+ id: "English_SereneWoman",
4874
+ label: "Serene Woman",
4875
+ description: "Calm and measured delivery.",
4876
+ gender: "female",
4877
+ accent: "English",
4878
+ sampleText: VOICE_PREVIEW_SAMPLE
4879
+ },
4880
+ {
4881
+ id: "English_PlayfulGirl",
4882
+ label: "Playful Girl",
4883
+ description: "Upbeat and energetic.",
4884
+ gender: "female",
4885
+ accent: "English",
4886
+ sampleText: VOICE_PREVIEW_SAMPLE
4887
+ },
4888
+ {
4889
+ id: "English_CalmWoman",
4890
+ label: "Calm Woman",
4891
+ description: "Soft, reassuring cadence.",
4892
+ gender: "female",
4893
+ accent: "English",
4894
+ sampleText: VOICE_PREVIEW_SAMPLE
4895
+ },
4896
+ {
4897
+ id: "English_Insightful_Speaker",
4898
+ label: "Insightful Speaker",
4899
+ description: "Clear analyst-style narration.",
4900
+ gender: "male",
4901
+ accent: "English",
4902
+ sampleText: VOICE_PREVIEW_SAMPLE
4903
+ },
4904
+ {
4905
+ id: "English_ManWithDeepVoice",
4906
+ label: "Deep Voice Man",
4907
+ description: "Rich, authoritative baritone.",
4908
+ gender: "male",
4909
+ accent: "English",
4910
+ sampleText: VOICE_PREVIEW_SAMPLE
4911
+ },
4912
+ {
4913
+ id: "English_Jovialman",
4914
+ label: "Jovial Man",
4915
+ description: "Relaxed conversational style.",
4916
+ gender: "male",
4917
+ accent: "English",
4918
+ sampleText: VOICE_PREVIEW_SAMPLE
4919
+ },
4920
+ {
4921
+ id: "English_PatientMan",
4922
+ label: "Patient Man",
4923
+ description: "Steady, tutorial-friendly pacing.",
4924
+ gender: "male",
4925
+ accent: "English",
4926
+ sampleText: VOICE_PREVIEW_SAMPLE
4927
+ },
4928
+ {
4929
+ id: "English_FriendlyPerson",
4930
+ label: "Friendly Person",
4931
+ description: "Neutral, approachable assistant voice.",
4932
+ gender: "neutral",
4933
+ accent: "English",
4934
+ sampleText: VOICE_PREVIEW_SAMPLE
4935
+ }
4936
+ ];
4937
+ }
4938
+ });
4939
+
4555
4940
  // ../shared/dist/index.js
4556
4941
  var dist_exports = {};
4557
4942
  __export(dist_exports, {
@@ -4566,8 +4951,13 @@ __export(dist_exports, {
4566
4951
  CREDIT_PRICES_CENTS: () => CREDIT_PRICES_CENTS,
4567
4952
  DANGEROUS_DEEPLINK_SCHEMES: () => DANGEROUS_DEEPLINK_SCHEMES,
4568
4953
  DB_PRICING_PLAN_MAP: () => DB_PRICING_PLAN_MAP,
4954
+ DEFAULT_STT_MODEL_BY_PROVIDER: () => DEFAULT_STT_MODEL_BY_PROVIDER,
4955
+ DEFAULT_TTS_MODEL: () => DEFAULT_TTS_MODEL,
4956
+ DEFAULT_TTS_SPEED: () => DEFAULT_TTS_SPEED,
4957
+ DEFAULT_TTS_VOICE_ID: () => DEFAULT_TTS_VOICE_ID,
4569
4958
  DEFAULT_UI_VARIANT: () => DEFAULT_UI_VARIANT,
4570
4959
  ENVIRONMENTAL_ERROR_PREFIXES: () => ENVIRONMENTAL_ERROR_PREFIXES,
4960
+ LEGACY_TTS_VOICE_ID_MAP: () => LEGACY_TTS_VOICE_ID_MAP,
4571
4961
  MAX_CREDIT_TOPUP_CENTS: () => MAX_CREDIT_TOPUP_CENTS,
4572
4962
  MAX_DEEPLINK_LENGTH: () => MAX_DEEPLINK_LENGTH,
4573
4963
  MIN_CREDIT_TOPUP_CENTS: () => MIN_CREDIT_TOPUP_CENTS,
@@ -4589,10 +4979,15 @@ __export(dist_exports, {
4589
4979
  PLATFORM_ADMIN_EMAIL: () => PLATFORM_ADMIN_EMAIL,
4590
4980
  SELECTOR_PRIORITIES: () => SELECTOR_PRIORITIES,
4591
4981
  SHARED_PRICING_PLAN_MAP: () => SHARED_PRICING_PLAN_MAP,
4982
+ STT_MODEL_OPTIONS: () => STT_MODEL_OPTIONS,
4983
+ STT_PROVIDER_OPTIONS: () => STT_PROVIDER_OPTIONS,
4592
4984
  SUPPORT_EMAIL: () => SUPPORT_EMAIL,
4593
4985
  TRANSIENT_CONTROL_ROLES: () => TRANSIENT_CONTROL_ROLES,
4594
4986
  TRIAL_DURATION_DAYS: () => TRIAL_DURATION_DAYS,
4987
+ TTS_MODEL_OPTIONS: () => TTS_MODEL_OPTIONS,
4988
+ TTS_VOICE_OPTIONS: () => TTS_VOICE_OPTIONS,
4595
4989
  UI_VARIANTS: () => UI_VARIANTS,
4990
+ VOICE_PREVIEW_SAMPLE: () => VOICE_PREVIEW_SAMPLE,
4596
4991
  accumulateStatements: () => accumulateStatements,
4597
4992
  aiCostToCreditCents: () => aiCostToCreditCents,
4598
4993
  analyzeMobileSelectorQuality: () => analyzeMobileSelectorQuality,
@@ -4655,16 +5050,23 @@ __export(dist_exports, {
4655
5050
  isRunnableStep: () => isRunnableStep,
4656
5051
  isSafeDeepLink: () => isSafeDeepLink,
4657
5052
  isSameRegistrableDomainHost: () => isSameRegistrableDomainHost,
5053
+ isSttModelForProvider: () => isSttModelForProvider,
4658
5054
  isTestReadyForPlatform: () => isTestReadyForPlatform,
4659
5055
  isTrialExpired: () => isTrialExpired,
4660
5056
  isUIVariant: () => isUIVariant,
4661
5057
  isValidMobileAppId: () => isValidMobileAppId,
5058
+ isValidSttProvider: () => isValidSttProvider,
5059
+ isValidTtsModel: () => isValidTtsModel,
5060
+ isValidTtsVoiceId: () => isValidTtsVoiceId,
4662
5061
  mediumForMobilePlatform: () => mediumForMobilePlatform,
4663
5062
  mobileCredentialPlaceholder: () => mobileCredentialPlaceholder,
4664
5063
  mobilePlatformFromMedium: () => mobilePlatformFromMedium,
4665
5064
  normalizeControlName: () => normalizeControlName,
5065
+ normalizeTtsVoiceId: () => normalizeTtsVoiceId,
4666
5066
  otherMobilePlatform: () => otherMobilePlatform,
4667
5067
  parenDepthDelta: () => parenDepthDelta,
5068
+ pickDisplayRun: () => pickDisplayRun,
5069
+ pickLatestHealthRun: () => pickLatestHealthRun,
4668
5070
  placeholderizeRecordedMobileAction: () => placeholderizeRecordedMobileAction,
4669
5071
  placeholderizeRecordedTypeValue: () => placeholderizeRecordedTypeValue,
4670
5072
  planHasBillingPortal: () => planHasBillingPortal,
@@ -4674,7 +5076,9 @@ __export(dist_exports, {
4674
5076
  resolveCrossOriginConfigOverride: () => resolveCrossOriginConfigOverride,
4675
5077
  resolveMobileStepValuePlaceholders: () => resolveMobileStepValuePlaceholders,
4676
5078
  resolvePlatformLocatorBundle: () => resolvePlatformLocatorBundle,
5079
+ resolveSttModelForProvider: () => resolveSttModelForProvider,
4677
5080
  safeOrigin: () => safeOrigin,
5081
+ sanitizeSttErrorMessage: () => sanitizeSttErrorMessage,
4678
5082
  sanitizeTestVariables: () => sanitizeTestVariables,
4679
5083
  selectorCandidatesForStep: () => selectorCandidatesForStep,
4680
5084
  selectorForValue: () => selectorForValue,
@@ -4684,6 +5088,7 @@ __export(dist_exports, {
4684
5088
  stepsNeedingReviewForPlatform: () => stepsNeedingReviewForPlatform,
4685
5089
  stripEnvironmentalPrefix: () => stripEnvironmentalPrefix,
4686
5090
  stripLoginScaffolding: () => stripLoginScaffolding,
5091
+ sttModelsForProvider: () => sttModelsForProvider,
4687
5092
  summarizeHealth: () => summarizeHealth,
4688
5093
  summarizeMobileReadiness: () => summarizeMobileReadiness,
4689
5094
  summarizeRunHealth: () => summarizeRunHealth,
@@ -4708,6 +5113,7 @@ var init_dist = __esm({
4708
5113
  init_agent_env();
4709
5114
  init_test_counting();
4710
5115
  init_origin_domain();
5116
+ init_voice_config();
4711
5117
  }
4712
5118
  });
4713
5119
 
@@ -5812,6 +6218,689 @@ var require_mobile_observation = __commonJS({
5812
6218
  }
5813
6219
  });
5814
6220
 
6221
+ // ../runner-core/dist/services/mobile/mobile-exploration-state.js
6222
+ var require_mobile_exploration_state = __commonJS({
6223
+ "../runner-core/dist/services/mobile/mobile-exploration-state.js"(exports) {
6224
+ "use strict";
6225
+ Object.defineProperty(exports, "__esModule", { value: true });
6226
+ exports.MobileExplorationState = void 0;
6227
+ exports.isDestructiveActionLabel = isDestructiveActionLabel;
6228
+ exports.isDestructiveConfirmationInput = isDestructiveConfirmationInput;
6229
+ exports.stableMobileControlIdentity = stableMobileControlIdentity;
6230
+ exports.classifySafeMobileNavigationControl = classifySafeMobileNavigationControl;
6231
+ exports.deriveScreenId = deriveScreenId;
6232
+ var mobile_observation_js_1 = require_mobile_observation();
6233
+ var DEFAULT_STALE_THRESHOLD = 4;
6234
+ var MAX_SCREEN_OBSERVATIONS = 50;
6235
+ var EDITABLE_CONTROL_ROLE_RE = /(?:android\.widget\.(?:EditText|AutoCompleteTextView)|XCUIElementType(?:TextField|SecureTextField|SearchField|TextView)|textbox|searchbox|combobox|textarea|select)$/i;
6236
+ var STATEFUL_CONTROL_ROLE_RE = /(?:Switch|CheckBox|Checkbox|RadioButton|radio|Slider|SeekBar|Stepper|PickerWheel|Toggle)$/i;
6237
+ var UNSAFE_OR_COMMIT_LABEL_RE = /\b(?:submit|save|confirm|delete|remove|archive|reset|disconnect|deactivate|destroy|purge|pay|purchase|buy|checkout|place\s+order|send|post|publish|upload|import|export|sign\s*out|log\s*out|logout|accept|agree|subscribe|unsubscribe|follow|unfollow|enable|disable|book|reserve|cancel|continue|next|previous|done|apply|retry|close|dismiss|sign\s*in|log\s*in|register)\b/i;
6238
+ var SAFE_MODAL_TRIGGER_LABEL_RE = /^(?:create|new|add|edit|invite|configure|manage|details|preview|share)$/i;
6239
+ var MENU_TRIGGER_LABEL_RE = /^(?:more|menu|options|actions|filter|filters|sort|columns|customize)$/i;
6240
+ var GENERIC_NAVIGATION_DESTINATION_LABEL_RE = /^(?:home|feed|dashboard|profile|settings|inbox|messages?|notifications?|projects?|about|privacy|terms|learn\s+more)$/i;
6241
+ var SYSTEM_BACK_CONTROL_LABEL_RE = /^(?:back|go\s+back|navigate\s+up|navigation\s+up|up)$/i;
6242
+ var SYSTEM_BACK_CONTROL_ID_RE = /^(?:action_bar_up|arrow_back|back_arrow|navigate_up|navigation_up|nav_up|toolbar_back|action_back|btn_back|button_back|back_button|back_btn|back_icon|icon_back)$/i;
6243
+ var DESTRUCTIVE_ACTION_LABEL_RE = /\b(?:deactivate|delete\s+account|delete\s+profile|close\s+account|cancel\s+account|remove\s+account|delete\s+(?:my\s+)?(?:data|history|everything|account)|delete\s+(?:all|forever|permanently)|permanently\s+(?:delete|remove|erase|deactivate)|erase\s+(?:all|everything|account|data)|wipe\s+(?:account|data|everything|all|device)|purge\s+(?:account|data|everything|all)|destroy\s+(?:account|data)|factory\s+reset|reset\s+(?:all\s+data|everything))\b/i;
6244
+ var DESTRUCTIVE_CONFIRMATION_INPUT_RE = /^(?:delete|delete\s+my\s+account|delete\s+account|delete\s+forever|permanently\s+delete|confirm\s+delete|erase|erase\s+all|wipe|deactivate|remove\s+account|close\s+account|yes,?\s*delete(?:\s+my\s+account)?)$/i;
6245
+ function isDestructiveActionLabel(label) {
6246
+ if (!label)
6247
+ return false;
6248
+ return DESTRUCTIVE_ACTION_LABEL_RE.test(normalizeControlIdentityPart(label));
6249
+ }
6250
+ function isDestructiveConfirmationInput(value) {
6251
+ if (!value)
6252
+ return false;
6253
+ return DESTRUCTIVE_CONFIRMATION_INPUT_RE.test(value.trim());
6254
+ }
6255
+ function normalizeControlIdentityPart(value) {
6256
+ return value.trim().replace(/\s+/g, " ").toLowerCase();
6257
+ }
6258
+ function semanticControlAlias(label) {
6259
+ return `navigation-label:${normalizeControlIdentityPart(label)}`;
6260
+ }
6261
+ function localControlCoverageIdentity(screenId, identity) {
6262
+ return `screen:${screenId}:${identity}`;
6263
+ }
6264
+ function storedMobileControlIdentity(candidate) {
6265
+ return [
6266
+ candidate.identity,
6267
+ `kind:${candidate.kind}`,
6268
+ `label:${normalizeControlIdentityPart(candidate.label)}`
6269
+ ].join(":");
6270
+ }
6271
+ function isSystemBackNavigationControl(element, label) {
6272
+ if (SYSTEM_BACK_CONTROL_LABEL_RE.test(normalizeControlIdentityPart(label)))
6273
+ return true;
6274
+ return [element.accessibilityId, element.resourceId].filter((value) => Boolean(value?.trim())).some((value) => {
6275
+ const leaf = normalizeControlIdentityPart(value).split(/[/:.]/).filter(Boolean).at(-1) ?? "";
6276
+ return SYSTEM_BACK_CONTROL_ID_RE.test(leaf);
6277
+ });
6278
+ }
6279
+ function stableMobileControlIdentity(element) {
6280
+ const role = normalizeControlIdentityPart(element.role || "control");
6281
+ const accessibilityId = element.accessibilityId?.trim();
6282
+ if (accessibilityId)
6283
+ return `a11y:${role}:${normalizeControlIdentityPart(accessibilityId)}`;
6284
+ const resourceId = element.resourceId?.trim();
6285
+ if (resourceId)
6286
+ return `resource:${role}:${normalizeControlIdentityPart(resourceId)}`;
6287
+ const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
6288
+ if (label)
6289
+ return `semantic:${role}:${normalizeControlIdentityPart(label)}`;
6290
+ return null;
6291
+ }
6292
+ function classifySafeMobileNavigationControl(element) {
6293
+ if (!element.actionable || element.enabled === false)
6294
+ return null;
6295
+ const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
6296
+ const safetyIdentity = [label, element.accessibilityId, element.resourceId].filter(Boolean).join(" ");
6297
+ if (!label || UNSAFE_OR_COMMIT_LABEL_RE.test(safetyIdentity))
6298
+ return null;
6299
+ if (isSystemBackNavigationControl(element, label))
6300
+ return null;
6301
+ const role = element.role.trim();
6302
+ if (EDITABLE_CONTROL_ROLE_RE.test(role) || STATEFUL_CONTROL_ROLE_RE.test(role))
6303
+ return null;
6304
+ const linkLike = /(?:Link$|^a$)/i.test(role);
6305
+ let kind = null;
6306
+ if (/(?:TabBarItem|\btab\b)/i.test(role))
6307
+ kind = "tab";
6308
+ else if (/menuitem/i.test(role))
6309
+ kind = "menu-item";
6310
+ else if (linkLike && element.navigationProvenance)
6311
+ kind = "link";
6312
+ else if (linkLike)
6313
+ return null;
6314
+ else if (element.hasPopup && element.hasPopup.toLowerCase() !== "false")
6315
+ kind = "menu-trigger";
6316
+ else if (element.expanded === false)
6317
+ kind = "disclosure";
6318
+ else if (MENU_TRIGGER_LABEL_RE.test(label))
6319
+ kind = "menu-trigger";
6320
+ else if (SAFE_MODAL_TRIGGER_LABEL_RE.test(label))
6321
+ kind = "modal-trigger";
6322
+ else if (element.navigationProvenance)
6323
+ kind = "navigation-button";
6324
+ else if (GENERIC_NAVIGATION_DESTINATION_LABEL_RE.test(label))
6325
+ kind = "navigation-button";
6326
+ if (!kind)
6327
+ return null;
6328
+ const identity = stableMobileControlIdentity(element);
6329
+ return identity ? { identity, label, kind } : null;
6330
+ }
6331
+ function sharedMobileControlCoverageIdentity(element, candidate, semanticFamilyId) {
6332
+ if (!element.navigationProvenance)
6333
+ return void 0;
6334
+ const durableId = element.accessibilityId?.trim() ? `a11y:${normalizeControlIdentityPart(element.accessibilityId)}` : element.resourceId?.trim() ? `resource:${normalizeControlIdentityPart(element.resourceId)}` : "";
6335
+ if (!durableId && !semanticFamilyId)
6336
+ return void 0;
6337
+ return [
6338
+ "shared-navigation",
6339
+ element.navigationProvenance,
6340
+ !durableId && semanticFamilyId ? `family:${semanticFamilyId}` : "",
6341
+ candidate.kind,
6342
+ durableId || "semantic-only",
6343
+ normalizeControlIdentityPart(candidate.label)
6344
+ ].filter(Boolean).join(":");
6345
+ }
6346
+ function mobileNavigationRegion(element, windowSize) {
6347
+ if (!windowSize || windowSize.width <= 0 || windowSize.height <= 0)
6348
+ return "unknown";
6349
+ const centerX = (element.bounds.x + element.bounds.width / 2) / windowSize.width;
6350
+ const centerY = (element.bounds.y + element.bounds.height / 2) / windowSize.height;
6351
+ if (centerY >= 0.72)
6352
+ return "bottom";
6353
+ if (centerY <= 0.28)
6354
+ return "top";
6355
+ if (centerX <= 0.35)
6356
+ return "left";
6357
+ if (centerX >= 0.65)
6358
+ return "right";
6359
+ return "center";
6360
+ }
6361
+ function buildSemanticNavigationGroups(elements, windowSize) {
6362
+ const groups = /* @__PURE__ */ new Map();
6363
+ for (const element of elements) {
6364
+ if (!element.navigationProvenance)
6365
+ continue;
6366
+ const candidate = classifySafeMobileNavigationControl(element);
6367
+ if (!candidate)
6368
+ continue;
6369
+ const groupKey = `${element.navigationProvenance}:${mobileNavigationRegion(element, windowSize)}`;
6370
+ const labels = groups.get(groupKey) ?? /* @__PURE__ */ new Set();
6371
+ labels.add(`${candidate.kind}:${normalizeControlIdentityPart(candidate.label)}`);
6372
+ groups.set(groupKey, labels);
6373
+ }
6374
+ return new Map([...groups].filter(([, labels]) => labels.size >= 2));
6375
+ }
6376
+ function deriveScreenId(signal) {
6377
+ const namespace = signal.activity?.trim() || signal.bundleId?.trim();
6378
+ return namespace ? `${namespace}#${signal.screenHash}` : signal.screenHash;
6379
+ }
6380
+ function observedElementIdentity(element) {
6381
+ return (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(element);
6382
+ }
6383
+ function mergeObservedElements(previous, current) {
6384
+ const merged = [...current];
6385
+ const seen = new Set(current.map(observedElementIdentity));
6386
+ for (const element of previous) {
6387
+ const identity = observedElementIdentity(element);
6388
+ if (seen.has(identity))
6389
+ continue;
6390
+ seen.add(identity);
6391
+ merged.push(element);
6392
+ }
6393
+ return merged;
6394
+ }
6395
+ function sanitizeElementsForEvidence(elements) {
6396
+ return elements.map((element) => {
6397
+ const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element);
6398
+ return {
6399
+ ...element,
6400
+ ...label ? { label } : { label: void 0 },
6401
+ text: void 0
6402
+ };
6403
+ });
6404
+ }
6405
+ var MobileExplorationState = class {
6406
+ /** screenId -> ScreenState (the mobile analog of routeState). */
6407
+ screens = /* @__PURE__ */ new Map();
6408
+ /** Observed directed transitions between screens. */
6409
+ transitions = [];
6410
+ /** Transition-announced screens that do not yet have a live observation. */
6411
+ frontier = /* @__PURE__ */ new Map();
6412
+ /** Safe navigation controls observed on each screen, keyed by stable identity. */
6413
+ safeControlsByScreen = /* @__PURE__ */ new Map();
6414
+ /** Screen-scoped coverage keys plus explicitly proven shared-navigation keys. */
6415
+ attemptedSafeControlCoverageIdentities = /* @__PURE__ */ new Set();
6416
+ successfulSafeControlAttemptCount = 0;
6417
+ /** Repeated id-less structural navigation families, grouped by region. */
6418
+ semanticNavigationFamilies = /* @__PURE__ */ new Map();
6419
+ nextSemanticNavigationFamilyId = 1;
6420
+ /** The screen-id of the most recently observed screen. */
6421
+ currentScreenId = null;
6422
+ /** Latest iteration recorded (drives stale detection + firstSeen stamps). */
6423
+ iteration = 0;
6424
+ /** Stale detector: progress mark (#screens + #total elements) at last advance. */
6425
+ lastProgressMark = 0;
6426
+ /** Consecutive iterations observed with no progress. */
6427
+ staleIterations = 0;
6428
+ staleThreshold;
6429
+ constructor(options = {}) {
6430
+ this.staleThreshold = typeof options.staleThreshold === "number" && options.staleThreshold > 0 ? Math.floor(options.staleThreshold) : DEFAULT_STALE_THRESHOLD;
6431
+ }
6432
+ // -------------------------------------------------------------------------
6433
+ // Observation recording
6434
+ // -------------------------------------------------------------------------
6435
+ /**
6436
+ * Returns true when `signal` identifies a screen this run has never observed
6437
+ * before. Pure read — does not mutate state.
6438
+ */
6439
+ isNewScreen(signal) {
6440
+ return !this.screens.has(deriveScreenId(signal));
6441
+ }
6442
+ /**
6443
+ * Record an observation of `screen` at `iteration`. Creates a `ScreenState`
6444
+ * on first sight (stamping `firstSeenIteration`), otherwise refreshes the
6445
+ * stored element set (latest-wins, mirroring the web reducer's PAGE_CAPTURED
6446
+ * element refresh). A driver observation already contains the structural UI
6447
+ * tree, elements, screenshot association, and screen identity, so it is
6448
+ * captured evidence immediately; the model's capture_screen call only adds
6449
+ * a friendly name/type. Returns the resolved screen-id.
6450
+ */
6451
+ recordObservation(signal, screen, iteration) {
6452
+ this.iteration = iteration;
6453
+ const screenId = deriveScreenId(signal);
6454
+ const evidenceElements = sanitizeElementsForEvidence(screen.elements);
6455
+ const existing = this.screens.get(screenId);
6456
+ if (existing) {
6457
+ this.screens.set(screenId, {
6458
+ ...existing,
6459
+ elements: mergeObservedElements(existing.elements, evidenceElements)
6460
+ });
6461
+ } else {
6462
+ this.screens.set(screenId, {
6463
+ screenId,
6464
+ firstSeenIteration: iteration,
6465
+ elements: evidenceElements,
6466
+ visited: true
6467
+ });
6468
+ }
6469
+ this.frontier.delete(screenId);
6470
+ const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
6471
+ const semanticNavigationGroups = buildSemanticNavigationGroups(evidenceElements, screen.windowSize);
6472
+ const semanticNavigationFamilyIds = /* @__PURE__ */ new Map();
6473
+ for (const [groupKey, labels] of semanticNavigationGroups) {
6474
+ semanticNavigationFamilyIds.set(groupKey, this.resolveSemanticNavigationFamilyId(groupKey, labels));
6475
+ }
6476
+ for (const element of evidenceElements) {
6477
+ const candidate = classifySafeMobileNavigationControl(element);
6478
+ if (!candidate)
6479
+ continue;
6480
+ const storedIdentity = storedMobileControlIdentity(candidate);
6481
+ const semanticGroupKey = element.navigationProvenance ? `${element.navigationProvenance}:${mobileNavigationRegion(element, screen.windowSize)}` : "";
6482
+ controls.set(storedIdentity, {
6483
+ ...candidate,
6484
+ identity: storedIdentity,
6485
+ sharedCoverageIdentity: sharedMobileControlCoverageIdentity(element, candidate, semanticNavigationFamilyIds.get(semanticGroupKey))
6486
+ });
6487
+ }
6488
+ if (controls.size > 0)
6489
+ this.safeControlsByScreen.set(screenId, controls);
6490
+ this.currentScreenId = screenId;
6491
+ this.updateStaleProgress();
6492
+ return screenId;
6493
+ }
6494
+ /**
6495
+ * Back-compatible acknowledgement from capture_screen. Observation already
6496
+ * marks structural evidence visited; this call only resolves a rare pending
6497
+ * transition frontier and preserves callers that annotate explicitly.
6498
+ */
6499
+ markVisited(screenId) {
6500
+ const state2 = this.screens.get(screenId);
6501
+ if (state2 && !state2.visited) {
6502
+ this.screens.set(screenId, { ...state2, visited: true });
6503
+ }
6504
+ this.frontier.delete(screenId);
6505
+ }
6506
+ /**
6507
+ * Record a confirmed tap against a safe navigation candidate. Callers invoke
6508
+ * this only after the driver reports success; recovery-unknown, stale-ref, and
6509
+ * failed actions must remain on the breadth frontier.
6510
+ */
6511
+ noteControlAttempt(screenId, element) {
6512
+ if (!screenId)
6513
+ return false;
6514
+ const candidate = classifySafeMobileNavigationControl(element);
6515
+ const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
6516
+ if (candidate) {
6517
+ const storedIdentity = storedMobileControlIdentity(candidate);
6518
+ const existing = controls.get(storedIdentity);
6519
+ controls.set(storedIdentity, {
6520
+ ...candidate,
6521
+ identity: storedIdentity,
6522
+ sharedCoverageIdentity: existing?.sharedCoverageIdentity ?? sharedMobileControlCoverageIdentity(element, candidate)
6523
+ });
6524
+ this.safeControlsByScreen.set(screenId, controls);
6525
+ }
6526
+ const tappedLabel = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
6527
+ if (!tappedLabel)
6528
+ return false;
6529
+ const before = this.attemptedSafeControlCoverageIdentities.size;
6530
+ const alias = semanticControlAlias(tappedLabel);
6531
+ for (const control of controls.values()) {
6532
+ if (semanticControlAlias(control.label) === alias) {
6533
+ this.attemptedSafeControlCoverageIdentities.add(localControlCoverageIdentity(screenId, control.identity));
6534
+ if (control.sharedCoverageIdentity) {
6535
+ this.attemptedSafeControlCoverageIdentities.add(control.sharedCoverageIdentity);
6536
+ }
6537
+ }
6538
+ }
6539
+ if (this.attemptedSafeControlCoverageIdentities.size > before) {
6540
+ this.successfulSafeControlAttemptCount += 1;
6541
+ this.staleIterations = 0;
6542
+ return true;
6543
+ }
6544
+ return false;
6545
+ }
6546
+ /** Attach a human-friendly name / coarse type to a screen (capture_screen). */
6547
+ annotateScreen(screenId, annotation) {
6548
+ const state2 = this.screens.get(screenId);
6549
+ if (!state2)
6550
+ return;
6551
+ this.screens.set(screenId, {
6552
+ ...state2,
6553
+ screenName: annotation.screenName ?? state2.screenName,
6554
+ screenType: annotation.screenType ?? state2.screenType
6555
+ });
6556
+ }
6557
+ /** Attach structurally observed authentication provenance to a screen. */
6558
+ setScreenAuthEvidence(screenId, evidence) {
6559
+ const state2 = this.screens.get(screenId);
6560
+ if (!state2)
6561
+ return;
6562
+ this.screens.set(screenId, {
6563
+ ...state2,
6564
+ requiresAuth: evidence.requiresAuth,
6565
+ authProvenance: evidence.authProvenance,
6566
+ sessionState: { ...evidence.sessionState }
6567
+ });
6568
+ }
6569
+ /** Append a free-form observation note to a screen (observations tool). */
6570
+ addScreenObservation(screenId, note) {
6571
+ const state2 = this.screens.get(screenId);
6572
+ if (!state2)
6573
+ return;
6574
+ const normalized = note.trim();
6575
+ if (!normalized)
6576
+ return;
6577
+ const current = state2.observations ?? [];
6578
+ if (current.some((existing) => existing.trim().toLowerCase() === normalized.toLowerCase())) {
6579
+ return;
6580
+ }
6581
+ const observations = [...current, normalized].slice(-MAX_SCREEN_OBSERVATIONS);
6582
+ this.screens.set(screenId, { ...state2, observations });
6583
+ }
6584
+ /**
6585
+ * Record a directed transition between two screens. Both endpoints are
6586
+ * ensured to exist on the frontier if not yet observed (mirrors the web
6587
+ * reducer adding both ends of a TRANSITION_RECORDED to the frontier). The
6588
+ * `toScreenId` becomes the current screen when the transition starts from the
6589
+ * current screen.
6590
+ */
6591
+ recordTransition(transition) {
6592
+ let recordedTransition = transition;
6593
+ if (!transition.viaControlIdentity && transition.viaRef) {
6594
+ const sourceElement = this.screens.get(transition.fromScreenId)?.elements.find((element) => element.ref === transition.viaRef);
6595
+ if (sourceElement) {
6596
+ recordedTransition = {
6597
+ ...transition,
6598
+ viaControlIdentity: (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(sourceElement)
6599
+ };
6600
+ }
6601
+ }
6602
+ const alreadyRecorded = this.transitions.some((existing) => existing.fromScreenId === recordedTransition.fromScreenId && existing.toScreenId === recordedTransition.toScreenId && existing.action === recordedTransition.action);
6603
+ if (!alreadyRecorded) {
6604
+ this.transitions.push(recordedTransition);
6605
+ }
6606
+ for (const endpointId of [transition.fromScreenId, transition.toScreenId]) {
6607
+ if (!this.screens.has(endpointId) && !this.frontier.has(endpointId)) {
6608
+ this.frontier.set(endpointId, {
6609
+ screenId: endpointId,
6610
+ source: "transition",
6611
+ discoveredAtIteration: this.iteration
6612
+ });
6613
+ }
6614
+ }
6615
+ if (transition.fromScreenId === this.currentScreenId) {
6616
+ this.currentScreenId = transition.toScreenId;
6617
+ }
6618
+ }
6619
+ // -------------------------------------------------------------------------
6620
+ // Selectors
6621
+ // -------------------------------------------------------------------------
6622
+ /** The current screen-id (most recently observed), or null before any observation. */
6623
+ getCurrentScreenId() {
6624
+ return this.currentScreenId;
6625
+ }
6626
+ /** ScreenState for `screenId`, if known. */
6627
+ getScreen(screenId) {
6628
+ return this.screens.get(screenId);
6629
+ }
6630
+ /** Number of distinct screens observed this run. */
6631
+ getScreenCount() {
6632
+ return this.screens.size;
6633
+ }
6634
+ /** Number of screens backed by a live structural driver observation. */
6635
+ getVisitedScreenCount() {
6636
+ let total = 0;
6637
+ for (const state2 of this.screens.values()) {
6638
+ if (state2.visited)
6639
+ total += 1;
6640
+ }
6641
+ return total;
6642
+ }
6643
+ /** Total elements observed across all known screens (latest snapshot each). */
6644
+ getTotalElementCount() {
6645
+ let total = 0;
6646
+ for (const state2 of this.screens.values()) {
6647
+ total += state2.elements.length;
6648
+ }
6649
+ return total;
6650
+ }
6651
+ /**
6652
+ * Frontier screen-ids ranked by recency (most recently discovered first),
6653
+ * excluding any screen already visited. Mirrors the web `getRankedFrontier`
6654
+ * intent (filter visited, rank by recency) — without URL prefix similarity,
6655
+ * which has no mobile analog.
6656
+ */
6657
+ getFrontier() {
6658
+ return [...this.frontier.values()].filter((item) => {
6659
+ const state2 = this.screens.get(item.screenId);
6660
+ return !state2 || !state2.visited;
6661
+ }).sort((a, b) => b.discoveredAtIteration - a.discoveredAtIteration).map((item) => item.screenId);
6662
+ }
6663
+ /** Safe navigation candidates not yet attempted, deduped across shared chrome. */
6664
+ getUnexploredSafeNavigationCandidates() {
6665
+ const authenticatedProductObserved = [...this.screens.values()].some((screen) => screen.requiresAuth === true);
6666
+ const candidates = [];
6667
+ for (const [screenId, controls] of this.safeControlsByScreen) {
6668
+ const origin = this.screens.get(screenId);
6669
+ const originIsConfirmedPublicEntry = origin?.authProvenance === "public" || !origin?.authProvenance && origin?.requiresAuth !== true && /^(?:AUTH|LANDING)$/i.test(origin?.screenType ?? "");
6670
+ if (authenticatedProductObserved && originIsConfirmedPublicEntry)
6671
+ continue;
6672
+ for (const control of controls.values()) {
6673
+ const alias = semanticControlAlias(control.label);
6674
+ const coverageIdentity = control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity);
6675
+ if (this.attemptedSafeControlCoverageIdentities.has(coverageIdentity) || this.attemptedSafeControlCoverageIdentities.has(localControlCoverageIdentity(screenId, control.identity)))
6676
+ continue;
6677
+ const existing = candidates.find((candidate) => candidate.identity === coverageIdentity || candidate.alias === alias && candidate.screenIds.has(screenId));
6678
+ if (existing) {
6679
+ existing.screenIds.add(screenId);
6680
+ } else {
6681
+ candidates.push({
6682
+ identity: coverageIdentity,
6683
+ label: control.label,
6684
+ kind: control.kind,
6685
+ alias,
6686
+ screenIds: /* @__PURE__ */ new Set([screenId])
6687
+ });
6688
+ }
6689
+ }
6690
+ }
6691
+ return candidates.map((candidate) => ({
6692
+ identity: candidate.identity,
6693
+ label: candidate.label,
6694
+ kind: candidate.kind,
6695
+ screenIds: [...candidate.screenIds]
6696
+ })).sort((a, b) => {
6697
+ const aCurrent = this.currentScreenId !== null && a.screenIds.includes(this.currentScreenId);
6698
+ const bCurrent = this.currentScreenId !== null && b.screenIds.includes(this.currentScreenId);
6699
+ if (aCurrent !== bCurrent)
6700
+ return aCurrent ? -1 : 1;
6701
+ return b.screenIds.length - a.screenIds.length;
6702
+ });
6703
+ }
6704
+ /** Harness-owned survey completion floor, independent of model assertions. */
6705
+ getSurveyBreadthCoverage() {
6706
+ const unvisitedScreenIds = this.getFrontier();
6707
+ const unexploredControls = this.getUnexploredSafeNavigationCandidates();
6708
+ const complete = unvisitedScreenIds.length === 0 && unexploredControls.length === 0;
6709
+ const missing = [];
6710
+ if (unvisitedScreenIds.length > 0) {
6711
+ missing.push(`${unvisitedScreenIds.length} uncaptured screen(s)`);
6712
+ }
6713
+ if (unexploredControls.length > 0) {
6714
+ missing.push(`${unexploredControls.length} safe navigation control(s)`);
6715
+ }
6716
+ return {
6717
+ required: true,
6718
+ complete,
6719
+ unvisitedScreenIds,
6720
+ unexploredControls,
6721
+ attemptedControlCount: this.successfulSafeControlAttemptCount,
6722
+ ...missing.length > 0 ? { reason: `Missing ${missing.join(" and ")}.` } : {}
6723
+ };
6724
+ }
6725
+ /**
6726
+ * Monotonic structural progress token for adaptive survey leases. Frontier
6727
+ * cardinality alone is insufficient: attempting control A can reveal control
6728
+ * B and leave the remaining count unchanged even though real work happened.
6729
+ */
6730
+ getSurveyBreadthProgressMark() {
6731
+ const discoveredControlIdentities = /* @__PURE__ */ new Set();
6732
+ for (const [screenId, controls] of this.safeControlsByScreen) {
6733
+ for (const control of controls.values()) {
6734
+ discoveredControlIdentities.add(control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity));
6735
+ }
6736
+ }
6737
+ return [
6738
+ `screens:${this.screens.size}`,
6739
+ `attempts:${this.successfulSafeControlAttemptCount}`,
6740
+ `controls:${discoveredControlIdentities.size}`,
6741
+ `transitions:${this.transitions.length}`
6742
+ ].join("|");
6743
+ }
6744
+ resolveSemanticNavigationFamilyId(groupKey, labels) {
6745
+ const families = this.semanticNavigationFamilies.get(groupKey) ?? [];
6746
+ let best;
6747
+ let bestIntersection = 0;
6748
+ for (const family of families) {
6749
+ let intersection = 0;
6750
+ for (const label of labels) {
6751
+ if (family.labels.has(label))
6752
+ intersection += 1;
6753
+ }
6754
+ const overlap = intersection / Math.min(labels.size, family.labels.size);
6755
+ if (intersection >= 2 && overlap >= 0.6 && intersection > bestIntersection) {
6756
+ best = family;
6757
+ bestIntersection = intersection;
6758
+ }
6759
+ }
6760
+ if (best) {
6761
+ for (const label of labels)
6762
+ best.labels.add(label);
6763
+ return best.id;
6764
+ }
6765
+ const created = {
6766
+ id: `semantic-nav-family-${this.nextSemanticNavigationFamilyId++}`,
6767
+ labels: new Set(labels)
6768
+ };
6769
+ families.push(created);
6770
+ this.semanticNavigationFamilies.set(groupKey, families);
6771
+ return created.id;
6772
+ }
6773
+ /** Observed transitions (defensive copy). */
6774
+ getTransitions() {
6775
+ return [...this.transitions];
6776
+ }
6777
+ // -------------------------------------------------------------------------
6778
+ // Stale-progress detection (ported semantics from the web stale guard)
6779
+ // -------------------------------------------------------------------------
6780
+ /**
6781
+ * Progress mark: distinct screens weighted heavily, plus total elements seen.
6782
+ * A run advances this mark whenever it finds a new screen OR new elements;
6783
+ * the stale detector triggers when the mark stops advancing for
6784
+ * `staleThreshold` consecutive iterations.
6785
+ */
6786
+ getProgressMark() {
6787
+ return this.screens.size * 1e3 + this.transitions.length * 100 + this.successfulSafeControlAttemptCount * 10 + this.getTotalElementCount();
6788
+ }
6789
+ updateStaleProgress() {
6790
+ const mark = this.getProgressMark();
6791
+ if (mark > this.lastProgressMark) {
6792
+ this.lastProgressMark = mark;
6793
+ this.staleIterations = 0;
6794
+ } else {
6795
+ this.staleIterations += 1;
6796
+ }
6797
+ }
6798
+ /** Consecutive no-progress iterations observed so far. */
6799
+ getStaleIterations() {
6800
+ return this.staleIterations;
6801
+ }
6802
+ /**
6803
+ * True when the run has gone `staleThreshold` consecutive iterations with no
6804
+ * new screen and no new element — the signal the explorer uses to nudge or
6805
+ * finish (mirrors the web explorer's stale guard).
6806
+ */
6807
+ isStaleProgress() {
6808
+ return this.staleIterations >= this.staleThreshold;
6809
+ }
6810
+ // -------------------------------------------------------------------------
6811
+ // Planner evidence export
6812
+ // -------------------------------------------------------------------------
6813
+ /**
6814
+ * Snapshot the screen-graph as a plain object (frozen mobile-types
6815
+ * `MobileScreenGraph`). Defensive copies so callers cannot mutate internal
6816
+ * state.
6817
+ */
6818
+ toScreenGraph() {
6819
+ const screens = /* @__PURE__ */ new Map();
6820
+ for (const [id, state2] of this.screens) {
6821
+ screens.set(id, {
6822
+ ...state2,
6823
+ elements: [...state2.elements],
6824
+ observations: state2.observations ? [...state2.observations] : void 0,
6825
+ sessionState: state2.sessionState ? { ...state2.sessionState } : void 0
6826
+ });
6827
+ }
6828
+ return {
6829
+ screens,
6830
+ transitions: this.transitions.map((t) => ({
6831
+ fromScreenId: t.fromScreenId,
6832
+ toScreenId: t.toScreenId,
6833
+ viaRef: t.viaRef,
6834
+ viaControlIdentity: t.viaControlIdentity,
6835
+ action: t.action
6836
+ }))
6837
+ };
6838
+ }
6839
+ /**
6840
+ * Build the planner evidence shape: one `CollectedPageData` per screen, with
6841
+ * the screen-id carried in BOTH `route` (the on-the-wire positional field the
6842
+ * server keys screenshots by) AND the explicit `screenId` field (per the
6843
+ * Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
6844
+ * the screen's parsed actionable elements into the platform-neutral
6845
+ * `CollectedPageElement[]` shape the server persists as SiteElements — without
6846
+ * this, mobile screens land with zero elements (coverage always 0%, no element
6847
+ * intelligence). `triggersNavigation` is derived from the durable source
6848
+ * element identity captured when the transition happened (with ref-only
6849
+ * fallback for legacy transitions). Insertion order matches first-seen order.
6850
+ */
6851
+ buildInMemoryScreenMap() {
6852
+ const pages = [];
6853
+ const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
6854
+ const navRefsByScreen = /* @__PURE__ */ new Map();
6855
+ const navIdentitiesByScreen = /* @__PURE__ */ new Map();
6856
+ for (const transition of this.transitions) {
6857
+ if (transition.viaControlIdentity) {
6858
+ const set = navIdentitiesByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
6859
+ set.add(transition.viaControlIdentity);
6860
+ navIdentitiesByScreen.set(transition.fromScreenId, set);
6861
+ } else if (transition.viaRef) {
6862
+ const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
6863
+ set.add(transition.viaRef);
6864
+ navRefsByScreen.set(transition.fromScreenId, set);
6865
+ }
6866
+ }
6867
+ for (const state2 of ordered) {
6868
+ const page = {
6869
+ route: state2.screenId,
6870
+ screenId: state2.screenId,
6871
+ pageType: state2.screenType ?? "UNKNOWN",
6872
+ elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
6873
+ navigationRefs: navRefsByScreen.get(state2.screenId),
6874
+ navigationIdentities: navIdentitiesByScreen.get(state2.screenId)
6875
+ }),
6876
+ requiresAuth: state2.requiresAuth ?? false
6877
+ };
6878
+ if (state2.sessionState) {
6879
+ page.sessionState = { ...state2.sessionState };
6880
+ }
6881
+ if (state2.screenName) {
6882
+ page.title = state2.screenName;
6883
+ }
6884
+ if (state2.observations && state2.observations.length > 0) {
6885
+ page.observations = state2.observations.map((note) => ({
6886
+ action: "observe",
6887
+ outcome: note,
6888
+ outcomeType: "SUCCESS"
6889
+ }));
6890
+ }
6891
+ pages.push(page);
6892
+ }
6893
+ return pages;
6894
+ }
6895
+ /** Alias for `buildInMemoryScreenMap()` — name parity with the planner-evidence call site. */
6896
+ toScreenMap() {
6897
+ return this.buildInMemoryScreenMap();
6898
+ }
6899
+ };
6900
+ exports.MobileExplorationState = MobileExplorationState;
6901
+ }
6902
+ });
6903
+
5815
6904
  // ../runner-core/dist/services/mobile/mobile-keyboard.js
5816
6905
  var require_mobile_keyboard = __commonJS({
5817
6906
  "../runner-core/dist/services/mobile/mobile-keyboard.js"(exports) {
@@ -63530,6 +64619,7 @@ var require_mobile_explorer = __commonJS({
63530
64619
  var ai_retry_js_1 = require_ai_retry();
63531
64620
  var mobile_source_parser_js_1 = require_mobile_source_parser();
63532
64621
  var mobile_observation_js_1 = require_mobile_observation();
64622
+ var mobile_exploration_state_js_1 = require_mobile_exploration_state();
63533
64623
  var mobile_boundary_js_1 = require_mobile_boundary();
63534
64624
  var mobile_keyboard_js_1 = require_mobile_keyboard();
63535
64625
  var mobile_auth_coverage_js_1 = require_mobile_auth_coverage();
@@ -63887,6 +64977,10 @@ var require_mobile_explorer = __commonJS({
63887
64977
  return void 0;
63888
64978
  return normalized;
63889
64979
  }
64980
+ function destructiveActionRefusal(label, kind = "tap") {
64981
+ const what = kind === "confirmation" ? `Typing "${label}" is a destructive confirmation` : `Tapping "${label}" is an irreversible, high-stakes destructive action`;
64982
+ return `Refused: ${what} (account/data deletion, deactivation, wipe, or similar). Autonomous testing must never execute irreversible destruction on the account under test. Record it as an untested destructive flow in your summary and choose a non-destructive control instead.`;
64983
+ }
63890
64984
  function hybridElementForEvidence(element, credentials) {
63891
64985
  const id = safeHybridIdentity(element.id, credentials);
63892
64986
  const name = safeHybridIdentity(element.name, credentials);
@@ -64509,7 +65603,7 @@ Max iterations: ${maxIterations}. You will be warned near the end.`;
64509
65603
  3. After each navigation, capture_screen the destination and record_transition. After completing a meaningful flow (login, search, create), ${enableRecordJourney ? "call record_journey with the steps and expected outcome." : "continue exploring."}
64510
65604
  4. Use mobile_relaunch for a clean, hermetic restart before testing a distinct flow that needs fresh state.
64511
65605
  5. mobile_swipe to scroll; mobile_back to return.
64512
- 6. Do NOT trigger irreversible side effects (real payments, sending messages/invites to others). Note them instead.
65606
+ 6. Do NOT trigger irreversible or destructive side effects \u2014 real payments; sending messages/invites to others; and ESPECIALLY account/data deletion, deactivation, "delete account", factory reset, or any "type DELETE to confirm" gate. The runner hard-blocks destructive taps and confirmation keystrokes, so attempting them only wastes an iteration. Note such flows in your summary and explore elsewhere.
64513
65607
  7. Stay inside this app.${targetLine} If a tap opens an app store, settings, browser, or another app, do not explore it; return with mobile_back or mobile_relaunch.
64514
65608
 
64515
65609
  Max iterations: ${maxIterations}. You will be warned near the end.`;
@@ -65374,6 +66468,11 @@ Exploration complete: ${summary}`);
65374
66468
  if (!driver.webTap) {
65375
66469
  return { ok: false, error: "This driver does not support hybrid WEBVIEW taps." };
65376
66470
  }
66471
+ const hybridTapLabel = hybridElementForEvidence(hybrid, redactionCredentials).label ?? "";
66472
+ if ((0, mobile_exploration_state_js_1.isDestructiveActionLabel)(hybridTapLabel)) {
66473
+ log2(` \u26A0 Refused destructive tap on "${hybridTapLabel}" (account/data-deletion class); noting instead.`);
66474
+ return { ok: false, error: destructiveActionRefusal(hybridTapLabel) };
66475
+ }
65377
66476
  const authSurfaceBeforeTap2 = currentAuthSurface();
65378
66477
  const fromScreenId2 = state2.getCurrentScreenId();
65379
66478
  const actionResult2 = await driver.webTap(hybrid.webviewContext, hybrid.css);
@@ -65404,6 +66503,11 @@ Exploration complete: ${summary}`);
65404
66503
  if (!resolved) {
65405
66504
  return { ok: false, error: `Unknown element ref "${String(toolArgs.ref)}". Call mobile_snapshot first; refs are only valid for the latest snapshot.` };
65406
66505
  }
66506
+ const nativeTapLabel = resolved.element.label ?? resolved.element.text ?? resolved.element.accessibilityId ?? "";
66507
+ if ((0, mobile_exploration_state_js_1.isDestructiveActionLabel)(nativeTapLabel)) {
66508
+ log2(` \u26A0 Refused destructive tap on "${nativeTapLabel}" (account/data-deletion class); noting instead.`);
66509
+ return { ok: false, error: destructiveActionRefusal(nativeTapLabel) };
66510
+ }
65407
66511
  const fromScreenId = state2.getCurrentScreenId();
65408
66512
  const boundaryBeforeAction = currentBoundary();
65409
66513
  const startedOutsideTarget = boundaryBeforeAction.kind !== "target" && boundaryBeforeAction.kind !== "unknown";
@@ -65444,6 +66548,10 @@ Exploration complete: ${summary}`);
65444
66548
  // ----- Type -----
65445
66549
  case "mobile_type": {
65446
66550
  const value = typeof toolArgs.value === "string" ? toolArgs.value : String(toolArgs.value ?? "");
66551
+ if ((0, mobile_exploration_state_js_1.isDestructiveConfirmationInput)(value)) {
66552
+ log2(` \u26A0 Refused destructive confirmation input "${value}"; noting instead.`);
66553
+ return { ok: false, error: destructiveActionRefusal(value, "confirmation") };
66554
+ }
65447
66555
  const allowedLiteral = inbox ? inbox.state.email : "";
65448
66556
  for (const [key, secret] of Object.entries(credentials)) {
65449
66557
  if (!secret || secret.length < 4)
@@ -70153,7 +71261,7 @@ var require_mobile_generator = __commonJS({
70153
71261
  - Re-snapshot before every assertion so the ref is fresh.
70154
71262
  - Assert the OUTCOME of the flow (the new screen / new content), not the control you just tapped.
70155
71263
  - Assert only DURABLE elements \u2014 screen chrome that exists on every visit (tab labels, headers, nav bars, the entity you just created). NEVER assert one-time / data-dependent UI: empty-state CTAs, first-run or onboarding prompts, or counters \u2014 they vanish as soon as the account has data, and the test then fails forever on a healthy app.
70156
- - Do NOT trigger irreversible side effects (real payments, sending messages to others) \u2014 note them and assert what you safely can.
71264
+ - Do NOT trigger irreversible or destructive side effects \u2014 real payments; sending messages to others; and ESPECIALLY account/data deletion, deactivation, or any "type DELETE to confirm" gate (the runner hard-blocks destructive taps and confirmation keystrokes). Note them and assert what you safely can.
70157
71265
  - Stay inside this app.${targetLine} Do not open or use app stores, browsers, email apps, settings, or any other app. If an action leaves the target app, the runner will relaunch the target app and reject that action.
70158
71266
  - NEVER tap the phone's home screen / launcher (app icons, "Predicted app" chips, search bars, widgets). Those are NOT part of the app under test \u2014 tapping them is junk that poisons the test. If the app ever backgrounds or you find yourself on the launcher, use mobile_relaunch to cold-start the app; do NOT tap a launcher icon or app-prediction to get back in.
70159
71267
  - Do NOT re-do a sequence you already completed. If you filled a form then navigated away, do NOT come back and fill it again \u2014 the scenario needs ONE clean pass. Filling the login form, going back, and re-filling it is exactly the kind of wandering that produces a bloated, broken test.
@@ -78826,6 +79934,34 @@ var require_anchor_harvester = __commonJS({
78826
79934
  }
78827
79935
  });
78828
79936
 
79937
+ // ../runner-core/dist/services/discovery-lint-flags.js
79938
+ var require_discovery_lint_flags = __commonJS({
79939
+ "../runner-core/dist/services/discovery-lint-flags.js"(exports) {
79940
+ "use strict";
79941
+ Object.defineProperty(exports, "__esModule", { value: true });
79942
+ exports.structuralLocatorLintMode = structuralLocatorLintMode;
79943
+ exports.isPwMethodPreflightEnabled = isPwMethodPreflightEnabled;
79944
+ exports.isVqaLoginDedupeEnabled = isVqaLoginDedupeEnabled;
79945
+ function strictOn(value) {
79946
+ return value === "true" || value === "1";
79947
+ }
79948
+ function structuralLocatorLintMode() {
79949
+ const v = (process.env.DISCOVERY_STRUCTURAL_LOCATOR_LINT || "").trim().toLowerCase();
79950
+ if (v === "warn")
79951
+ return "warn";
79952
+ if (v === "true" || v === "1" || v === "enforce")
79953
+ return "enforce";
79954
+ return "off";
79955
+ }
79956
+ function isPwMethodPreflightEnabled() {
79957
+ return strictOn(process.env.DISCOVERY_PW_METHOD_PREFLIGHT);
79958
+ }
79959
+ function isVqaLoginDedupeEnabled() {
79960
+ return strictOn(process.env.DISCOVERY_VQALOGIN_DEDUPE);
79961
+ }
79962
+ }
79963
+ });
79964
+
78829
79965
  // ../runner-core/dist/services/auth-recipe.js
78830
79966
  var require_auth_recipe = __commonJS({
78831
79967
  "../runner-core/dist/services/auth-recipe.js"(exports) {
@@ -78848,9 +79984,11 @@ var require_auth_recipe = __commonJS({
78848
79984
  exports.extractAuthRegion = extractAuthRegion;
78849
79985
  exports.stripAuthRegions = stripAuthRegions;
78850
79986
  exports.ensureExpectImported = ensureExpectImported;
79987
+ exports.ensureLoginCall = ensureLoginCall;
78851
79988
  exports.upsertAuthBlock = upsertAuthBlock;
78852
79989
  exports.buildHealAuthStamp = buildHealAuthStamp;
78853
79990
  var crypto_1 = __require("crypto");
79991
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
78854
79992
  exports.AUTH_MARKER_VERSION = "v1";
78855
79993
  var START_MARKER = `// >>> VALIDATE_QA_AUTH ${exports.AUTH_MARKER_VERSION} \u2014 generated, do not edit >>>`;
78856
79994
  var END_MARKER = `// <<< VALIDATE_QA_AUTH ${exports.AUTH_MARKER_VERSION} <<<`;
@@ -79114,10 +80252,13 @@ ${tail}`;
79114
80252
  var TEST_OPENER_RE = /test(?:\.(?:only|skip|fixme))?\s*\(\s*(['"`])(?:[^\\]|\\.)*?\1\s*,\s*(?:\{[\s\S]*?\}\s*,\s*)?async\s*\(\s*\{[^}]*\}[^)]*\)\s*=>\s*\{/;
79115
80253
  var LOGIN_CALL_RE = /await\s+__vqaLogin\s*\(\s*page\s*\)/;
79116
80254
  function ensureLoginCall(code) {
79117
- const matches = [...code.matchAll(new RegExp(TEST_OPENER_RE.source, "g"))];
79118
- if (matches.length === 0)
80255
+ if (code.match(new RegExp(TEST_OPENER_RE.source, "g")) === null)
79119
80256
  return code;
79120
80257
  let result = code;
80258
+ if ((0, discovery_lint_flags_js_1.isVqaLoginDedupeEnabled)()) {
80259
+ result = result.replace(/^[ \t]*await[ \t]+__vqaLogin[ \t]*\([ \t]*page[ \t]*\)[ \t]*;?[ \t]*\r?\n/gm, "");
80260
+ }
80261
+ const matches = [...result.matchAll(new RegExp(TEST_OPENER_RE.source, "g"))];
79121
80262
  for (let i = matches.length - 1; i >= 0; i--) {
79122
80263
  const m = matches[i];
79123
80264
  if (m.index === void 0)
@@ -79526,7 +80667,7 @@ var require_agentic_discovery_prompts = __commonJS({
79526
80667
  "## Test-quality bar",
79527
80668
  "",
79528
80669
  "- ONE `test.describe(<featureName>)` block per file with ONE `test(...)` call inside.",
79529
- "- Use `await page.goto(<relativePath>)` \u2014 baseURL is set in the runner config; never hardcode the host.",
80670
+ "- Use `await page.goto(<relativePath>)` \u2014 baseURL is set in the runner config; never hardcode the host. If the base URL includes a path (e.g. `https://host/app`), every goto path MUST start with that path: Playwright only prefixes relative paths, so `page.goto('/')` lands on the ORIGIN ROOT outside the app \u2014 the app home is `page.goto('/app')`.",
79530
80671
  "- Every test must contain at least one `expect(...)` assertion.",
79531
80672
  "- **The LAST statement inside the test body MUST be `await expect(...)` that PROVES THE OUTCOME of the test's primary action.** A test ending in `.click()`, `.fill()`, `.press()`, or a `console.log` is INVALID. For an INTERACTION test, re-asserting that the control you just clicked is visible or `aria-selected=true` proves NOTHING (the toggle could be broken and the test would still pass) and is rejected by the assertion-strength gate. Instead verify the post-action state: a before\u2192after count/value change, new content/value that appeared (`getByText` of the revealed value, `toHaveText`/`toContainText`), a created item present or a removed item gone (`toHaveCount`/`not.toBeVisible`), a field value (`toHaveValue`), or a control now disabled (`toBeDisabled`). EXEMPT: a pure navigation/routing test may end on `toHaveURL` + one landmark `toBeVisible`, and the mandatory post-login wait ends on a landmark `toBeVisible` \u2014 those are correct and required. The parent validator REJECTS tests whose last non-trivial line is anything other than `await expect(...)`.",
79532
80673
  " Prefer: `expect(locator).toBeVisible()`, `expect(page).toHaveURL(/regex/)`, `expect(locator).toHaveText(...)`.",
@@ -81762,7 +82903,7 @@ var require_agentic_discovery_prompts = __commonJS({
81762
82903
  lines.push("## Test-quality bar");
81763
82904
  lines.push("");
81764
82905
  lines.push("- ONE `test.describe(<featureName>)` block per file with ONE `test(...)` inside.");
81765
- lines.push("- Use `await page.goto(<relativePath>)` \u2014 never hardcode the host.");
82906
+ lines.push("- Use `await page.goto(<relativePath>)` \u2014 never hardcode the host. If the base URL includes a path (e.g. `https://host/app`), every goto path MUST start with that path (`page.goto('/')` escapes to the origin root \u2014 the app home is `page.goto('/app')`).");
81766
82907
  lines.push("- Every test MUST include the `@generated` tag.");
81767
82908
  lines.push("- Each test creates its OWN run-scoped data; never depend on pre-seeded data.");
81768
82909
  lines.push("");
@@ -82316,6 +83457,21 @@ var require_agentic_discovery_prompts = __commonJS({
82316
83457
  lines.push(`Use \`browser_navigate\` with the full URL (prepend ${baseUrl}). In the`);
82317
83458
  lines.push("emitted test code keep URLs RELATIVE (`page.goto('/path')`) \u2014 the Playwright");
82318
83459
  lines.push("config sets `baseURL`.");
83460
+ {
83461
+ const gotoBasePath = (() => {
83462
+ try {
83463
+ const p = new URL(baseUrl).pathname.replace(/\/+$/, "");
83464
+ return p && p !== "/" ? p : void 0;
83465
+ } catch {
83466
+ return void 0;
83467
+ }
83468
+ })();
83469
+ if (gotoBasePath) {
83470
+ lines.push(`IMPORTANT: the base URL has a PATH (\`${gotoBasePath}\`) and a leading-slash path escapes`);
83471
+ lines.push(`it to the origin root. Every \`page.goto\` path MUST start with \`${gotoBasePath}\` \u2014 the`);
83472
+ lines.push(`app home is \`page.goto('${gotoBasePath}')\`, NOT \`page.goto('/')\`.`);
83473
+ }
83474
+ }
82319
83475
  lines.push("");
82320
83476
  lines.push("## The scenario");
82321
83477
  lines.push("");
@@ -82994,17 +84150,19 @@ var require_discover_generator = __commonJS({
82994
84150
  "../runner-core/dist/services/discover-generator.js"(exports) {
82995
84151
  "use strict";
82996
84152
  Object.defineProperty(exports, "__esModule", { value: true });
82997
- exports.MIN_BUG_DESCRIPTION_LENGTH = exports.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
84153
+ exports.MIN_BUG_DESCRIPTION_LENGTH = exports.VALID_PLAYWRIGHT_GETBY_METHODS = exports.BLOCKED_MCP_TOOLS_SCENARIO_GEN = void 0;
82998
84154
  exports.filterMcpToolsForScenarioGen = filterMcpToolsForScenarioGen;
82999
84155
  exports.detectScenarioSignals = detectScenarioSignals;
83000
84156
  exports.scenarioStepsCoverAuth = scenarioStepsCoverAuth;
83001
84157
  exports.getLoginCommands = getLoginCommands;
84158
+ exports.hasConfiguredLoginCredentials = hasConfiguredLoginCredentials;
83002
84159
  exports.computeSkeletonTags = computeSkeletonTags;
83003
84160
  exports.shouldReplayAuthRecipe = shouldReplayAuthRecipe;
83004
84161
  exports.buildPageContext = buildPageContext;
83005
84162
  exports.stripTypescriptSyntax = stripTypescriptSyntax;
83006
84163
  exports.stripImportDeclarations = stripImportDeclarations;
83007
84164
  exports.stripStringsAndComments = stripStringsAndComments;
84165
+ exports.gotoBasePathFromBaseUrl = gotoBasePathFromBaseUrl;
83008
84166
  exports.sanitizeCode = sanitizeCode;
83009
84167
  exports.validateTests = validateTests;
83010
84168
  exports.formatEvidenceForLiveGenerate = formatEvidenceForLiveGenerate;
@@ -83015,6 +84173,7 @@ var require_discover_generator = __commonJS({
83015
84173
  exports.upgradePositionalProvenLines = upgradePositionalProvenLines;
83016
84174
  exports.synthesizeRefActionLines = synthesizeRefActionLines;
83017
84175
  exports.renderSessionRecording = renderSessionRecording;
84176
+ exports.scanFragileStructuralLocators = scanFragileStructuralLocators;
83018
84177
  exports.validateFinalActionFidelity = validateFinalActionFidelity;
83019
84178
  exports.resolveSuspectedBugVerdict = resolveSuspectedBugVerdict;
83020
84179
  exports.buildScenarioSystemPrompt = buildScenarioSystemPrompt;
@@ -83042,6 +84201,7 @@ var require_discover_generator = __commonJS({
83042
84201
  var auth_recipe_js_1 = require_auth_recipe();
83043
84202
  var auth_replay_js_1 = require_auth_replay();
83044
84203
  var agentic_discovery_prompts_js_1 = require_agentic_discovery_prompts();
84204
+ var discovery_lint_flags_js_1 = require_discovery_lint_flags();
83045
84205
  exports.BLOCKED_MCP_TOOLS_SCENARIO_GEN = /* @__PURE__ */ new Set([
83046
84206
  "browser_install",
83047
84207
  "browser_close",
@@ -83591,11 +84751,15 @@ var require_discover_generator = __commonJS({
83591
84751
  const replayPlan = (0, auth_recipe_js_1.buildReplayPlan)(login);
83592
84752
  return { ...login, replayPlan };
83593
84753
  }
83594
- function computeSkeletonTags(targetPages, pages) {
84754
+ function hasConfiguredLoginCredentials(credentials) {
84755
+ return ["TEST_USER_EMAIL", "TEST_USER_PASSWORD"].some((k) => typeof credentials?.[k] === "string" && credentials[k].trim().length > 0);
84756
+ }
84757
+ function computeSkeletonTags(targetPages, pages, opts) {
83595
84758
  const isAuthPage = (p) => p.pageType === "AUTH" || /\/(auth|login|sign-?in)\b/i.test(p.route);
84759
+ const siteHasAuthEvidence = pages.some(isAuthPage) || opts?.extraAuthEvidence === true;
83596
84760
  const targetPagesInSiteMap = pages.filter((p) => targetPages.includes(p.route));
83597
84761
  const hasAuthPage = targetPagesInSiteMap.some(isAuthPage);
83598
- const hasAuthRequiredPage = targetPagesInSiteMap.some((p) => p.requiresAuth && !isAuthPage(p));
84762
+ const hasAuthRequiredPage = siteHasAuthEvidence && targetPagesInSiteMap.some((p) => p.requiresAuth && !isAuthPage(p));
83599
84763
  if (hasAuthPage && !hasAuthRequiredPage)
83600
84764
  return ["@auth", "@discovered"];
83601
84765
  if (hasAuthRequiredPage)
@@ -83603,7 +84767,7 @@ var require_discover_generator = __commonJS({
83603
84767
  return ["@discovered", "@regression"];
83604
84768
  }
83605
84769
  function shouldReplayAuthRecipe(targetPages, siteMapPages, recipe) {
83606
- return !!recipe && computeSkeletonTags(targetPages, siteMapPages).includes("@needs-auth");
84770
+ return !!recipe && computeSkeletonTags(targetPages, siteMapPages, { extraAuthEvidence: true }).includes("@needs-auth");
83607
84771
  }
83608
84772
  function isLowConfidenceSnapshotKind(kind) {
83609
84773
  return kind === "missing" || kind === "error" || kind === "timeout" || kind === "empty" || kind === "ai-capture_fallback";
@@ -83762,6 +84926,15 @@ ${elementContext}${formContext}${observationContext}${interactionContext}${lowCo
83762
84926
  return assembled.join(separator);
83763
84927
  }
83764
84928
  var SENSITIVE_ENV_KEY_RE = /email|password|token|secret|api[_-]?key|access[_-]?key|username|user(name)?|bearer|session|cookie|\bpat\b|refresh|jwt/i;
84929
+ exports.VALID_PLAYWRIGHT_GETBY_METHODS = /* @__PURE__ */ new Set([
84930
+ "getByRole",
84931
+ "getByText",
84932
+ "getByLabel",
84933
+ "getByPlaceholder",
84934
+ "getByTestId",
84935
+ "getByAltText",
84936
+ "getByTitle"
84937
+ ]);
83765
84938
  var VALID_PLAYWRIGHT_MATCHERS = /* @__PURE__ */ new Set([
83766
84939
  // Locator assertions
83767
84940
  "toBeAttached",
@@ -84049,6 +85222,16 @@ ${elementContext}${formContext}${observationContext}${interactionContext}${lowCo
84049
85222
  function findSensitiveLiteralLeaks(code, testVariables) {
84050
85223
  return buildSensitiveEnvReplacements(testVariables).filter((replacement) => code.includes(replacement.value)).map((replacement) => replacement.key);
84051
85224
  }
85225
+ function gotoBasePathFromBaseUrl(baseUrl) {
85226
+ if (!baseUrl)
85227
+ return void 0;
85228
+ try {
85229
+ const path = new URL(baseUrl).pathname.replace(/\/+$/, "");
85230
+ return path && path !== "/" ? path : void 0;
85231
+ } catch {
85232
+ return void 0;
85233
+ }
85234
+ }
84052
85235
  function sanitizeCode(code, options) {
84053
85236
  let result = code;
84054
85237
  const isNetworkIdleWaitLine = (line) => /^\s*await page\.waitForLoadState\((['"])networkidle\1\);\s*$/.test(line);
@@ -84071,12 +85254,24 @@ ${elementContext}${formContext}${observationContext}${interactionContext}${lowCo
84071
85254
  result = result.replace(/\s*await page\.waitForTimeout\(\d+\);\s*/g, "\n");
84072
85255
  result = result.replace(/(await page\.goto\((?:[^()()]|\([^()]*\))*\);)\s*\n\s*await expect\(page\)\.toHaveURL\((?:[^()()]|\([^()]*\))*\);/g, (_match, gotoLine) => gotoLine);
84073
85256
  result = result.replace(/const baseUrl\s*=\s*process\.env\.BASE_URL[^;]*;\s*\n/g, "");
84074
- result = result.replace(/page\.goto\(`\$\{baseUrl\}([^`]*)`/g, (_match, path) => `page.goto('${path}'`);
85257
+ result = result.replace(
85258
+ /page\.goto\(`\$\{baseUrl\}([^`]*)`/g,
85259
+ // Empty template path (goto(`${baseUrl}`)) means the app home → '/', which the
85260
+ // path-scoped basePath pass below re-joins to the real base (e.g. '/wing').
85261
+ (_match, path) => `page.goto('${path || "/"}'`
85262
+ );
84075
85263
  result = result.replace(/test\s*\(\s*(['"`].*?['"`])\s*,\s*\{\s*tags\s*:\s*\[/g, (match) => match.replace("tags", "tag"));
84076
85264
  result = result.replace(/page\.goto\(\s*['"]https?:\/\/[^/'"]+(\/[^'"]*)?['"]/g, (_match, path) => `page.goto('${path || "/"}'`);
84077
85265
  result = result.replace(/waitForURL\(\s*['"]https?:\/\/[^/'"]+(\/.+?)['"]\s*\)/g, (_match, path) => `waitForURL(/${path.replace(/\//g, "\\/")}/)`);
84078
85266
  result = result.replace(/toHaveURL\(\s*['"]https?:\/\/[^/'"]+(\/.+?)['"]\s*\)/g, (_match, path) => `toHaveURL(/${path.replace(/\//g, "\\/")}/)`);
84079
85267
  result = result.replace(/(waitForURL|toHaveURL)\(\/(.*?)\\\/\/\)/g, (_match, fn, inner) => `${fn}(/${inner}/)`);
85268
+ const basePath = options?.basePath && options.basePath !== "/" ? options.basePath.replace(/\/+$/, "") : void 0;
85269
+ if (basePath) {
85270
+ result = result.replace(/\b(goto|waitForURL|toHaveURL)\(\s*(['"])(\/(?!\/)[^'"]*)\2/g, (_match, fn, _quote, path) => {
85271
+ const joined = path === basePath || path.startsWith(basePath + "/") ? path : basePath + (path === "/" ? "" : path);
85272
+ return `${fn}('${joined}'`;
85273
+ });
85274
+ }
84080
85275
  result = result.replace(/\)\)\.(first\(\)|nth\(\d+\)|last\(\))\./g, (match, method) => `).${method}).`);
84081
85276
  result = result.replace(/^(\s+)expect\(page\b/gm, "$1await expect(page");
84082
85277
  result = result.replace(/^(\s+)page\.waitForURL\(/gm, "$1await page.waitForURL(");
@@ -84143,7 +85338,8 @@ ${testOpen}`;
84143
85338
  } : void 0;
84144
85339
  const playwrightCode = sanitizeCode(String(test.playwrightCode || ""), {
84145
85340
  sensitiveEnvMap: options?.sensitiveEnvMap,
84146
- authStamp
85341
+ authStamp,
85342
+ basePath: options?.basePath
84147
85343
  });
84148
85344
  const leakedSensitiveKeys = findSensitiveLiteralLeaks(playwrightCode, options?.sensitiveEnvMap);
84149
85345
  const authGuardStamped = !!options?.authRecipe && rawTags.includes("@needs-auth") && !rawTags.includes("@auth") && process.env.SHARED_AUTH_INJECT === "true";
@@ -84226,6 +85422,16 @@ ${testOpen}`;
84226
85422
  return false;
84227
85423
  }
84228
85424
  }
85425
+ if ((0, discovery_lint_flags_js_1.isPwMethodPreflightEnabled)()) {
85426
+ const getByCalls = codeForMatcherScan.match(/\.getBy[A-Za-z0-9_]*\s*\(/g) ?? [];
85427
+ for (const call of getByCalls) {
85428
+ const methodName = call.replace(/[\s(]+$/, "").slice(1);
85429
+ if (!exports.VALID_PLAYWRIGHT_GETBY_METHODS.has(methodName)) {
85430
+ logs2?.push(` \u2717 Rejected "${t.name}": invalid Playwright locator method "${methodName}" (not a real getBy* API \u2014 likely hallucinated)`);
85431
+ return false;
85432
+ }
85433
+ }
85434
+ }
84229
85435
  if (code.includes("// TODO")) {
84230
85436
  logs2?.push(` \u26A0 "${t.name}" contains TODO comment \u2014 marking as unverified`);
84231
85437
  t.tags = t.tags.filter((tag) => tag !== "@verified");
@@ -85339,6 +86545,35 @@ ${testOpen}`;
85339
86545
  }
85340
86546
  return null;
85341
86547
  }
86548
+ var STRUCTURAL_ACTION_RE = /\.(?:click|dblclick|tap|fill|type|pressSequentially|press|selectOption|hover|check|uncheck|setChecked|setInputFiles|dragTo|focus|clear)\s*\(/;
86549
+ var POSITIONAL_PSEUDO_RE = /:(?:last-child|first-child|nth-child|nth-of-type|nth-last-child|nth-last-of-type)\b/i;
86550
+ var TABLE_TOKEN_RE = /\b(?:tr|td|th|tbody|thead)\b/i;
86551
+ var ROW_ANCHOR_RE = /getByRole\s*\(\s*['"`]row['"`]|getByTestId|data-testid|data-test\b|\[role\s*=\s*['"`]row['"`]\]/i;
86552
+ var LOCATOR_ARG_RE = /\.locator\(\s*(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/g;
86553
+ function scanFragileStructuralLocators(code) {
86554
+ const findings = [];
86555
+ const scanCode = code.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
86556
+ for (const rawLine of scanCode.split("\n")) {
86557
+ const line = rawLine.replace(/\/\/.*$/, "");
86558
+ if (!STRUCTURAL_ACTION_RE.test(line))
86559
+ continue;
86560
+ if (ROW_ANCHOR_RE.test(line))
86561
+ continue;
86562
+ LOCATOR_ARG_RE.lastIndex = 0;
86563
+ let m;
86564
+ while ((m = LOCATOR_ARG_RE.exec(line)) !== null) {
86565
+ const css = m[2];
86566
+ if (POSITIONAL_PSEUDO_RE.test(css) && TABLE_TOKEN_RE.test(css)) {
86567
+ findings.push({
86568
+ line: rawLine.trim().slice(0, 200),
86569
+ reason: `Positional table-row selector "${css.slice(0, 120)}" is not scoped to a row identity \u2014 it breaks when rows reorder, sort, filter, or a row is inserted. Scope through the row: page.getByRole('row', { name: '<a stable cell value in that row>' }).getByRole('button', { name: '<verb>' }).`
86570
+ });
86571
+ break;
86572
+ }
86573
+ }
86574
+ }
86575
+ return findings;
86576
+ }
85342
86577
  function validateFinalActionFidelity(playwrightCode, actionLedger, credentials, opts) {
85343
86578
  const ledgerSigs = [];
85344
86579
  for (const entry of actionLedger) {
@@ -85545,7 +86780,11 @@ ${outcomesText}
85545
86780
  ${hasVerifiedRecipe ? "The browser is ALREADY authenticated \u2014 `__vqaLogin(page)` ran first; cookies/session are populated. Do NOT log in again; begin from the authenticated app." : "The browser starts on about:blank with NO cookies, NO session, NO localStorage. You are NOT logged in."}
85546
86781
  The base URL is: ${baseUrl}
85547
86782
  When navigating, use \`browser_navigate\` with the full URL (prepend ${baseUrl}).
85548
- In your generated test code, keep URLs RELATIVE (e.g. \`page.goto('/path')\`) \u2014 Playwright config has baseURL set.
86783
+ In your generated test code, keep URLs RELATIVE (e.g. \`page.goto('/path')\`) \u2014 Playwright config has baseURL set.${(() => {
86784
+ const basePath = gotoBasePathFromBaseUrl(baseUrl);
86785
+ return basePath ? `
86786
+ IMPORTANT: the base URL has a PATH (\`${basePath}\`), and Playwright's baseURL only prefixes relative paths \u2014 a leading-slash path escapes to the origin root. Every \`page.goto\` path MUST start with \`${basePath}\` (the app home is \`page.goto('${basePath}')\`, NOT \`page.goto('/')\`).` : "";
86787
+ })()}
85549
86788
 
85550
86789
  ${evidenceBlock}${credentialNote}
85551
86790
  ## ASSERTION VERIFICATION WORKFLOW
@@ -85753,7 +86992,9 @@ ${tailBlock}`;
85753
86992
  }
85754
86993
  }
85755
86994
  });
85756
- const tags = computeSkeletonTags(scenario.targetPages, siteMap.pages);
86995
+ const tags = computeSkeletonTags(scenario.targetPages, siteMap.pages, {
86996
+ extraAuthEvidence: !!opts?.recipe || hasConfiguredLoginCredentials(credentials)
86997
+ });
85757
86998
  const baseSkeletonHadRegression = tags.includes("@regression");
85758
86999
  const hasVerifiedRecipe = !!opts?.recipe && tags.includes("@needs-auth");
85759
87000
  const systemPrompt = buildScenarioSystemPrompt(scenario, baseUrl, credentials, tags, siteMap.pages, { appBrief: opts?.appBrief, availableVariableKeys: opts?.availableVariableKeys, hasVerifiedRecipe, knowledgeContext: opts?.knowledgeContext });
@@ -85772,6 +87013,7 @@ ${tailBlock}`;
85772
87013
  let actionRevisionAttempted = false;
85773
87014
  let ambiguityRevisionAttempted = false;
85774
87015
  let strengthRevisionAttempted = false;
87016
+ let structuralRevisionAttempted = false;
85775
87017
  let lastRenderedLedgerEntryCount = 0;
85776
87018
  const successfulVerifications = [];
85777
87019
  const actionLedger = [];
@@ -85962,7 +87204,7 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
85962
87204
  const suspectedBugDeclared = toolArgs.suspectedBug === true;
85963
87205
  const bugDescription = String(toolArgs.bugDescription || "").slice(0, 500).replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/\s+/g, " ").trim();
85964
87206
  const testObj = { name, description, playwrightCode: code, tags };
85965
- const validated = validateTests([testObj], logs2, { sensitiveEnvMap: credentials, authRecipe: opts?.recipe ?? null });
87207
+ const validated = validateTests([testObj], logs2, { sensitiveEnvMap: credentials, authRecipe: opts?.recipe ?? null, basePath: gotoBasePathFromBaseUrl(baseUrl) });
85966
87208
  if (validated.length > 0) {
85967
87209
  if (actionLedger.length > lastRenderedLedgerEntryCount && actionLedger.length > 0) {
85968
87210
  const renderedLedger = renderSessionRecording(actionLedger, credentials);
@@ -86042,6 +87284,33 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
86042
87284
  });
86043
87285
  continue;
86044
87286
  }
87287
+ const structuralLintMode = (0, discovery_lint_flags_js_1.structuralLocatorLintMode)();
87288
+ const structuralFindings = structuralLintMode !== "off" ? scanFragileStructuralLocators(validated[0].playwrightCode) : [];
87289
+ if (structuralLintMode === "warn" && structuralFindings.length > 0) {
87290
+ logs2.push(` Structural-locator gate [warn]: ${structuralFindings.length} positional table-row selector(s) \u2014 ${structuralFindings.map((f) => sanitizeLedgerText(f.line)).join(" | ").slice(0, 300)}`);
87291
+ }
87292
+ if (structuralLintMode === "enforce" && structuralFindings.length > 0 && !structuralRevisionAttempted) {
87293
+ structuralRevisionAttempted = true;
87294
+ const renderedLedger = actionLedger.length > 0 ? renderSessionRecording(actionLedger, credentials) : "";
87295
+ if (renderedLedger)
87296
+ lastRenderedLedgerEntryCount = actionLedger.length;
87297
+ logs2.push(` Structural-locator gate: ${structuralFindings.length} positional table-row selector(s) found \u2014 requesting one revision (recording attached: ${renderedLedger ? "yes" : "no"})`);
87298
+ const structuralPayload = {
87299
+ acknowledged: false,
87300
+ valid: false,
87301
+ revisionRequired: true,
87302
+ reason: "fragile_structural_locator",
87303
+ instruction: "One or more locators below select a table row by POSITION (tr:last-child, tr:nth-child, td:nth-child) instead of by identity. Positional row selectors break the moment rows are reordered, sorted, filtered, or a row is inserted.\nFor EACH flagged locator:\n1. Scope through the row IDENTITY: page.getByRole('row', { name: '<a stable cell value in that row \u2014 an id, name, email>' }).getByRole('button', { name: '<verb>' }).\n2. If the row genuinely has no stable text, use a data-testid on the row if one exists.\n3. Keep every other line exactly as-is. Submit the COMPLETE corrected test and call finish_generate again.",
87304
+ offendingLocators: structuralFindings.map((f) => ({
87305
+ line: sanitizeLedgerText(f.line),
87306
+ reason: f.reason
87307
+ }))
87308
+ };
87309
+ if (renderedLedger)
87310
+ structuralPayload.sessionRecording = renderedLedger;
87311
+ toolResults.push({ role: "tool", tool_call_id: toolCall.id, content: JSON.stringify(structuralPayload) });
87312
+ continue;
87313
+ }
86045
87314
  const ambiguityGateEval = evaluateAmbiguityGate(validated[0].playwrightCode, ambiguityRevisionAttempted, iteration < MAX_SCENARIO_ITERATIONS);
86046
87315
  const ambiguousLocators = ambiguityGateEval.findings;
86047
87316
  if (ambiguityGateEval.fire) {
@@ -86081,7 +87350,12 @@ Do NOT add steps you did not execute. Do NOT skip steps from the recording.${led
86081
87350
  const creditedControlSig = revealVerdict === "revealed" ? assertedSigs.find((s) => revealedControls.has(s)) ?? null : null;
86082
87351
  let strengthVerdict = null;
86083
87352
  try {
86084
- strengthVerdict = (0, shared_1.validateAssertionStrength)(validated[0].playwrightCode, { revealedControls });
87353
+ strengthVerdict = (0, shared_1.validateAssertionStrength)(validated[0].playwrightCode, {
87354
+ revealedControls,
87355
+ // Opt-in additive-contradiction gate (see assertion-strength.ts). On Fargate this
87356
+ // env must be dispatcher-forwarded to take effect here; off → byte-identical.
87357
+ flagContradictedAdditions: process.env.STRENGTH_ADDITIVE_CONTRADICTION_GATE === "true"
87358
+ });
86085
87359
  } catch (err) {
86086
87360
  logs2.push(` Strength gate errored \u2014 skipping (fail-open): ${String(err)}`);
86087
87361
  }
@@ -86133,12 +87407,16 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
86133
87407
  const strengthSeverity = strengthVerdict?.severity ?? "none";
86134
87408
  const hasResidualViolations = qualityCheck.offendingLines.length > 0;
86135
87409
  const hasResidualActionDrift = actionCheck.offendingActions.length > 0;
87410
+ const hasResidualStructural = structuralLintMode === "enforce" && structuralFindings.length > 0;
86136
87411
  if (hasResidualViolations) {
86137
87412
  logs2.push(` Assertion quality gate: violations remain after revision \u2014 downgrading to @needs-review`);
86138
87413
  }
86139
87414
  if (hasResidualActionDrift) {
86140
87415
  logs2.push(` Action fidelity gate: ${actionCheck.offendingActions.length} action drift(s) remain after revision \u2014 downgrading to @needs-review`);
86141
87416
  }
87417
+ if (hasResidualStructural) {
87418
+ logs2.push(` Structural-locator gate: ${structuralFindings.length} positional table-row selector(s) remain after revision \u2014 downgrading to @needs-review`);
87419
+ }
86142
87420
  let actualVerified = verified;
86143
87421
  if (verified && verifyCallCount === 0) {
86144
87422
  actualVerified = false;
@@ -86191,6 +87469,16 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
86191
87469
  residual: ambiguousLocators.length,
86192
87470
  lines: ambiguousLocators.map((a) => sanitizeLedgerText(a.line))
86193
87471
  },
87472
+ // G2 — omitted entirely when off so the off-path healingContext is
87473
+ // byte-identical; present in warn (measure) + enforce (act) modes.
87474
+ ...structuralLintMode !== "off" ? {
87475
+ structuralGate: {
87476
+ mode: structuralLintMode,
87477
+ fired: structuralRevisionAttempted,
87478
+ residual: structuralFindings.length,
87479
+ lines: structuralFindings.map((f) => sanitizeLedgerText(f.line))
87480
+ }
87481
+ } : {},
86194
87482
  // Durable marker the server promote-seam (failure-analyzer.checkTestStability)
86195
87483
  // reads to decide ACTIVE vs QUARANTINED(@bug-found) vs hold-DRAFT. Omitted when
86196
87484
  // the gate errored (fail-open) so a missing marker is treated as "promote".
@@ -86216,7 +87504,7 @@ Do NOT assert that the button/tab/heading you interacted with is merely visible
86216
87504
  verified: actualVerified,
86217
87505
  healingContext: { ...baseHealingContext, gateActivity }
86218
87506
  };
86219
- if ((hasResidualViolations || hasResidualActionDrift || hasUncoveredOutcomes) && !result.tags.includes("@needs-review")) {
87507
+ if ((hasResidualViolations || hasResidualActionDrift || hasUncoveredOutcomes || hasResidualStructural) && !result.tags.includes("@needs-review")) {
86220
87508
  result.tags.push("@needs-review");
86221
87509
  }
86222
87510
  if (actualVerified) {
@@ -301293,7 +302581,8 @@ ${currentError.slice(0, 2e3)}`
301293
302581
  }
301294
302582
  lastHealedCode = (0, discover_generator_js_1.sanitizeCode)(stripCodeFences(fixedCode), {
301295
302583
  sensitiveEnvMap: testVariables,
301296
- authStamp: (0, auth_recipe_js_1.buildHealAuthStamp)(playwrightCode, lastFailureError, logs2)
302584
+ authStamp: (0, auth_recipe_js_1.buildHealAuthStamp)(playwrightCode, lastFailureError, logs2),
302585
+ basePath: (0, discover_generator_js_1.gotoBasePathFromBaseUrl)(baseUrl)
301297
302586
  });
301298
302587
  logs2.push("\u{1F680} Running fixed code natively...");
301299
302588
  let verifyResult;
@@ -302747,7 +304036,8 @@ ${errorContent}`;
302747
304036
  }
302748
304037
  const candidateCode = (0, discover_generator_js_1.sanitizeCode)(stripCodeFences(fixedCode), {
302749
304038
  sensitiveEnvMap: testVariables,
302750
- authStamp: (0, auth_recipe_js_1.buildHealAuthStamp)(playwrightCode, options?.lastFailureError, logs2)
304039
+ authStamp: (0, auth_recipe_js_1.buildHealAuthStamp)(playwrightCode, options?.lastFailureError, logs2),
304040
+ basePath: (0, discover_generator_js_1.gotoBasePathFromBaseUrl)(baseUrl)
302751
304041
  });
302752
304042
  {
302753
304043
  const syntaxCheckCode = (0, discover_generator_js_1.stripTypescriptSyntax)((0, discover_generator_js_1.stripImportDeclarations)(candidateCode));
@@ -304494,7 +305784,8 @@ var require_grok_per_test_healer = __commonJS({
304494
305784
  }
304495
305785
  const healedCode = (0, discover_generator_js_1.sanitizeCode)(payload.playwrightCode, {
304496
305786
  sensitiveEnvMap: secrets,
304497
- authStamp: (0, auth_recipe_js_1.buildHealAuthStamp)(playwrightCode, options.lastFailureError, logs2)
305787
+ authStamp: (0, auth_recipe_js_1.buildHealAuthStamp)(playwrightCode, options.lastFailureError, logs2),
305788
+ basePath: (0, discover_generator_js_1.gotoBasePathFromBaseUrl)(baseUrl)
304498
305789
  });
304499
305790
  const leakedKey = findLeakedCredential(healedCode, secrets);
304500
305791
  if (leakedKey) {
@@ -305667,7 +306958,8 @@ var require_grok_batch_healer = __commonJS({
305667
306958
  }
305668
306959
  const healedCode = (0, discover_generator_js_1.sanitizeCode)(verdict.playwrightCode, {
305669
306960
  sensitiveEnvMap: secrets,
305670
- authStamp: (0, auth_recipe_js_1.buildHealAuthStamp)(test.playwrightCode, test.lastFailureError, logs2)
306961
+ authStamp: (0, auth_recipe_js_1.buildHealAuthStamp)(test.playwrightCode, test.lastFailureError, logs2),
306962
+ basePath: (0, discover_generator_js_1.gotoBasePathFromBaseUrl)(baseUrl)
305671
306963
  });
305672
306964
  const leakedKey = findLeakedCredential(healedCode, secrets);
305673
306965
  if (leakedKey) {
@@ -310679,7 +311971,7 @@ var require_discover_explorer = __commonJS({
310679
311971
  },
310680
311972
  requiresAuth: {
310681
311973
  type: "boolean",
310682
- description: "Whether this page requires authentication"
311974
+ description: "OBSERVED evidence only: true if reaching this page logged-out redirected to a login page, returned 401/403, or showed an explicit sign-in wall. Never infer from content looking account-ish \u2014 a page you read without logging in does NOT require auth."
310683
311975
  },
310684
311976
  observations: {
310685
311977
  type: "array",
@@ -310908,7 +312200,7 @@ You do NOT have a \`record_journey\` tool - your job is mapping, not testing. Do
310908
312200
  2. Action buttons: submit, save, delete, create, edit - include label, triggersSubmit
310909
312201
  3. Navigation links to OTHER pages within the app - include label, triggersNavigation
310910
312202
  4. Skip repeated navigation chrome you already cataloged (sidebar/header links appear on every page)
310911
- - **requiresAuth**: whether the page needs login
312203
+ - **requiresAuth**: OBSERVED evidence only \u2014 you were redirected to a login page, got 401/403, or hit a sign-in wall. If you are reading the page without having logged in, it does NOT require auth (account-ish CONTENT is not evidence)
310912
312204
  - **observations**: empty array (you are mapping, not interacting)
310913
312205
 
310914
312206
  ## Loading and readiness
@@ -314606,9 +315898,13 @@ var require_agentic_survey_prompts = __commonJS({
314606
315898
  ' route: string; // path-only, e.g. "/dashboard/profile"',
314607
315899
  ' type: string; // "AUTH" | "DASHBOARD" | "FORM" | "LIST" | ...',
314608
315900
  " summary: string; // 1-line of key elements/affordances",
314609
- " requiresAuth?: boolean; // true if reaching this page logged-out",
314610
- " // redirected to /login OR content was",
314611
- " // visibly gated behind authentication",
315901
+ " requiresAuth?: boolean; // OBSERVED evidence ONLY: you were redirected",
315902
+ " // to a login page, got 401/403, or hit an",
315903
+ " // explicit sign-in wall. NEVER infer it from",
315904
+ " // content merely LOOKING account-ish \u2014 if you",
315905
+ " // read the page without logging in, it does",
315906
+ " // NOT require auth. A site with no login at",
315907
+ " // all has requiresAuth=false everywhere.",
314612
315908
  " }>;",
314613
315909
  ' flows?: string[]; // ["/login \u2192 /dashboard (submit form)"]',
314614
315910
  " observations?: string[]; // things YOU noticed in the loop",
@@ -320442,675 +321738,6 @@ ${(0, inbox_tools_js_1.buildInboxAgentsGuidance)(inboxReg.email, inboxReg.passwo
320442
321738
  }
320443
321739
  });
320444
321740
 
320445
- // ../runner-core/dist/services/mobile/mobile-exploration-state.js
320446
- var require_mobile_exploration_state = __commonJS({
320447
- "../runner-core/dist/services/mobile/mobile-exploration-state.js"(exports) {
320448
- "use strict";
320449
- Object.defineProperty(exports, "__esModule", { value: true });
320450
- exports.MobileExplorationState = void 0;
320451
- exports.stableMobileControlIdentity = stableMobileControlIdentity;
320452
- exports.classifySafeMobileNavigationControl = classifySafeMobileNavigationControl;
320453
- exports.deriveScreenId = deriveScreenId;
320454
- var mobile_observation_js_1 = require_mobile_observation();
320455
- var DEFAULT_STALE_THRESHOLD = 4;
320456
- var MAX_SCREEN_OBSERVATIONS = 50;
320457
- var EDITABLE_CONTROL_ROLE_RE = /(?:android\.widget\.(?:EditText|AutoCompleteTextView)|XCUIElementType(?:TextField|SecureTextField|SearchField|TextView)|textbox|searchbox|combobox|textarea|select)$/i;
320458
- var STATEFUL_CONTROL_ROLE_RE = /(?:Switch|CheckBox|Checkbox|RadioButton|radio|Slider|SeekBar|Stepper|PickerWheel|Toggle)$/i;
320459
- var UNSAFE_OR_COMMIT_LABEL_RE = /\b(?:submit|save|confirm|delete|remove|archive|reset|disconnect|deactivate|destroy|purge|pay|purchase|buy|checkout|place\s+order|send|post|publish|upload|import|export|sign\s*out|log\s*out|logout|accept|agree|subscribe|unsubscribe|follow|unfollow|enable|disable|book|reserve|cancel|continue|next|previous|done|apply|retry|close|dismiss|sign\s*in|log\s*in|register)\b/i;
320460
- var SAFE_MODAL_TRIGGER_LABEL_RE = /^(?:create|new|add|edit|invite|configure|manage|details|preview|share)$/i;
320461
- var MENU_TRIGGER_LABEL_RE = /^(?:more|menu|options|actions|filter|filters|sort|columns|customize)$/i;
320462
- var GENERIC_NAVIGATION_DESTINATION_LABEL_RE = /^(?:home|feed|dashboard|profile|settings|inbox|messages?|notifications?|projects?|about|privacy|terms|learn\s+more)$/i;
320463
- var SYSTEM_BACK_CONTROL_LABEL_RE = /^(?:back|go\s+back|navigate\s+up|navigation\s+up|up)$/i;
320464
- var SYSTEM_BACK_CONTROL_ID_RE = /^(?:action_bar_up|arrow_back|back_arrow|navigate_up|navigation_up|nav_up|toolbar_back|action_back|btn_back|button_back|back_button|back_btn|back_icon|icon_back)$/i;
320465
- function normalizeControlIdentityPart(value) {
320466
- return value.trim().replace(/\s+/g, " ").toLowerCase();
320467
- }
320468
- function semanticControlAlias(label) {
320469
- return `navigation-label:${normalizeControlIdentityPart(label)}`;
320470
- }
320471
- function localControlCoverageIdentity(screenId, identity) {
320472
- return `screen:${screenId}:${identity}`;
320473
- }
320474
- function storedMobileControlIdentity(candidate) {
320475
- return [
320476
- candidate.identity,
320477
- `kind:${candidate.kind}`,
320478
- `label:${normalizeControlIdentityPart(candidate.label)}`
320479
- ].join(":");
320480
- }
320481
- function isSystemBackNavigationControl(element, label) {
320482
- if (SYSTEM_BACK_CONTROL_LABEL_RE.test(normalizeControlIdentityPart(label)))
320483
- return true;
320484
- return [element.accessibilityId, element.resourceId].filter((value) => Boolean(value?.trim())).some((value) => {
320485
- const leaf = normalizeControlIdentityPart(value).split(/[/:.]/).filter(Boolean).at(-1) ?? "";
320486
- return SYSTEM_BACK_CONTROL_ID_RE.test(leaf);
320487
- });
320488
- }
320489
- function stableMobileControlIdentity(element) {
320490
- const role = normalizeControlIdentityPart(element.role || "control");
320491
- const accessibilityId = element.accessibilityId?.trim();
320492
- if (accessibilityId)
320493
- return `a11y:${role}:${normalizeControlIdentityPart(accessibilityId)}`;
320494
- const resourceId = element.resourceId?.trim();
320495
- if (resourceId)
320496
- return `resource:${role}:${normalizeControlIdentityPart(resourceId)}`;
320497
- const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
320498
- if (label)
320499
- return `semantic:${role}:${normalizeControlIdentityPart(label)}`;
320500
- return null;
320501
- }
320502
- function classifySafeMobileNavigationControl(element) {
320503
- if (!element.actionable || element.enabled === false)
320504
- return null;
320505
- const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
320506
- const safetyIdentity = [label, element.accessibilityId, element.resourceId].filter(Boolean).join(" ");
320507
- if (!label || UNSAFE_OR_COMMIT_LABEL_RE.test(safetyIdentity))
320508
- return null;
320509
- if (isSystemBackNavigationControl(element, label))
320510
- return null;
320511
- const role = element.role.trim();
320512
- if (EDITABLE_CONTROL_ROLE_RE.test(role) || STATEFUL_CONTROL_ROLE_RE.test(role))
320513
- return null;
320514
- const linkLike = /(?:Link$|^a$)/i.test(role);
320515
- let kind = null;
320516
- if (/(?:TabBarItem|\btab\b)/i.test(role))
320517
- kind = "tab";
320518
- else if (/menuitem/i.test(role))
320519
- kind = "menu-item";
320520
- else if (linkLike && element.navigationProvenance)
320521
- kind = "link";
320522
- else if (linkLike)
320523
- return null;
320524
- else if (element.hasPopup && element.hasPopup.toLowerCase() !== "false")
320525
- kind = "menu-trigger";
320526
- else if (element.expanded === false)
320527
- kind = "disclosure";
320528
- else if (MENU_TRIGGER_LABEL_RE.test(label))
320529
- kind = "menu-trigger";
320530
- else if (SAFE_MODAL_TRIGGER_LABEL_RE.test(label))
320531
- kind = "modal-trigger";
320532
- else if (element.navigationProvenance)
320533
- kind = "navigation-button";
320534
- else if (GENERIC_NAVIGATION_DESTINATION_LABEL_RE.test(label))
320535
- kind = "navigation-button";
320536
- if (!kind)
320537
- return null;
320538
- const identity = stableMobileControlIdentity(element);
320539
- return identity ? { identity, label, kind } : null;
320540
- }
320541
- function sharedMobileControlCoverageIdentity(element, candidate, semanticFamilyId) {
320542
- if (!element.navigationProvenance)
320543
- return void 0;
320544
- const durableId = element.accessibilityId?.trim() ? `a11y:${normalizeControlIdentityPart(element.accessibilityId)}` : element.resourceId?.trim() ? `resource:${normalizeControlIdentityPart(element.resourceId)}` : "";
320545
- if (!durableId && !semanticFamilyId)
320546
- return void 0;
320547
- return [
320548
- "shared-navigation",
320549
- element.navigationProvenance,
320550
- !durableId && semanticFamilyId ? `family:${semanticFamilyId}` : "",
320551
- candidate.kind,
320552
- durableId || "semantic-only",
320553
- normalizeControlIdentityPart(candidate.label)
320554
- ].filter(Boolean).join(":");
320555
- }
320556
- function mobileNavigationRegion(element, windowSize) {
320557
- if (!windowSize || windowSize.width <= 0 || windowSize.height <= 0)
320558
- return "unknown";
320559
- const centerX = (element.bounds.x + element.bounds.width / 2) / windowSize.width;
320560
- const centerY = (element.bounds.y + element.bounds.height / 2) / windowSize.height;
320561
- if (centerY >= 0.72)
320562
- return "bottom";
320563
- if (centerY <= 0.28)
320564
- return "top";
320565
- if (centerX <= 0.35)
320566
- return "left";
320567
- if (centerX >= 0.65)
320568
- return "right";
320569
- return "center";
320570
- }
320571
- function buildSemanticNavigationGroups(elements, windowSize) {
320572
- const groups = /* @__PURE__ */ new Map();
320573
- for (const element of elements) {
320574
- if (!element.navigationProvenance)
320575
- continue;
320576
- const candidate = classifySafeMobileNavigationControl(element);
320577
- if (!candidate)
320578
- continue;
320579
- const groupKey = `${element.navigationProvenance}:${mobileNavigationRegion(element, windowSize)}`;
320580
- const labels = groups.get(groupKey) ?? /* @__PURE__ */ new Set();
320581
- labels.add(`${candidate.kind}:${normalizeControlIdentityPart(candidate.label)}`);
320582
- groups.set(groupKey, labels);
320583
- }
320584
- return new Map([...groups].filter(([, labels]) => labels.size >= 2));
320585
- }
320586
- function deriveScreenId(signal) {
320587
- const namespace = signal.activity?.trim() || signal.bundleId?.trim();
320588
- return namespace ? `${namespace}#${signal.screenHash}` : signal.screenHash;
320589
- }
320590
- function observedElementIdentity(element) {
320591
- return (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(element);
320592
- }
320593
- function mergeObservedElements(previous, current) {
320594
- const merged = [...current];
320595
- const seen = new Set(current.map(observedElementIdentity));
320596
- for (const element of previous) {
320597
- const identity = observedElementIdentity(element);
320598
- if (seen.has(identity))
320599
- continue;
320600
- seen.add(identity);
320601
- merged.push(element);
320602
- }
320603
- return merged;
320604
- }
320605
- function sanitizeElementsForEvidence(elements) {
320606
- return elements.map((element) => {
320607
- const label = (0, mobile_observation_js_1.safeMobileElementLabel)(element);
320608
- return {
320609
- ...element,
320610
- ...label ? { label } : { label: void 0 },
320611
- text: void 0
320612
- };
320613
- });
320614
- }
320615
- var MobileExplorationState = class {
320616
- /** screenId -> ScreenState (the mobile analog of routeState). */
320617
- screens = /* @__PURE__ */ new Map();
320618
- /** Observed directed transitions between screens. */
320619
- transitions = [];
320620
- /** Transition-announced screens that do not yet have a live observation. */
320621
- frontier = /* @__PURE__ */ new Map();
320622
- /** Safe navigation controls observed on each screen, keyed by stable identity. */
320623
- safeControlsByScreen = /* @__PURE__ */ new Map();
320624
- /** Screen-scoped coverage keys plus explicitly proven shared-navigation keys. */
320625
- attemptedSafeControlCoverageIdentities = /* @__PURE__ */ new Set();
320626
- successfulSafeControlAttemptCount = 0;
320627
- /** Repeated id-less structural navigation families, grouped by region. */
320628
- semanticNavigationFamilies = /* @__PURE__ */ new Map();
320629
- nextSemanticNavigationFamilyId = 1;
320630
- /** The screen-id of the most recently observed screen. */
320631
- currentScreenId = null;
320632
- /** Latest iteration recorded (drives stale detection + firstSeen stamps). */
320633
- iteration = 0;
320634
- /** Stale detector: progress mark (#screens + #total elements) at last advance. */
320635
- lastProgressMark = 0;
320636
- /** Consecutive iterations observed with no progress. */
320637
- staleIterations = 0;
320638
- staleThreshold;
320639
- constructor(options = {}) {
320640
- this.staleThreshold = typeof options.staleThreshold === "number" && options.staleThreshold > 0 ? Math.floor(options.staleThreshold) : DEFAULT_STALE_THRESHOLD;
320641
- }
320642
- // -------------------------------------------------------------------------
320643
- // Observation recording
320644
- // -------------------------------------------------------------------------
320645
- /**
320646
- * Returns true when `signal` identifies a screen this run has never observed
320647
- * before. Pure read — does not mutate state.
320648
- */
320649
- isNewScreen(signal) {
320650
- return !this.screens.has(deriveScreenId(signal));
320651
- }
320652
- /**
320653
- * Record an observation of `screen` at `iteration`. Creates a `ScreenState`
320654
- * on first sight (stamping `firstSeenIteration`), otherwise refreshes the
320655
- * stored element set (latest-wins, mirroring the web reducer's PAGE_CAPTURED
320656
- * element refresh). A driver observation already contains the structural UI
320657
- * tree, elements, screenshot association, and screen identity, so it is
320658
- * captured evidence immediately; the model's capture_screen call only adds
320659
- * a friendly name/type. Returns the resolved screen-id.
320660
- */
320661
- recordObservation(signal, screen, iteration) {
320662
- this.iteration = iteration;
320663
- const screenId = deriveScreenId(signal);
320664
- const evidenceElements = sanitizeElementsForEvidence(screen.elements);
320665
- const existing = this.screens.get(screenId);
320666
- if (existing) {
320667
- this.screens.set(screenId, {
320668
- ...existing,
320669
- elements: mergeObservedElements(existing.elements, evidenceElements)
320670
- });
320671
- } else {
320672
- this.screens.set(screenId, {
320673
- screenId,
320674
- firstSeenIteration: iteration,
320675
- elements: evidenceElements,
320676
- visited: true
320677
- });
320678
- }
320679
- this.frontier.delete(screenId);
320680
- const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
320681
- const semanticNavigationGroups = buildSemanticNavigationGroups(evidenceElements, screen.windowSize);
320682
- const semanticNavigationFamilyIds = /* @__PURE__ */ new Map();
320683
- for (const [groupKey, labels] of semanticNavigationGroups) {
320684
- semanticNavigationFamilyIds.set(groupKey, this.resolveSemanticNavigationFamilyId(groupKey, labels));
320685
- }
320686
- for (const element of evidenceElements) {
320687
- const candidate = classifySafeMobileNavigationControl(element);
320688
- if (!candidate)
320689
- continue;
320690
- const storedIdentity = storedMobileControlIdentity(candidate);
320691
- const semanticGroupKey = element.navigationProvenance ? `${element.navigationProvenance}:${mobileNavigationRegion(element, screen.windowSize)}` : "";
320692
- controls.set(storedIdentity, {
320693
- ...candidate,
320694
- identity: storedIdentity,
320695
- sharedCoverageIdentity: sharedMobileControlCoverageIdentity(element, candidate, semanticNavigationFamilyIds.get(semanticGroupKey))
320696
- });
320697
- }
320698
- if (controls.size > 0)
320699
- this.safeControlsByScreen.set(screenId, controls);
320700
- this.currentScreenId = screenId;
320701
- this.updateStaleProgress();
320702
- return screenId;
320703
- }
320704
- /**
320705
- * Back-compatible acknowledgement from capture_screen. Observation already
320706
- * marks structural evidence visited; this call only resolves a rare pending
320707
- * transition frontier and preserves callers that annotate explicitly.
320708
- */
320709
- markVisited(screenId) {
320710
- const state2 = this.screens.get(screenId);
320711
- if (state2 && !state2.visited) {
320712
- this.screens.set(screenId, { ...state2, visited: true });
320713
- }
320714
- this.frontier.delete(screenId);
320715
- }
320716
- /**
320717
- * Record a confirmed tap against a safe navigation candidate. Callers invoke
320718
- * this only after the driver reports success; recovery-unknown, stale-ref, and
320719
- * failed actions must remain on the breadth frontier.
320720
- */
320721
- noteControlAttempt(screenId, element) {
320722
- if (!screenId)
320723
- return false;
320724
- const candidate = classifySafeMobileNavigationControl(element);
320725
- const controls = this.safeControlsByScreen.get(screenId) ?? /* @__PURE__ */ new Map();
320726
- if (candidate) {
320727
- const storedIdentity = storedMobileControlIdentity(candidate);
320728
- const existing = controls.get(storedIdentity);
320729
- controls.set(storedIdentity, {
320730
- ...candidate,
320731
- identity: storedIdentity,
320732
- sharedCoverageIdentity: existing?.sharedCoverageIdentity ?? sharedMobileControlCoverageIdentity(element, candidate)
320733
- });
320734
- this.safeControlsByScreen.set(screenId, controls);
320735
- }
320736
- const tappedLabel = (0, mobile_observation_js_1.safeMobileElementLabel)(element)?.trim();
320737
- if (!tappedLabel)
320738
- return false;
320739
- const before = this.attemptedSafeControlCoverageIdentities.size;
320740
- const alias = semanticControlAlias(tappedLabel);
320741
- for (const control of controls.values()) {
320742
- if (semanticControlAlias(control.label) === alias) {
320743
- this.attemptedSafeControlCoverageIdentities.add(localControlCoverageIdentity(screenId, control.identity));
320744
- if (control.sharedCoverageIdentity) {
320745
- this.attemptedSafeControlCoverageIdentities.add(control.sharedCoverageIdentity);
320746
- }
320747
- }
320748
- }
320749
- if (this.attemptedSafeControlCoverageIdentities.size > before) {
320750
- this.successfulSafeControlAttemptCount += 1;
320751
- this.staleIterations = 0;
320752
- return true;
320753
- }
320754
- return false;
320755
- }
320756
- /** Attach a human-friendly name / coarse type to a screen (capture_screen). */
320757
- annotateScreen(screenId, annotation) {
320758
- const state2 = this.screens.get(screenId);
320759
- if (!state2)
320760
- return;
320761
- this.screens.set(screenId, {
320762
- ...state2,
320763
- screenName: annotation.screenName ?? state2.screenName,
320764
- screenType: annotation.screenType ?? state2.screenType
320765
- });
320766
- }
320767
- /** Attach structurally observed authentication provenance to a screen. */
320768
- setScreenAuthEvidence(screenId, evidence) {
320769
- const state2 = this.screens.get(screenId);
320770
- if (!state2)
320771
- return;
320772
- this.screens.set(screenId, {
320773
- ...state2,
320774
- requiresAuth: evidence.requiresAuth,
320775
- authProvenance: evidence.authProvenance,
320776
- sessionState: { ...evidence.sessionState }
320777
- });
320778
- }
320779
- /** Append a free-form observation note to a screen (observations tool). */
320780
- addScreenObservation(screenId, note) {
320781
- const state2 = this.screens.get(screenId);
320782
- if (!state2)
320783
- return;
320784
- const normalized = note.trim();
320785
- if (!normalized)
320786
- return;
320787
- const current = state2.observations ?? [];
320788
- if (current.some((existing) => existing.trim().toLowerCase() === normalized.toLowerCase())) {
320789
- return;
320790
- }
320791
- const observations = [...current, normalized].slice(-MAX_SCREEN_OBSERVATIONS);
320792
- this.screens.set(screenId, { ...state2, observations });
320793
- }
320794
- /**
320795
- * Record a directed transition between two screens. Both endpoints are
320796
- * ensured to exist on the frontier if not yet observed (mirrors the web
320797
- * reducer adding both ends of a TRANSITION_RECORDED to the frontier). The
320798
- * `toScreenId` becomes the current screen when the transition starts from the
320799
- * current screen.
320800
- */
320801
- recordTransition(transition) {
320802
- let recordedTransition = transition;
320803
- if (!transition.viaControlIdentity && transition.viaRef) {
320804
- const sourceElement = this.screens.get(transition.fromScreenId)?.elements.find((element) => element.ref === transition.viaRef);
320805
- if (sourceElement) {
320806
- recordedTransition = {
320807
- ...transition,
320808
- viaControlIdentity: (0, mobile_observation_js_1.stableParsedMobileElementIdentity)(sourceElement)
320809
- };
320810
- }
320811
- }
320812
- const alreadyRecorded = this.transitions.some((existing) => existing.fromScreenId === recordedTransition.fromScreenId && existing.toScreenId === recordedTransition.toScreenId && existing.action === recordedTransition.action);
320813
- if (!alreadyRecorded) {
320814
- this.transitions.push(recordedTransition);
320815
- }
320816
- for (const endpointId of [transition.fromScreenId, transition.toScreenId]) {
320817
- if (!this.screens.has(endpointId) && !this.frontier.has(endpointId)) {
320818
- this.frontier.set(endpointId, {
320819
- screenId: endpointId,
320820
- source: "transition",
320821
- discoveredAtIteration: this.iteration
320822
- });
320823
- }
320824
- }
320825
- if (transition.fromScreenId === this.currentScreenId) {
320826
- this.currentScreenId = transition.toScreenId;
320827
- }
320828
- }
320829
- // -------------------------------------------------------------------------
320830
- // Selectors
320831
- // -------------------------------------------------------------------------
320832
- /** The current screen-id (most recently observed), or null before any observation. */
320833
- getCurrentScreenId() {
320834
- return this.currentScreenId;
320835
- }
320836
- /** ScreenState for `screenId`, if known. */
320837
- getScreen(screenId) {
320838
- return this.screens.get(screenId);
320839
- }
320840
- /** Number of distinct screens observed this run. */
320841
- getScreenCount() {
320842
- return this.screens.size;
320843
- }
320844
- /** Number of screens backed by a live structural driver observation. */
320845
- getVisitedScreenCount() {
320846
- let total = 0;
320847
- for (const state2 of this.screens.values()) {
320848
- if (state2.visited)
320849
- total += 1;
320850
- }
320851
- return total;
320852
- }
320853
- /** Total elements observed across all known screens (latest snapshot each). */
320854
- getTotalElementCount() {
320855
- let total = 0;
320856
- for (const state2 of this.screens.values()) {
320857
- total += state2.elements.length;
320858
- }
320859
- return total;
320860
- }
320861
- /**
320862
- * Frontier screen-ids ranked by recency (most recently discovered first),
320863
- * excluding any screen already visited. Mirrors the web `getRankedFrontier`
320864
- * intent (filter visited, rank by recency) — without URL prefix similarity,
320865
- * which has no mobile analog.
320866
- */
320867
- getFrontier() {
320868
- return [...this.frontier.values()].filter((item) => {
320869
- const state2 = this.screens.get(item.screenId);
320870
- return !state2 || !state2.visited;
320871
- }).sort((a, b) => b.discoveredAtIteration - a.discoveredAtIteration).map((item) => item.screenId);
320872
- }
320873
- /** Safe navigation candidates not yet attempted, deduped across shared chrome. */
320874
- getUnexploredSafeNavigationCandidates() {
320875
- const authenticatedProductObserved = [...this.screens.values()].some((screen) => screen.requiresAuth === true);
320876
- const candidates = [];
320877
- for (const [screenId, controls] of this.safeControlsByScreen) {
320878
- const origin = this.screens.get(screenId);
320879
- const originIsConfirmedPublicEntry = origin?.authProvenance === "public" || !origin?.authProvenance && origin?.requiresAuth !== true && /^(?:AUTH|LANDING)$/i.test(origin?.screenType ?? "");
320880
- if (authenticatedProductObserved && originIsConfirmedPublicEntry)
320881
- continue;
320882
- for (const control of controls.values()) {
320883
- const alias = semanticControlAlias(control.label);
320884
- const coverageIdentity = control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity);
320885
- if (this.attemptedSafeControlCoverageIdentities.has(coverageIdentity) || this.attemptedSafeControlCoverageIdentities.has(localControlCoverageIdentity(screenId, control.identity)))
320886
- continue;
320887
- const existing = candidates.find((candidate) => candidate.identity === coverageIdentity || candidate.alias === alias && candidate.screenIds.has(screenId));
320888
- if (existing) {
320889
- existing.screenIds.add(screenId);
320890
- } else {
320891
- candidates.push({
320892
- identity: coverageIdentity,
320893
- label: control.label,
320894
- kind: control.kind,
320895
- alias,
320896
- screenIds: /* @__PURE__ */ new Set([screenId])
320897
- });
320898
- }
320899
- }
320900
- }
320901
- return candidates.map((candidate) => ({
320902
- identity: candidate.identity,
320903
- label: candidate.label,
320904
- kind: candidate.kind,
320905
- screenIds: [...candidate.screenIds]
320906
- })).sort((a, b) => {
320907
- const aCurrent = this.currentScreenId !== null && a.screenIds.includes(this.currentScreenId);
320908
- const bCurrent = this.currentScreenId !== null && b.screenIds.includes(this.currentScreenId);
320909
- if (aCurrent !== bCurrent)
320910
- return aCurrent ? -1 : 1;
320911
- return b.screenIds.length - a.screenIds.length;
320912
- });
320913
- }
320914
- /** Harness-owned survey completion floor, independent of model assertions. */
320915
- getSurveyBreadthCoverage() {
320916
- const unvisitedScreenIds = this.getFrontier();
320917
- const unexploredControls = this.getUnexploredSafeNavigationCandidates();
320918
- const complete = unvisitedScreenIds.length === 0 && unexploredControls.length === 0;
320919
- const missing = [];
320920
- if (unvisitedScreenIds.length > 0) {
320921
- missing.push(`${unvisitedScreenIds.length} uncaptured screen(s)`);
320922
- }
320923
- if (unexploredControls.length > 0) {
320924
- missing.push(`${unexploredControls.length} safe navigation control(s)`);
320925
- }
320926
- return {
320927
- required: true,
320928
- complete,
320929
- unvisitedScreenIds,
320930
- unexploredControls,
320931
- attemptedControlCount: this.successfulSafeControlAttemptCount,
320932
- ...missing.length > 0 ? { reason: `Missing ${missing.join(" and ")}.` } : {}
320933
- };
320934
- }
320935
- /**
320936
- * Monotonic structural progress token for adaptive survey leases. Frontier
320937
- * cardinality alone is insufficient: attempting control A can reveal control
320938
- * B and leave the remaining count unchanged even though real work happened.
320939
- */
320940
- getSurveyBreadthProgressMark() {
320941
- const discoveredControlIdentities = /* @__PURE__ */ new Set();
320942
- for (const [screenId, controls] of this.safeControlsByScreen) {
320943
- for (const control of controls.values()) {
320944
- discoveredControlIdentities.add(control.sharedCoverageIdentity ?? localControlCoverageIdentity(screenId, control.identity));
320945
- }
320946
- }
320947
- return [
320948
- `screens:${this.screens.size}`,
320949
- `attempts:${this.successfulSafeControlAttemptCount}`,
320950
- `controls:${discoveredControlIdentities.size}`,
320951
- `transitions:${this.transitions.length}`
320952
- ].join("|");
320953
- }
320954
- resolveSemanticNavigationFamilyId(groupKey, labels) {
320955
- const families = this.semanticNavigationFamilies.get(groupKey) ?? [];
320956
- let best;
320957
- let bestIntersection = 0;
320958
- for (const family of families) {
320959
- let intersection = 0;
320960
- for (const label of labels) {
320961
- if (family.labels.has(label))
320962
- intersection += 1;
320963
- }
320964
- const overlap = intersection / Math.min(labels.size, family.labels.size);
320965
- if (intersection >= 2 && overlap >= 0.6 && intersection > bestIntersection) {
320966
- best = family;
320967
- bestIntersection = intersection;
320968
- }
320969
- }
320970
- if (best) {
320971
- for (const label of labels)
320972
- best.labels.add(label);
320973
- return best.id;
320974
- }
320975
- const created = {
320976
- id: `semantic-nav-family-${this.nextSemanticNavigationFamilyId++}`,
320977
- labels: new Set(labels)
320978
- };
320979
- families.push(created);
320980
- this.semanticNavigationFamilies.set(groupKey, families);
320981
- return created.id;
320982
- }
320983
- /** Observed transitions (defensive copy). */
320984
- getTransitions() {
320985
- return [...this.transitions];
320986
- }
320987
- // -------------------------------------------------------------------------
320988
- // Stale-progress detection (ported semantics from the web stale guard)
320989
- // -------------------------------------------------------------------------
320990
- /**
320991
- * Progress mark: distinct screens weighted heavily, plus total elements seen.
320992
- * A run advances this mark whenever it finds a new screen OR new elements;
320993
- * the stale detector triggers when the mark stops advancing for
320994
- * `staleThreshold` consecutive iterations.
320995
- */
320996
- getProgressMark() {
320997
- return this.screens.size * 1e3 + this.transitions.length * 100 + this.successfulSafeControlAttemptCount * 10 + this.getTotalElementCount();
320998
- }
320999
- updateStaleProgress() {
321000
- const mark = this.getProgressMark();
321001
- if (mark > this.lastProgressMark) {
321002
- this.lastProgressMark = mark;
321003
- this.staleIterations = 0;
321004
- } else {
321005
- this.staleIterations += 1;
321006
- }
321007
- }
321008
- /** Consecutive no-progress iterations observed so far. */
321009
- getStaleIterations() {
321010
- return this.staleIterations;
321011
- }
321012
- /**
321013
- * True when the run has gone `staleThreshold` consecutive iterations with no
321014
- * new screen and no new element — the signal the explorer uses to nudge or
321015
- * finish (mirrors the web explorer's stale guard).
321016
- */
321017
- isStaleProgress() {
321018
- return this.staleIterations >= this.staleThreshold;
321019
- }
321020
- // -------------------------------------------------------------------------
321021
- // Planner evidence export
321022
- // -------------------------------------------------------------------------
321023
- /**
321024
- * Snapshot the screen-graph as a plain object (frozen mobile-types
321025
- * `MobileScreenGraph`). Defensive copies so callers cannot mutate internal
321026
- * state.
321027
- */
321028
- toScreenGraph() {
321029
- const screens = /* @__PURE__ */ new Map();
321030
- for (const [id, state2] of this.screens) {
321031
- screens.set(id, {
321032
- ...state2,
321033
- elements: [...state2.elements],
321034
- observations: state2.observations ? [...state2.observations] : void 0,
321035
- sessionState: state2.sessionState ? { ...state2.sessionState } : void 0
321036
- });
321037
- }
321038
- return {
321039
- screens,
321040
- transitions: this.transitions.map((t) => ({
321041
- fromScreenId: t.fromScreenId,
321042
- toScreenId: t.toScreenId,
321043
- viaRef: t.viaRef,
321044
- viaControlIdentity: t.viaControlIdentity,
321045
- action: t.action
321046
- }))
321047
- };
321048
- }
321049
- /**
321050
- * Build the planner evidence shape: one `CollectedPageData` per screen, with
321051
- * the screen-id carried in BOTH `route` (the on-the-wire positional field the
321052
- * server keys screenshots by) AND the explicit `screenId` field (per the
321053
- * Phase-0 `CollectedPageData.screenId` contract). `elements` are mapped from
321054
- * the screen's parsed actionable elements into the platform-neutral
321055
- * `CollectedPageElement[]` shape the server persists as SiteElements — without
321056
- * this, mobile screens land with zero elements (coverage always 0%, no element
321057
- * intelligence). `triggersNavigation` is derived from the durable source
321058
- * element identity captured when the transition happened (with ref-only
321059
- * fallback for legacy transitions). Insertion order matches first-seen order.
321060
- */
321061
- buildInMemoryScreenMap() {
321062
- const pages = [];
321063
- const ordered = [...this.screens.values()].sort((a, b) => a.firstSeenIteration - b.firstSeenIteration);
321064
- const navRefsByScreen = /* @__PURE__ */ new Map();
321065
- const navIdentitiesByScreen = /* @__PURE__ */ new Map();
321066
- for (const transition of this.transitions) {
321067
- if (transition.viaControlIdentity) {
321068
- const set = navIdentitiesByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
321069
- set.add(transition.viaControlIdentity);
321070
- navIdentitiesByScreen.set(transition.fromScreenId, set);
321071
- } else if (transition.viaRef) {
321072
- const set = navRefsByScreen.get(transition.fromScreenId) ?? /* @__PURE__ */ new Set();
321073
- set.add(transition.viaRef);
321074
- navRefsByScreen.set(transition.fromScreenId, set);
321075
- }
321076
- }
321077
- for (const state2 of ordered) {
321078
- const page = {
321079
- route: state2.screenId,
321080
- screenId: state2.screenId,
321081
- pageType: state2.screenType ?? "UNKNOWN",
321082
- elements: (0, mobile_observation_js_1.buildCollectedElementsForScreen)(state2.elements, {
321083
- navigationRefs: navRefsByScreen.get(state2.screenId),
321084
- navigationIdentities: navIdentitiesByScreen.get(state2.screenId)
321085
- }),
321086
- requiresAuth: state2.requiresAuth ?? false
321087
- };
321088
- if (state2.sessionState) {
321089
- page.sessionState = { ...state2.sessionState };
321090
- }
321091
- if (state2.screenName) {
321092
- page.title = state2.screenName;
321093
- }
321094
- if (state2.observations && state2.observations.length > 0) {
321095
- page.observations = state2.observations.map((note) => ({
321096
- action: "observe",
321097
- outcome: note,
321098
- outcomeType: "SUCCESS"
321099
- }));
321100
- }
321101
- pages.push(page);
321102
- }
321103
- return pages;
321104
- }
321105
- /** Alias for `buildInMemoryScreenMap()` — name parity with the planner-evidence call site. */
321106
- toScreenMap() {
321107
- return this.buildInMemoryScreenMap();
321108
- }
321109
- };
321110
- exports.MobileExplorationState = MobileExplorationState;
321111
- }
321112
- });
321113
-
321114
321741
  // ../runner-core/dist/services/mobile/mobile-discovery-agent.js
321115
321742
  var require_mobile_discovery_agent = __commonJS({
321116
321743
  "../runner-core/dist/services/mobile/mobile-discovery-agent.js"(exports) {
@@ -321406,7 +322033,7 @@ Explore complete: ${explore.exploreStats.pagesDiscovered} screens, ${explore.exp
321406
322033
  };
321407
322034
  }
321408
322035
  if (isSurvey && explore.authCoverage && !explore.authCoverage.complete) {
321409
- throw new Error("Mobile survey authentication coverage is incomplete; planning was skipped. " + (explore.authCoverage.reason ?? "No authenticated product screen was proven."));
322036
+ log2("Mobile survey continuing to planning with incomplete authenticated coverage: " + (explore.authCoverage.reason ?? "No authenticated product screen was proven."));
321410
322037
  }
321411
322038
  if (isSurvey && explore.breadthCoverage && !explore.breadthCoverage.complete) {
321412
322039
  log2("Mobile survey continuing to planning with residual breadth gaps: " + (explore.breadthCoverage.reason ?? "Unvisited screens or safe navigation controls remain."));
@@ -321667,6 +322294,9 @@ Fatal error: ${msg}`);
321667
322294
  if (explore.breadthCoverage && !explore.breadthCoverage.complete) {
321668
322295
  warnings.push("Discovery retained the captured mobile screen graph with residual navigation gaps: " + (explore.breadthCoverage.reason ?? "some observed controls could not be revisited."));
321669
322296
  }
322297
+ if (explore.authCoverage && !explore.authCoverage.complete) {
322298
+ warnings.push("Discovery retained the captured mobile screen graph with incomplete authenticated coverage: " + (explore.authCoverage.reason ?? "no authenticated product screen was proven."));
322299
+ }
321670
322300
  if (explore.truncated) {
321671
322301
  const reasonLabel = explore.truncatedReason === "rate_limit" ? "AI rate-limit" : explore.truncatedReason === "timeout" ? "AI timeout" : "AI error";
321672
322302
  warnings.push(`Discovery may be incomplete: exploration aborted by ${reasonLabel}. Re-run after the limit clears for a complete screen graph.`);
@@ -336176,7 +336806,7 @@ var require_package4 = __commonJS({
336176
336806
  "package.json"(exports, module) {
336177
336807
  module.exports = {
336178
336808
  name: "@validate.qa/runner",
336179
- version: "1.0.37",
336809
+ version: "1.0.39",
336180
336810
  description: "validate local test runner - execute Playwright tests on your machine, connected to validate.qa cloud.",
336181
336811
  bin: {
336182
336812
  "validate-runner": "dist/cli.js"
@@ -339434,8 +340064,9 @@ async function activateTargetApp(sessionId, platform3, appId) {
339434
340064
  const args = platform3 === "IOS" ? [{ bundleId: appId }] : [{ appId }];
339435
340065
  await executeMobileScript(sessionId, "mobile: activateApp", args);
339436
340066
  }
340067
+ var APP_TERMINATE_TIMEOUT_MS = 5e3;
339437
340068
  async function terminateTargetApp(sessionId, platform3, appId) {
339438
- const args = platform3 === "IOS" ? [{ bundleId: appId }] : [{ appId }];
340069
+ const args = platform3 === "IOS" ? [{ bundleId: appId, timeout: APP_TERMINATE_TIMEOUT_MS }] : [{ appId, timeout: APP_TERMINATE_TIMEOUT_MS }];
339439
340070
  await executeMobileScript(sessionId, "mobile: terminateApp", args);
339440
340071
  }
339441
340072
  async function freshLaunchTargetApp(sessionId, platform3, appId, log2) {
@@ -342446,7 +343077,11 @@ var MobileBridgeDriver = class {
342446
343077
  async relaunch() {
342447
343078
  await this.withSessionRecovery("app relaunch", async () => {
342448
343079
  const args = this.appLifecycleArgs();
342449
- await this.executeScript("mobile: terminateApp", [args]);
343080
+ try {
343081
+ await this.executeScript("mobile: terminateApp", [{ ...args, timeout: 5e3 }]);
343082
+ } catch (err) {
343083
+ if (isRecoverableAppiumSessionFailure(err)) throw err;
343084
+ }
342450
343085
  await this.executeScript("mobile: activateApp", [args]);
342451
343086
  try {
342452
343087
  await waitForColdBootSettle(this.sessionPath);