codex-chatgpt-control 0.5.0-alpha.1 → 0.5.1-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/contracts/v1/fixtures/backend-capabilities.json +1 -0
  3. package/contracts/v1/fixtures/command-descriptors.json +29 -2
  4. package/contracts/v1/fixtures/doctor-bridge-upload.json +2 -2
  5. package/contracts/v1/fixtures/doctor-scenario-preflight.json +26 -4
  6. package/contracts/v1/fixtures/files-attach-indeterminate.json +30 -0
  7. package/contracts/v1/fixtures/help-root.json +1 -1
  8. package/contracts/v1/fixtures/messages-stop-indeterminate.json +31 -0
  9. package/contracts/v1/fixtures/messages-stop-needs-confirmation.json +20 -0
  10. package/contracts/v1/fixtures/messages-stop-noop.json +19 -0
  11. package/contracts/v1/fixtures/messages-stop-success.json +21 -0
  12. package/contracts/v1/fixtures/primitive-bootstrap-blocker.json +3 -3
  13. package/contracts/v1/fixtures/reports-create-redacted.json +3 -3
  14. package/contracts/v1/fixtures/reports-summarize-redacted.json +1 -1
  15. package/contracts/v1/fixtures/run-browser-bridge-blocker.json +12 -12
  16. package/contracts/v1/fixtures/stream-blocked.ndjson +2 -2
  17. package/contracts/v1/manifest.json +25 -0
  18. package/contracts/v1/parity-suite.json +19 -1
  19. package/dist/codex-chatgpt-control-backend.mjs +3654 -603
  20. package/dist/codex-chatgpt-control.bundle.mjs +3627 -546
  21. package/dist/src/backend/client.d.ts +2 -1
  22. package/dist/src/backend/client.js +1 -0
  23. package/dist/src/backend/protocol.d.ts +1 -1
  24. package/dist/src/backend/protocol.js +1 -0
  25. package/dist/src/backend/session.js +2 -0
  26. package/dist/src/browser/attach.d.ts +1 -0
  27. package/dist/src/browser/attach.js +69 -35
  28. package/dist/src/browser/chatgpt-url.d.ts +8 -0
  29. package/dist/src/browser/chatgpt-url.js +28 -0
  30. package/dist/src/browser/downloads.d.ts +1 -1
  31. package/dist/src/browser/downloads.js +13 -4
  32. package/dist/src/browser/page-state.js +211 -4
  33. package/dist/src/client.d.ts +2 -1
  34. package/dist/src/client.js +2 -1
  35. package/dist/src/commands/configuration.js +330 -73
  36. package/dist/src/commands/context.d.ts +5 -1
  37. package/dist/src/commands/context.js +2 -2
  38. package/dist/src/commands/conversation.js +13 -1
  39. package/dist/src/commands/doctor.js +24 -2
  40. package/dist/src/commands/experience.d.ts +1 -0
  41. package/dist/src/commands/experience.js +156 -23
  42. package/dist/src/commands/files.d.ts +1 -0
  43. package/dist/src/commands/files.js +877 -170
  44. package/dist/src/commands/messages.d.ts +2 -1
  45. package/dist/src/commands/messages.js +310 -2
  46. package/dist/src/commands/modes.js +197 -52
  47. package/dist/src/commands/registry.js +8 -0
  48. package/dist/src/commands/sequence.js +3 -1
  49. package/dist/src/commands/session.d.ts +4 -1
  50. package/dist/src/commands/session.js +34 -3
  51. package/dist/src/dom/generation-state.d.ts +6 -1
  52. package/dist/src/dom/generation-state.js +206 -45
  53. package/dist/src/dom/locale/am.d.ts +23 -1
  54. package/dist/src/dom/locale/am.js +23 -1
  55. package/dist/src/dom/locale/ar.d.ts +21 -1
  56. package/dist/src/dom/locale/ar.js +24 -4
  57. package/dist/src/dom/locale/bg.d.ts +19 -1
  58. package/dist/src/dom/locale/bg.js +22 -4
  59. package/dist/src/dom/locale/bn.d.ts +23 -1
  60. package/dist/src/dom/locale/bn.js +23 -1
  61. package/dist/src/dom/locale/bs.d.ts +17 -1
  62. package/dist/src/dom/locale/bs.js +21 -5
  63. package/dist/src/dom/locale/ca.d.ts +20 -1
  64. package/dist/src/dom/locale/ca.js +25 -6
  65. package/dist/src/dom/locale/cs.d.ts +17 -1
  66. package/dist/src/dom/locale/cs.js +17 -1
  67. package/dist/src/dom/locale/da.d.ts +17 -1
  68. package/dist/src/dom/locale/da.js +18 -2
  69. package/dist/src/dom/locale/de.d.ts +16 -1
  70. package/dist/src/dom/locale/de.js +18 -3
  71. package/dist/src/dom/locale/el.d.ts +18 -1
  72. package/dist/src/dom/locale/el.js +21 -4
  73. package/dist/src/dom/locale/en.d.ts +2 -1
  74. package/dist/src/dom/locale/en.js +2 -1
  75. package/dist/src/dom/locale/es-419.d.ts +18 -1
  76. package/dist/src/dom/locale/es-419.js +18 -1
  77. package/dist/src/dom/locale/es-ES.d.ts +18 -1
  78. package/dist/src/dom/locale/es-ES.js +18 -1
  79. package/dist/src/dom/locale/et.d.ts +16 -1
  80. package/dist/src/dom/locale/et.js +16 -1
  81. package/dist/src/dom/locale/fa.d.ts +21 -1
  82. package/dist/src/dom/locale/fa.js +23 -3
  83. package/dist/src/dom/locale/fi.d.ts +18 -1
  84. package/dist/src/dom/locale/fi.js +18 -1
  85. package/dist/src/dom/locale/fr-CA.d.ts +15 -1
  86. package/dist/src/dom/locale/fr-CA.js +15 -1
  87. package/dist/src/dom/locale/fr-FR.d.ts +18 -1
  88. package/dist/src/dom/locale/fr-FR.js +22 -5
  89. package/dist/src/dom/locale/gu.d.ts +23 -1
  90. package/dist/src/dom/locale/gu.js +27 -5
  91. package/dist/src/dom/locale/hi.d.ts +23 -1
  92. package/dist/src/dom/locale/hi.js +25 -3
  93. package/dist/src/dom/locale/hr.d.ts +16 -1
  94. package/dist/src/dom/locale/hr.js +19 -4
  95. package/dist/src/dom/locale/hu.d.ts +17 -1
  96. package/dist/src/dom/locale/hu.js +20 -4
  97. package/dist/src/dom/locale/hy.d.ts +23 -1
  98. package/dist/src/dom/locale/hy.js +26 -4
  99. package/dist/src/dom/locale/id.d.ts +17 -1
  100. package/dist/src/dom/locale/id.js +19 -3
  101. package/dist/src/dom/locale/index.js +1 -0
  102. package/dist/src/dom/locale/is.d.ts +21 -1
  103. package/dist/src/dom/locale/is.js +21 -1
  104. package/dist/src/dom/locale/it.d.ts +15 -1
  105. package/dist/src/dom/locale/it.js +20 -6
  106. package/dist/src/dom/locale/ja.d.ts +19 -1
  107. package/dist/src/dom/locale/ja.js +23 -5
  108. package/dist/src/dom/locale/ka.d.ts +23 -1
  109. package/dist/src/dom/locale/ka.js +23 -1
  110. package/dist/src/dom/locale/kk.d.ts +23 -1
  111. package/dist/src/dom/locale/kk.js +25 -3
  112. package/dist/src/dom/locale/kn.d.ts +22 -1
  113. package/dist/src/dom/locale/kn.js +26 -5
  114. package/dist/src/dom/locale/ko.d.ts +17 -1
  115. package/dist/src/dom/locale/ko.js +17 -1
  116. package/dist/src/dom/locale/lt.d.ts +18 -1
  117. package/dist/src/dom/locale/lt.js +20 -3
  118. package/dist/src/dom/locale/lv.d.ts +18 -1
  119. package/dist/src/dom/locale/lv.js +18 -1
  120. package/dist/src/dom/locale/mk.d.ts +22 -1
  121. package/dist/src/dom/locale/mk.js +26 -5
  122. package/dist/src/dom/locale/ml.d.ts +23 -1
  123. package/dist/src/dom/locale/ml.js +25 -3
  124. package/dist/src/dom/locale/mn.d.ts +23 -1
  125. package/dist/src/dom/locale/mn.js +23 -1
  126. package/dist/src/dom/locale/mr.d.ts +23 -1
  127. package/dist/src/dom/locale/mr.js +25 -3
  128. package/dist/src/dom/locale/ms.d.ts +20 -1
  129. package/dist/src/dom/locale/ms.js +20 -1
  130. package/dist/src/dom/locale/my.d.ts +22 -1
  131. package/dist/src/dom/locale/my.js +24 -3
  132. package/dist/src/dom/locale/nb.d.ts +17 -1
  133. package/dist/src/dom/locale/nb.js +17 -1
  134. package/dist/src/dom/locale/nl.d.ts +20 -2
  135. package/dist/src/dom/locale/nl.js +22 -4
  136. package/dist/src/dom/locale/pa.d.ts +23 -1
  137. package/dist/src/dom/locale/pa.js +27 -5
  138. package/dist/src/dom/locale/pl.d.ts +17 -1
  139. package/dist/src/dom/locale/pl.js +19 -3
  140. package/dist/src/dom/locale/pt-BR.d.ts +18 -1
  141. package/dist/src/dom/locale/pt-BR.js +20 -3
  142. package/dist/src/dom/locale/pt-PT.d.ts +18 -1
  143. package/dist/src/dom/locale/pt-PT.js +22 -5
  144. package/dist/src/dom/locale/ro.d.ts +15 -1
  145. package/dist/src/dom/locale/ro.js +18 -4
  146. package/dist/src/dom/locale/ru.d.ts +23 -1
  147. package/dist/src/dom/locale/ru.js +24 -2
  148. package/dist/src/dom/locale/sk.d.ts +17 -1
  149. package/dist/src/dom/locale/sk.js +19 -3
  150. package/dist/src/dom/locale/sl.d.ts +21 -1
  151. package/dist/src/dom/locale/sl.js +24 -4
  152. package/dist/src/dom/locale/so.d.ts +22 -1
  153. package/dist/src/dom/locale/so.js +24 -3
  154. package/dist/src/dom/locale/sq.d.ts +21 -1
  155. package/dist/src/dom/locale/sq.js +25 -5
  156. package/dist/src/dom/locale/sr.d.ts +24 -1
  157. package/dist/src/dom/locale/sr.js +25 -2
  158. package/dist/src/dom/locale/sv.d.ts +16 -1
  159. package/dist/src/dom/locale/sv.js +16 -1
  160. package/dist/src/dom/locale/sw.d.ts +18 -1
  161. package/dist/src/dom/locale/sw.js +18 -1
  162. package/dist/src/dom/locale/ta.d.ts +23 -1
  163. package/dist/src/dom/locale/ta.js +27 -5
  164. package/dist/src/dom/locale/te.d.ts +23 -1
  165. package/dist/src/dom/locale/te.js +27 -5
  166. package/dist/src/dom/locale/th.d.ts +11 -1
  167. package/dist/src/dom/locale/th.js +11 -1
  168. package/dist/src/dom/locale/tl.d.ts +8 -1
  169. package/dist/src/dom/locale/tl.js +8 -1
  170. package/dist/src/dom/locale/tr.d.ts +21 -1
  171. package/dist/src/dom/locale/tr.js +23 -3
  172. package/dist/src/dom/locale/types.d.ts +1 -1
  173. package/dist/src/dom/locale/uk.d.ts +23 -1
  174. package/dist/src/dom/locale/uk.js +25 -3
  175. package/dist/src/dom/locale/ur.d.ts +22 -1
  176. package/dist/src/dom/locale/ur.js +25 -4
  177. package/dist/src/dom/locale/vi.d.ts +22 -1
  178. package/dist/src/dom/locale/vi.js +25 -4
  179. package/dist/src/dom/locale/zh-HK.d.ts +21 -1
  180. package/dist/src/dom/locale/zh-HK.js +23 -3
  181. package/dist/src/dom/locale/zh-Hans.d.ts +23 -1
  182. package/dist/src/dom/locale/zh-Hans.js +27 -5
  183. package/dist/src/dom/locale/zh-TW.d.ts +23 -1
  184. package/dist/src/dom/locale/zh-TW.js +25 -3
  185. package/dist/src/dom/menus.js +26 -2
  186. package/dist/src/dom/selectors.d.ts +1 -0
  187. package/dist/src/dom/selectors.js +9 -1
  188. package/dist/src/dom/wait-snapshot.d.ts +1 -0
  189. package/dist/src/dom/wait-snapshot.js +45 -22
  190. package/dist/src/errors.d.ts +1 -1
  191. package/dist/src/errors.js +2 -2
  192. package/dist/src/platform/local-paths.d.ts +1 -0
  193. package/dist/src/platform/local-paths.js +7 -3
  194. package/dist/src/safety/risk.d.ts +1 -0
  195. package/dist/src/safety/risk.js +1 -0
  196. package/dist/src/scripts/apply-intelligence-locale-captures.d.ts +10 -1
  197. package/dist/src/scripts/apply-intelligence-locale-captures.js +219 -7
  198. package/dist/src/scripts/capture-intelligence-locales.d.ts +119 -1
  199. package/dist/src/scripts/capture-intelligence-locales.js +653 -232
  200. package/dist/src/scripts/capture-surface-profile.d.ts +3 -1
  201. package/dist/src/scripts/capture-surface-profile.js +75 -25
  202. package/dist/src/scripts/live-smoke/scenarios.d.ts +3 -0
  203. package/dist/src/scripts/live-smoke/scenarios.js +321 -13
  204. package/dist/src/scripts/live-smoke.js +1 -0
  205. package/dist/src/scripts/locale-capture/surface-graph.d.ts +31 -0
  206. package/dist/src/scripts/locale-capture/surface-graph.js +50 -0
  207. package/dist/src/scripts/release-canary-module.d.ts +20 -0
  208. package/dist/src/scripts/release-canary-module.js +86 -0
  209. package/dist/src/types.d.ts +30 -6
  210. package/package.json +8 -2
  211. package/references/2026-07-16-chat-work-surfaces.md +13 -0
  212. package/references/2026-08-08-model-selection-locale-sweep.md +32 -0
  213. package/references/backend-protocol.md +22 -7
  214. package/references/language-coverage.md +24 -13
  215. package/references/localization.md +36 -14
  216. package/references/python-parity.md +9 -3
  217. package/references/troubleshooting.md +26 -4
@@ -4,8 +4,8 @@ import { dirname, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { attachChatGPTBrowser } from "../browser/attach.js";
6
6
  import { BROWSER_BRIDGE_REMEDIATION, BROWSER_BRIDGE_UNAVAILABLE_MESSAGE } from "../errors.js";
7
- import { localeLabels } from "../dom/locale-labels.js";
8
7
  import { nonEnglishLanguages, readLanguageCoverage } from "./locale-capture/language-coverage.js";
8
+ import { assignOrderedChatConfigurationRows, assignChatSelectedSurfaceOptions, assignOrderedSurfaceOptions, assignOrderedWorkConfigurationRows, } from "./locale-capture/surface-graph.js";
9
9
  const SCHEMA_VERSION = "chatgpt.browser_control.intelligence_locale_capture.v1";
10
10
  const CHATGPT_HOME = "https://chatgpt.com/";
11
11
  const DEFAULT_SWITCH_TIMEOUT_MS = 15_000;
@@ -45,6 +45,8 @@ const USAGE = [
45
45
  " --no-open-version-submenu Do not open the model-version submenu.",
46
46
  " --capture-generation-state Submit one bounded probe per locale to capture localized running/stopped generation labels. Default: false.",
47
47
  " --no-capture-generation-state Disable generation-state capture.",
48
+ " --capture-surfaces Capture Chat/Work radios, composers, Power/Advanced controls, and ordered configuration rows.",
49
+ " --no-capture-surfaces Disable Chat/Work surface capture. Default.",
48
50
  " --generation-prompt Override the redacted probe prompt used only for generation-state capture.",
49
51
  " --generation-timeout-ms Wait for generation controls after submit. Default: 8000.",
50
52
  " --restore Restore the initially selected language after a sweep. Default with --auto-switch.",
@@ -68,6 +70,7 @@ export async function main(argv = process.argv.slice(2), runtime = globalThis) {
68
70
  throw error;
69
71
  }
70
72
  const languages = await readLanguageCoverage(options.coveragePath);
73
+ const knownLanguageNames = languages.map(language => language.nativeName);
71
74
  if (options.printQueue) {
72
75
  printQueue(nonEnglishLanguages(languages));
73
76
  return 0;
@@ -85,15 +88,16 @@ export async function main(argv = process.argv.slice(2), runtime = globalThis) {
85
88
  }, null, 2));
86
89
  return 2;
87
90
  }
88
- const initialLanguage = options.autoSwitch
89
- ? await attachCapturePage(runtime, options).then(page => readSelectedLanguage(page)).catch(() => undefined)
91
+ const initialLanguage = options.autoSwitch && options.restore
92
+ ? await readInitialLanguage(runtime, options, knownLanguageNames)
90
93
  : undefined;
91
94
  const sweepLanguages = resolveSweepLanguages(options, languages);
92
95
  const records = [];
96
+ let restoreFailed = false;
93
97
  try {
94
98
  for (const language of sweepLanguages) {
95
99
  const page = await attachCapturePage(runtime, options);
96
- const record = await captureOne(page, language, options);
100
+ const record = await captureOne(page, language, options, knownLanguageNames);
97
101
  records.push(record);
98
102
  await appendRecord(options.out, record);
99
103
  printCaptureRecord(record, options.out);
@@ -106,12 +110,36 @@ export async function main(argv = process.argv.slice(2), runtime = globalThis) {
106
110
  }
107
111
  finally {
108
112
  if (options.autoSwitch && options.restore && initialLanguage !== undefined) {
109
- await attachCapturePage(runtime, options).then(page => restoreLanguage(page, initialLanguage, options)).catch(error => {
110
- console.error(`Unable to restore initial language ${initialLanguage}: ${error instanceof Error ? error.message : String(error)}`);
113
+ await attachCapturePage(runtime, options).then(page => restoreLanguage(page, initialLanguage.selectedLabel, initialLanguage.htmlLang, knownLanguageNames, options)).catch(error => {
114
+ restoreFailed = true;
115
+ console.error(`Unable to restore initial language ${initialLanguage.selectedLabel}: ${error instanceof Error ? error.message : String(error)}`);
111
116
  });
112
117
  }
113
118
  }
114
- return records.some(record => record.status === "blocked") ? 1 : 0;
119
+ return restoreFailed || records.some(record => record.status === "blocked"
120
+ || !surfaceCaptureSucceeded(options.captureSurfaces, record.surfaceCapture)) ? 1 : 0;
121
+ }
122
+ async function readInitialLanguage(runtime, options, knownLanguageNames) {
123
+ let lastError;
124
+ for (let attempt = 0; attempt < 3; attempt += 1) {
125
+ try {
126
+ const page = await attachCapturePage(runtime, options);
127
+ const proof = await renderedProof(page);
128
+ const selected = await readSelectedLanguage(page, knownLanguageNames);
129
+ if (selected !== undefined && proof.htmlLang !== undefined && proof.htmlLang.length > 0) {
130
+ return { selectedLabel: selected, htmlLang: proof.htmlLang };
131
+ }
132
+ lastError = new Error("Settings language value was empty.");
133
+ }
134
+ catch (error) {
135
+ lastError = error;
136
+ }
137
+ await wait(500);
138
+ }
139
+ throw new Error(`Unable to establish the initial ChatGPT language before a restorable sweep: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
140
+ }
141
+ export function surfaceCaptureSucceeded(requested, capture) {
142
+ return !requested || (capture?.status === "ok" && capture.restoredChat);
115
143
  }
116
144
  export function parseArgs(argv) {
117
145
  let locale;
@@ -124,6 +152,7 @@ export function parseArgs(argv) {
124
152
  let locales;
125
153
  let openVersionSubmenu = true;
126
154
  let captureGenerationState = false;
155
+ let captureSurfaces = false;
127
156
  let generationPrompt = DEFAULT_GENERATION_PROMPT;
128
157
  let generationCaptureTimeoutMs = DEFAULT_GENERATION_CAPTURE_TIMEOUT_MS;
129
158
  let restore;
@@ -176,6 +205,12 @@ export function parseArgs(argv) {
176
205
  case "--no-capture-generation-state":
177
206
  captureGenerationState = false;
178
207
  break;
208
+ case "--capture-surfaces":
209
+ captureSurfaces = true;
210
+ break;
211
+ case "--no-capture-surfaces":
212
+ captureSurfaces = false;
213
+ break;
179
214
  case "--generation-prompt":
180
215
  generationPrompt = requiredValue(argv, ++index, arg);
181
216
  break;
@@ -222,6 +257,7 @@ export function parseArgs(argv) {
222
257
  locales,
223
258
  openVersionSubmenu,
224
259
  captureGenerationState,
260
+ captureSurfaces,
225
261
  generationPrompt,
226
262
  generationCaptureTimeoutMs,
227
263
  restore: restore ?? autoSwitch,
@@ -276,11 +312,11 @@ function resolveSweepLanguages(options, languages) {
276
312
  status: ""
277
313
  }];
278
314
  }
279
- async function captureOne(page, language, options) {
315
+ export async function captureOne(page, language, options, knownLanguageNames, dependencies = {}) {
280
316
  const warnings = [];
281
317
  try {
282
318
  if (options.autoSwitch) {
283
- await switchLanguage(page, language, options);
319
+ await switchLanguage(page, language, knownLanguageNames, options);
284
320
  const proof = await renderedProof(page);
285
321
  if (!htmlLangMatches(proof.htmlLang, language.bcp47)) {
286
322
  return blockedRecord(language, proof, warnings, "rendered_locale_mismatch", `Rendered html lang ${proof.htmlLang || "unknown"} did not match requested ${language.bcp47}.`);
@@ -288,9 +324,16 @@ async function captureOne(page, language, options) {
288
324
  }
289
325
  await closeSettingsIfOpen(page);
290
326
  await returnToChatSurface(page, options);
291
- const picker = await captureIntelligencePicker(page, options);
327
+ if (options.captureSurfaces) {
328
+ await ensureChatSurfaceSelected(page, options);
329
+ }
330
+ const picker = await (dependencies.captureIntelligencePicker ?? captureIntelligencePicker)(page, options);
331
+ await closeFloatingMenus(page);
332
+ const surfaceCapture = options.captureSurfaces
333
+ ? await captureLocaleSurface(page, options, picker.configuration)
334
+ : undefined;
292
335
  const generation = options.captureGenerationState
293
- ? await captureGenerationStateLabels(page, options)
336
+ ? await (dependencies.captureGenerationStateLabels ?? captureGenerationStateLabels)(page, options)
294
337
  : undefined;
295
338
  if (generation !== undefined) {
296
339
  warnings.push(...generation.warnings);
@@ -310,6 +353,10 @@ async function captureOne(page, language, options) {
310
353
  modelVersionLabels: picker.modelVersionLabels,
311
354
  warnings
312
355
  };
356
+ if (surfaceCapture !== undefined) {
357
+ record.surfaceCapture = surfaceCapture;
358
+ warnings.push(...surfaceCapture.warnings);
359
+ }
313
360
  if (generation !== undefined) {
314
361
  record.generationStopLabels = generation.stopLabels;
315
362
  record.generationStoppedLabels = generation.stoppedLabels;
@@ -322,39 +369,86 @@ async function captureOne(page, language, options) {
322
369
  return blockedRecord(language, proof, warnings, "capture_failed", error instanceof Error ? error.message : String(error));
323
370
  }
324
371
  }
325
- async function switchLanguage(page, language, options) {
372
+ async function switchLanguage(page, language, knownLanguageNames, options) {
326
373
  await openSettings(page, options);
327
- await openLanguageCombobox(page);
374
+ await openLanguageCombobox(page, knownLanguageNames);
328
375
  await clickOptionExact(page, language.nativeName);
329
- await waitForRenderedLanguage(page, language.bcp47, options.switchTimeoutMs).catch(async () => {
376
+ await wait(options.settleMs);
377
+ if (page.goto !== undefined) {
378
+ await page.goto(CHATGPT_HOME).catch(error => {
379
+ if (!/ERR_ABORTED/i.test(error instanceof Error ? error.message : String(error)))
380
+ throw error;
381
+ });
330
382
  await wait(options.settleMs);
331
- });
383
+ }
384
+ await waitForRenderedLanguage(page, language.bcp47, options.switchTimeoutMs);
332
385
  }
333
- async function restoreLanguage(page, selectedLanguageText, options) {
386
+ async function restoreLanguage(page, selectedLanguageText, expectedHtmlLang, knownLanguageNames, options) {
334
387
  await openSettings(page, options);
335
- await openLanguageCombobox(page);
388
+ await openLanguageCombobox(page, knownLanguageNames);
336
389
  await clickOptionExact(page, selectedLanguageText);
337
390
  await wait(options.settleMs);
338
- await closeSettingsIfOpen(page);
391
+ let restored = false;
392
+ for (let attempt = 0; attempt < 2; attempt += 1) {
393
+ if (page.goto !== undefined) {
394
+ await page.goto(CHATGPT_HOME).catch(error => {
395
+ if (!/ERR_ABORTED/i.test(error instanceof Error ? error.message : String(error)))
396
+ throw error;
397
+ });
398
+ }
399
+ await wait(options.settleMs);
400
+ try {
401
+ await waitForRenderedLanguage(page, expectedHtmlLang, options.switchTimeoutMs);
402
+ restored = true;
403
+ break;
404
+ }
405
+ catch (error) {
406
+ if (attempt === 1)
407
+ throw error;
408
+ }
409
+ }
410
+ if (!restored)
411
+ throw new Error(`Rendered language did not restore to ${expectedHtmlLang}.`);
412
+ for (let attempt = 0; attempt < 6; attempt += 1) {
413
+ await closeSettingsIfOpen(page);
414
+ await wait(500);
415
+ if (!await isSettingsOpen(page))
416
+ return;
417
+ }
418
+ throw new Error("Settings modal remained open after restoring the initial language.");
339
419
  }
340
420
  async function openSettings(page, options) {
341
421
  if (await isSettingsOpen(page))
342
422
  return;
343
423
  await closeFloatingMenus(page);
344
- const profile = page.locator?.("[data-testid=\"accounts-profile-button\"]")?.last?.();
345
- if (profile?.click === undefined) {
346
- throw new Error("Profile menu button was not available.");
347
- }
348
- await profile.click();
349
- await wait(options.settleMs);
350
- const settings = page.locator?.("[data-testid=\"settings-menu-item\"]")?.last?.();
351
- if (settings?.click === undefined) {
352
- throw new Error("Settings menu item was not available.");
424
+ const profiles = page.locator?.("[data-testid=\"accounts-profile-button\"]");
425
+ const profileCount = await profiles?.count?.().catch(() => 0) ?? 0;
426
+ let settings;
427
+ for (let index = profileCount - 1; index >= 0; index -= 1) {
428
+ const profile = profiles?.nth?.(index);
429
+ if (profile?.click === undefined)
430
+ continue;
431
+ if (profile.isVisible !== undefined && !await profile.isVisible().catch(() => false))
432
+ continue;
433
+ await profile.click().catch(() => undefined);
434
+ await wait(Math.min(options.settleMs, 500));
435
+ const candidate = page.locator?.("[data-testid=\"settings-menu-item\"]")?.last?.();
436
+ const candidateCount = await candidate?.count?.().catch(() => 0) ?? 0;
437
+ if (candidateCount === 1 && candidate?.click !== undefined) {
438
+ settings = candidate;
439
+ break;
440
+ }
353
441
  }
442
+ if (settings?.click === undefined)
443
+ throw new Error("Settings menu item was not available from any visible profile control.");
354
444
  await settings.click();
355
- await wait(options.settleMs);
356
- if (!await isSettingsOpen(page)) {
357
- throw new Error("Settings modal did not open.");
445
+ const deadline = Date.now() + 5_000;
446
+ for (;;) {
447
+ if (await isSettingsOpen(page))
448
+ return;
449
+ if (Date.now() >= deadline)
450
+ throw new Error("Settings modal did not open.");
451
+ await wait(Math.min(options.settleMs, 500));
358
452
  }
359
453
  }
360
454
  async function isSettingsOpen(page) {
@@ -363,11 +457,11 @@ async function isSettingsOpen(page) {
363
457
  || document.querySelector("[data-testid='close-button']") !== null
364
458
  || document.querySelectorAll("button[role='combobox']").length >= 3)) ?? false;
365
459
  }
366
- async function openLanguageCombobox(page) {
367
- const combo = page.locator?.("button[role=\"combobox\"]")?.nth?.(3);
368
- if (combo?.click === undefined) {
369
- throw new Error("Language combobox was not available.");
370
- }
460
+ async function openLanguageCombobox(page, knownLanguageNames) {
461
+ const match = await findLanguageCombobox(page, knownLanguageNames);
462
+ const combo = page.locator?.("[role='dialog'] button[role='combobox']")?.nth?.(match.index);
463
+ if (combo?.click === undefined)
464
+ throw new Error("Language combobox was not actionable.");
371
465
  await combo.click();
372
466
  await wait(500);
373
467
  }
@@ -378,10 +472,22 @@ async function clickOptionExact(page, label) {
378
472
  }
379
473
  await option.click();
380
474
  }
381
- async function readSelectedLanguage(page) {
475
+ async function readSelectedLanguage(page, knownLanguageNames) {
382
476
  await openSettings(page, { settleMs: DEFAULT_SETTLE_MS });
383
- const labels = await page.evaluate?.(() => Array.from(document.querySelectorAll("button[role='combobox']")).map(button => (button.textContent ?? "").replace(/\s+/g, " ").trim()));
384
- return labels?.[3];
477
+ return (await findLanguageCombobox(page, knownLanguageNames)).selectedLabel;
478
+ }
479
+ async function findLanguageCombobox(page, knownLanguageNames) {
480
+ const values = await page.evaluate?.(() => Array.from(document.querySelectorAll("[role='dialog'] button[role='combobox']"))
481
+ .map((button, index) => ({
482
+ index,
483
+ label: (button.textContent ?? "").replace(/\s+/g, " ").trim()
484
+ }))) ?? [];
485
+ const known = new Set(knownLanguageNames.map(normalized));
486
+ const matches = values.filter(value => known.has(normalized(value.label)));
487
+ if (matches.length !== 1) {
488
+ throw new Error(`Expected one Settings language combobox by selected native-language value; observed ${matches.length}.`);
489
+ }
490
+ return { index: matches[0].index, selectedLabel: matches[0].label };
385
491
  }
386
492
  async function closeSettingsIfOpen(page) {
387
493
  if (!await isSettingsOpen(page))
@@ -436,81 +542,431 @@ async function closeFloatingMenus(page) {
436
542
  }
437
543
  async function captureIntelligencePicker(page, options) {
438
544
  await closeFloatingMenus(page);
439
- await openPicker(page);
440
- await wait(options.settleMs);
441
- const first = await readPickerState(page);
442
- let modelVersionLabels = [];
443
- if (options.openVersionSubmenu && first.versionFamilyLabels.length > 0) {
444
- await openVersionSubmenu(page);
445
- await wait(options.settleMs);
446
- modelVersionLabels = (await readPickerState(page)).modelVersionLabels;
545
+ const configuration = await captureConfigurationMenu(page, "chat", options.settleMs, options.openVersionSubmenu);
546
+ const proof = await renderedProof(page);
547
+ const model = configuration.rows.find(row => row.axis === "model");
548
+ const effort = configuration.rows.find(row => row.axis === "effort");
549
+ if (model === undefined || effort === undefined || effort.options.length === 0) {
550
+ throw new Error("Chat configuration did not expose ordered Model and Effort options.");
447
551
  }
448
- return { ...first, modelVersionLabels };
552
+ const selectedIntelligenceLabel = effort.options.find(option => option.checked)?.label;
553
+ return {
554
+ htmlLang: proof.htmlLang ?? "",
555
+ url: proof.url ?? "",
556
+ menuHeading: configuration.power.axisLabel,
557
+ intelligenceLabels: effort.options.map(option => option.label),
558
+ ...(selectedIntelligenceLabel === undefined ? {} : { selectedIntelligenceLabel }),
559
+ versionFamilyLabels: [],
560
+ modelVersionLabels: model.options.map(option => option.label),
561
+ configuration
562
+ };
449
563
  }
450
- async function openPicker(page) {
451
- const proCandidates = [
452
- page.locator?.("form button.__composer-pill")?.last?.(),
453
- page.locator?.("button.__composer-pill")?.last?.(),
454
- page.getByRole?.("button", { name: /^Pro$/ })?.last?.(),
455
- page.locator?.("button")?.filter?.({ hasText: /^Pro$/ })?.last?.(),
456
- page.locator?.("button")?.filter?.({ hasText: /^\s*Pro\s*$/ })?.last?.(),
457
- ];
458
- for (const proButton of proCandidates) {
459
- if (proButton?.click === undefined)
460
- continue;
461
- await proButton.click().catch(() => undefined);
462
- if (await pickerIsOpen(page))
463
- return;
564
+ async function captureLocaleSurface(page, options, chatConfiguration) {
565
+ const warnings = [];
566
+ let chatLabel;
567
+ let workLabel;
568
+ let restoredChat = false;
569
+ let result;
570
+ try {
571
+ await waitForOrderedSurfaceOptions(page, options.switchTimeoutMs);
572
+ const mapped = assignChatSelectedSurfaceOptions(await readVisibleSurfaceOptions(page));
573
+ chatLabel = mapped.chatLabel;
574
+ workLabel = mapped.workLabel;
575
+ const chatComposerLabels = await readVisibleComposerLabels(page);
576
+ await clickSurfaceOption(page, workLabel);
577
+ await waitForSurfaceSelection(page, workLabel, options.switchTimeoutMs);
578
+ await wait(options.settleMs);
579
+ const workComposerLabels = await readVisibleComposerLabels(page);
580
+ const workConfiguration = await captureConfigurationMenu(page, "work", options.settleMs, true);
581
+ result = {
582
+ schemaVersion: "chatgpt.browser_control.locale_surface_capture.v1",
583
+ status: "ok",
584
+ chat: {
585
+ optionLabel: chatLabel,
586
+ composerLabels: chatComposerLabels,
587
+ power: chatConfiguration.power,
588
+ advanced: chatConfiguration.advanced,
589
+ configurationRows: chatConfiguration.rows
590
+ },
591
+ work: {
592
+ optionLabel: workLabel,
593
+ composerLabels: workComposerLabels,
594
+ power: workConfiguration.power,
595
+ advanced: workConfiguration.advanced,
596
+ configurationRows: workConfiguration.rows
597
+ },
598
+ warnings
599
+ };
600
+ }
601
+ catch (error) {
602
+ result = {
603
+ schemaVersion: "chatgpt.browser_control.locale_surface_capture.v1",
604
+ status: "blocked",
605
+ warnings,
606
+ blocker: {
607
+ kind: "selector_drift",
608
+ code: "locale_surface_capture_failed",
609
+ message: error instanceof Error ? error.message : String(error)
610
+ }
611
+ };
612
+ }
613
+ finally {
614
+ await closeFloatingMenus(page).catch(() => undefined);
615
+ if (chatLabel !== undefined) {
616
+ try {
617
+ await clickSurfaceOption(page, chatLabel);
618
+ await waitForSurfaceSelection(page, chatLabel, options.switchTimeoutMs);
619
+ restoredChat = true;
620
+ }
621
+ catch (error) {
622
+ warnings.push(`Unable to restore Chat surface: ${error instanceof Error ? error.message : String(error)}`);
623
+ }
624
+ }
464
625
  }
465
- await clickStructuralPickerCandidate(page).catch(() => undefined);
466
- if (await pickerIsOpen(page))
626
+ if (result.status === "ok" && !restoredChat) {
627
+ result = {
628
+ ...result,
629
+ status: "blocked",
630
+ blocker: {
631
+ kind: "selector_drift",
632
+ code: "locale_surface_restore_failed",
633
+ message: "The localized Chat/Work surface was captured, but Chat could not be restored."
634
+ }
635
+ };
636
+ }
637
+ return { ...result, restoredChat };
638
+ }
639
+ async function ensureChatSurfaceSelected(page, options) {
640
+ const mapped = await waitForOrderedSurfaceOptions(page, options.switchTimeoutMs);
641
+ if (mapped.selected === "chat")
467
642
  return;
468
- for (const label of localeLabels.modeLabels) {
469
- const button = page.getByRole?.("button", { name: new RegExp(`^${escapeRegExp(label)}$`, "i") })?.last?.();
470
- if (button?.click === undefined)
471
- continue;
472
- await button.click().catch(() => undefined);
473
- if (await pickerIsOpen(page))
643
+ await clickSurfaceOption(page, mapped.chatLabel);
644
+ await waitForSurfaceSelection(page, mapped.chatLabel, options.switchTimeoutMs);
645
+ await wait(options.settleMs);
646
+ }
647
+ async function waitForOrderedSurfaceOptions(page, timeoutMs) {
648
+ const deadline = Date.now() + timeoutMs;
649
+ let lastError;
650
+ for (;;) {
651
+ try {
652
+ return assignOrderedSurfaceOptions(await readVisibleSurfaceOptions(page));
653
+ }
654
+ catch (error) {
655
+ lastError = error;
656
+ }
657
+ if (Date.now() >= deadline) {
658
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
659
+ }
660
+ await wait(250);
661
+ }
662
+ }
663
+ async function readVisibleSurfaceOptions(page) {
664
+ return await page.evaluate?.(() => {
665
+ const visible = (element) => {
666
+ const rect = element.getBoundingClientRect?.();
667
+ const style = window.getComputedStyle?.(element);
668
+ return rect !== undefined && rect.width > 0 && rect.height > 0
669
+ && style?.display !== "none" && style?.visibility !== "hidden";
670
+ };
671
+ const label = (element) => (element.getAttribute("aria-label")
672
+ ?? element.innerText
673
+ ?? element.textContent
674
+ ?? "").replace(/\s+/g, " ").trim();
675
+ return Array.from(document.querySelectorAll("[role='radio'], input[type='radio']"))
676
+ .filter(element => visible(element) && element.closest("[role='dialog']") === null)
677
+ .map(element => ({
678
+ label: label(element),
679
+ checked: element.getAttribute("aria-checked") === "true"
680
+ || element.getAttribute("data-state") === "on"
681
+ || (element.tagName === "INPUT" && element.checked)
682
+ }))
683
+ .filter(option => option.label.length > 0)
684
+ .slice(0, 8);
685
+ }) ?? [];
686
+ }
687
+ async function readVisibleComposerLabels(page) {
688
+ return await page.evaluate?.(() => {
689
+ const visible = (element) => {
690
+ const rect = element.getBoundingClientRect?.();
691
+ const style = window.getComputedStyle?.(element);
692
+ return rect !== undefined && rect.width > 0 && rect.height > 0
693
+ && style?.display !== "none" && style?.visibility !== "hidden";
694
+ };
695
+ const roots = Array.from(document.querySelectorAll("main form, main [data-testid*='composer' i], main [class*='composer' i]"));
696
+ const nodes = roots.flatMap(root => Array.from(root.querySelectorAll("textarea, [contenteditable='true'], [role='textbox'], input")));
697
+ return Array.from(new Set(nodes.filter(visible).map(element => (element.getAttribute("aria-label")
698
+ ?? element.getAttribute("placeholder")
699
+ ?? element.innerText
700
+ ?? element.textContent
701
+ ?? "").replace(/\s+/g, " ").trim()).filter(Boolean))).slice(0, 16);
702
+ }) ?? [];
703
+ }
704
+ async function clickSurfaceOption(page, label) {
705
+ const locator = page.getByRole?.("radio", { name: label, exact: true });
706
+ const count = await locator?.count?.().catch(() => 0) ?? 0;
707
+ if (count !== 1 || locator?.click === undefined) {
708
+ throw new Error(`Surface radio ${JSON.stringify(label)} was missing or ambiguous.`);
709
+ }
710
+ await locator.click();
711
+ }
712
+ async function waitForSurfaceSelection(page, label, timeoutMs) {
713
+ const deadline = Date.now() + timeoutMs;
714
+ for (;;) {
715
+ const options = await readVisibleSurfaceOptions(page);
716
+ if (options.some(option => option.label === label && option.checked))
474
717
  return;
718
+ if (Date.now() >= deadline)
719
+ throw new Error(`Timed out waiting for surface ${JSON.stringify(label)}.`);
720
+ await wait(250);
721
+ }
722
+ }
723
+ async function captureConfigurationMenu(page, experience, settleMs, captureModelOptions) {
724
+ const expectedRowCount = experience === "chat" ? 2 : 3;
725
+ const opener = await findConfigurationOpener(page, experience);
726
+ const openerLabel = normalized(await opener.innerText?.().catch(() => undefined)
727
+ ?? await opener.textContent?.().catch(() => undefined)
728
+ ?? "");
729
+ if (openerLabel.length === 0 || opener.click === undefined) {
730
+ throw new Error(`${experienceLabel(experience)} configuration opener did not expose a label and click action.`);
475
731
  }
476
- throw new Error("Unable to open Intelligence picker.");
477
- }
478
- async function clickStructuralPickerCandidate(page) {
479
- const point = await page.evaluate?.(() => {
480
- let targetPoint;
481
- let bestScore = Number.NEGATIVE_INFINITY;
482
- const considerButton = (button, formScoped) => {
483
- const rect = button.getBoundingClientRect();
484
- const text = (button.textContent ?? "").replace(/\s+/g, " ").trim();
485
- if (text.length === 0 || text.length > 32)
486
- return;
487
- if (rect.width < 24 || rect.height < 24 || rect.width > 220 || rect.height > 64)
488
- return;
489
- if (rect.bottom < window.innerHeight * 0.45)
490
- return;
491
- const aria = button.getAttribute("aria-label") ?? "";
492
- const testId = button.getAttribute("data-testid") ?? "";
493
- if (/composer-plus|send|microphone|dictat|voice|audio/i.test(`${aria} ${testId}`))
494
- return;
495
- const score = (formScoped ? 1000 : 0) + rect.bottom + rect.right / 10;
496
- if (score > bestScore) {
497
- bestScore = score;
498
- targetPoint = {
499
- x: rect.left + rect.width / 2,
500
- y: rect.top + rect.height / 2,
501
- };
732
+ await opener.click();
733
+ await wait(Math.min(settleMs, 750));
734
+ let root = await waitForRawConfigurationRoot(page, expectedRowCount, 5_000);
735
+ const initialAdvancedExpanded = root.advanced.expanded;
736
+ try {
737
+ if (!root.advanced.expanded) {
738
+ await setAdvancedExpansion(page, true, expectedRowCount);
739
+ root = await readRawConfigurationRoot(page, expectedRowCount);
740
+ }
741
+ const orderedRows = experience === "chat"
742
+ ? assignOrderedChatConfigurationRows(root.rows)
743
+ : assignOrderedWorkConfigurationRows(root.rows);
744
+ const rowsWithOptions = [];
745
+ for (const row of orderedRows) {
746
+ const locator = page.getByRole?.("menuitem", { name: row.label, exact: true });
747
+ const rowCount = await locator?.count?.().catch(() => 0) ?? 0;
748
+ if (rowCount !== 1 || locator?.click === undefined) {
749
+ throw new Error(`${experienceLabel(experience)} configuration row ${JSON.stringify(row.label)} was missing or ambiguous.`);
750
+ }
751
+ const shouldCapture = row.axis !== "model" || captureModelOptions;
752
+ let options = [];
753
+ if (shouldCapture) {
754
+ await locator.click();
755
+ await wait(250);
756
+ options = await readVisibleSubmenuOptions(page);
757
+ if (options.length === 0) {
758
+ throw new Error(`${experienceLabel(experience)} ${row.axis} submenu exposed no options.`);
759
+ }
760
+ await page.keyboard?.press?.("Escape").catch(() => undefined);
761
+ await wait(100);
502
762
  }
763
+ rowsWithOptions.push({ ...row, options });
764
+ }
765
+ const rows = experience === "chat"
766
+ ? assignOrderedChatConfigurationRows(rowsWithOptions)
767
+ : assignOrderedWorkConfigurationRows(rowsWithOptions);
768
+ const effort = rows.find(row => row.axis === "effort");
769
+ const valueLabel = effort?.valueLabel ?? effort?.options.find(option => option.checked)?.label ?? openerLabel;
770
+ return {
771
+ openerLabel,
772
+ power: { ...root.power, valueLabel },
773
+ advanced: { ...root.advanced, initiallyExpanded: initialAdvancedExpanded },
774
+ rows
503
775
  };
504
- document.querySelectorAll("form button").forEach(button => considerButton(button, true));
505
- if (targetPoint === undefined) {
506
- document.querySelectorAll("button").forEach(button => considerButton(button, false));
776
+ }
777
+ finally {
778
+ await restoreAdvancedExpansion(page, opener, initialAdvancedExpanded, expectedRowCount);
779
+ await closeFloatingMenus(page).catch(() => undefined);
780
+ }
781
+ }
782
+ async function findConfigurationOpener(page, experience) {
783
+ const deadline = Date.now() + 5_000;
784
+ let lastCounts = [];
785
+ for (;;) {
786
+ const candidates = [
787
+ page.locator?.("main form button.__composer-pill")?.last?.(),
788
+ page.locator?.("main button.__composer-pill")?.last?.(),
789
+ page.locator?.("button.__composer-pill")?.last?.()
790
+ ];
791
+ lastCounts = [];
792
+ for (const candidate of candidates) {
793
+ const count = await candidate?.count?.().catch(() => 0) ?? 0;
794
+ lastCounts.push(count);
795
+ if (count === 1 && candidate !== undefined)
796
+ return candidate;
507
797
  }
508
- return targetPoint;
509
- });
510
- if (point === undefined)
511
- return false;
512
- await clickPagePoint(page, point);
513
- return true;
798
+ if (Date.now() >= deadline)
799
+ break;
800
+ await wait(250);
801
+ }
802
+ const diagnostic = await page.evaluate?.(() => ({
803
+ htmlLang: document.documentElement.lang,
804
+ url: location.href.replace(/\/c\/[^/?#]+/i, "/c/sanitized"),
805
+ dialogCount: document.querySelectorAll("[role='dialog']").length,
806
+ composerPillCount: document.querySelectorAll("button.__composer-pill").length,
807
+ mainComposerPillCount: document.querySelectorAll("main button.__composer-pill").length,
808
+ mainTextboxCount: document.querySelectorAll("main textarea, main [contenteditable='true'], main [role='textbox']").length
809
+ })).catch(() => undefined);
810
+ throw new Error(`Expected one ${experienceLabel(experience)} configuration opener; locatorCounts=${lastCounts.join(",")}; state=${JSON.stringify(diagnostic ?? {})}.`);
811
+ }
812
+ async function readRawConfigurationRoot(page, expectedRowCount) {
813
+ const capture = await page.evaluate?.((wantedRowCount) => {
814
+ const visible = (element) => {
815
+ const rect = element.getBoundingClientRect?.();
816
+ const style = window.getComputedStyle?.(element);
817
+ return rect !== undefined && rect.width > 0 && rect.height > 0
818
+ && style?.display !== "none" && style?.visibility !== "hidden";
819
+ };
820
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
821
+ const menus = Array.from(document.querySelectorAll("[role='menu']")).filter(visible);
822
+ const root = menus.find(menu => {
823
+ const rows = menu.querySelectorAll("[role='menuitem'][data-has-submenu], [role='menuitem'][aria-haspopup='menu']");
824
+ return rows.length === wantedRowCount && menu.querySelector("[role='slider']") !== null;
825
+ });
826
+ if (root === undefined)
827
+ return undefined;
828
+ const slider = root.querySelector("[role='slider']");
829
+ const powerItem = Array.from(root.querySelectorAll("[role='menuitem']"))
830
+ .find(item => !item.hasAttribute("data-has-submenu") && item.getAttribute("aria-label") !== null);
831
+ const advancedItem = Array.from(root.querySelectorAll("[role='menuitem'][aria-expanded]"))
832
+ .find(item => !item.hasAttribute("data-has-submenu") && item.getAttribute("aria-haspopup") !== "menu");
833
+ const minimum = Number(slider?.getAttribute("aria-valuemin"));
834
+ const maximum = Number(slider?.getAttribute("aria-valuemax"));
835
+ const value = Number(slider?.getAttribute("aria-valuenow"));
836
+ if (slider === null || powerItem === undefined || advancedItem === undefined
837
+ || !Number.isFinite(minimum) || !Number.isFinite(maximum) || !Number.isFinite(value))
838
+ return undefined;
839
+ const rows = Array.from(root.querySelectorAll("[role='menuitem'][data-has-submenu], [role='menuitem'][aria-haspopup='menu']"))
840
+ .filter(visible)
841
+ .map(row => {
842
+ const html = row;
843
+ const axisLabel = normalize(row.querySelector(".truncate")?.textContent ?? "");
844
+ const valueLabel = normalize(row.querySelector("[data-trailing-style='default']")?.textContent ?? "");
845
+ const item = {
846
+ label: normalize(row.getAttribute("aria-label") ?? html.innerText ?? row.textContent ?? ""),
847
+ axisLabel,
848
+ options: []
849
+ };
850
+ if (valueLabel.length > 0)
851
+ item.valueLabel = valueLabel;
852
+ return item;
853
+ });
854
+ const accessibleLabel = normalize(advancedItem.getAttribute("aria-label") ?? "");
855
+ return {
856
+ power: {
857
+ axisLabel: normalize(powerItem.getAttribute("aria-label") ?? powerItem.innerText ?? powerItem.textContent ?? ""),
858
+ minimum,
859
+ maximum,
860
+ value,
861
+ position: value - minimum + 1,
862
+ count: maximum - minimum + 1
863
+ },
864
+ advanced: {
865
+ label: normalize(advancedItem.innerText ?? advancedItem.textContent ?? ""),
866
+ ...(accessibleLabel.length === 0 ? {} : { accessibleLabel }),
867
+ expanded: advancedItem.getAttribute("aria-expanded") === "true"
868
+ },
869
+ rows
870
+ };
871
+ }, expectedRowCount);
872
+ if (capture === undefined) {
873
+ throw new Error(`Expected one Power/Advanced menu with ${expectedRowCount} configuration rows.`);
874
+ }
875
+ if (capture.rows.length !== expectedRowCount) {
876
+ throw new Error(`Expected ${expectedRowCount} visible configuration rows; observed ${capture.rows.length}.`);
877
+ }
878
+ if (capture.power.axisLabel.length === 0 || capture.advanced.label.length === 0) {
879
+ throw new Error("Power or Advanced labels were empty.");
880
+ }
881
+ return capture;
882
+ }
883
+ async function waitForRawConfigurationRoot(page, expectedRowCount, timeoutMs) {
884
+ const deadline = Date.now() + timeoutMs;
885
+ let lastError;
886
+ for (;;) {
887
+ try {
888
+ return await readRawConfigurationRoot(page, expectedRowCount);
889
+ }
890
+ catch (error) {
891
+ lastError = error;
892
+ }
893
+ if (Date.now() >= deadline) {
894
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
895
+ }
896
+ await wait(250);
897
+ }
898
+ }
899
+ async function setAdvancedExpansion(page, expanded, expectedRowCount) {
900
+ const root = await readRawConfigurationRoot(page, expectedRowCount);
901
+ if (root.advanced.expanded === expanded)
902
+ return;
903
+ const toggle = page.locator?.("[role='menu'] [role='menuitem'][aria-expanded]:not([data-has-submenu]):not([aria-haspopup='menu'])")?.last?.();
904
+ const count = await toggle?.count?.().catch(() => 0) ?? 0;
905
+ if (count !== 1 || toggle === undefined) {
906
+ throw new Error("Advanced configuration toggle was missing or ambiguous.");
907
+ }
908
+ if (toggle.press !== undefined) {
909
+ await toggle.press("Enter");
910
+ }
911
+ else if (toggle.click !== undefined) {
912
+ await toggle.click();
913
+ }
914
+ else {
915
+ throw new Error("Advanced configuration toggle was not actionable.");
916
+ }
917
+ await wait(250);
918
+ const after = await readRawConfigurationRoot(page, expectedRowCount);
919
+ if (after.advanced.expanded !== expanded) {
920
+ throw new Error(`Advanced configuration toggle did not become ${expanded ? "expanded" : "compact"}.`);
921
+ }
922
+ }
923
+ async function restoreAdvancedExpansion(page, opener, initiallyExpanded, expectedRowCount) {
924
+ await page.keyboard?.press?.("Escape").catch(() => undefined);
925
+ let root = await readRawConfigurationRoot(page, expectedRowCount).catch(() => undefined);
926
+ if (root === undefined) {
927
+ await opener.click?.();
928
+ await wait(200);
929
+ root = await readRawConfigurationRoot(page, expectedRowCount);
930
+ }
931
+ if (root.advanced.expanded !== initiallyExpanded) {
932
+ await setAdvancedExpansion(page, initiallyExpanded, expectedRowCount);
933
+ }
934
+ }
935
+ function experienceLabel(experience) {
936
+ return experience === "chat" ? "Chat" : "Work";
937
+ }
938
+ async function readVisibleSubmenuOptions(page) {
939
+ return await page.evaluate?.(() => {
940
+ const visible = (element) => {
941
+ const rect = element.getBoundingClientRect?.();
942
+ const style = window.getComputedStyle?.(element);
943
+ return rect !== undefined && rect.width > 0 && rect.height > 0
944
+ && style?.display !== "none" && style?.visibility !== "hidden";
945
+ };
946
+ const normalize = (value) => value.replace(/\s+/g, " ").trim();
947
+ const menus = Array.from(document.querySelectorAll("[role='menu']")).filter(visible);
948
+ const submenu = [...menus].reverse().find(menu => menu.querySelector("[role='menuitemradio']") !== null);
949
+ if (submenu === undefined)
950
+ return [];
951
+ return Array.from(submenu.querySelectorAll("[role='menuitemradio']"))
952
+ .filter(visible)
953
+ .map(option => {
954
+ const contentRoot = Array.from(option.children)
955
+ .find(child => !child.hasAttribute("data-trailing-style"));
956
+ const primary = contentRoot?.children.item(0) ?? contentRoot;
957
+ return {
958
+ label: normalize(primary?.textContent
959
+ ?? option.querySelector(".truncate")?.textContent
960
+ ?? option.getAttribute("aria-label")
961
+ ?? option.innerText
962
+ ?? option.textContent
963
+ ?? ""),
964
+ checked: option.getAttribute("aria-checked") === "true"
965
+ || option.getAttribute("data-state") === "checked"
966
+ };
967
+ })
968
+ .filter(option => option.label.length > 0);
969
+ }) ?? [];
514
970
  }
515
971
  async function clickPagePoint(page, point) {
516
972
  const pageWithMouse = page;
@@ -524,83 +980,12 @@ async function clickPagePoint(page, point) {
524
980
  }
525
981
  await body.click({ position: { x: point.x, y: point.y } });
526
982
  }
527
- async function pickerIsOpen(page) {
528
- await wait(300);
529
- return page.evaluate?.(() => document.querySelector("[data-testid='composer-intelligence-picker-content']") !== null
530
- || document.querySelector("[role='menuitemradio']") !== null) ?? false;
531
- }
532
- async function readPickerState(page) {
533
- return await page.evaluate?.(() => {
534
- const menu = document.querySelector("[data-testid='composer-intelligence-picker-content']")
535
- ?? Array.from(document.querySelectorAll("[role='menu']")).find(candidate => candidate.querySelector("[role='menuitemradio']"))
536
- ?? document;
537
- const menuTextLines = (menu.textContent ?? "").split(/\n/).map(line => line.trim()).filter(Boolean);
538
- const items = Array.from(document.querySelectorAll("[role='menuitemradio'], [role='menuitem']")).map((element) => {
539
- const label = (element.textContent ?? "").replace(/\s+/g, " ").trim();
540
- const role = element.getAttribute("role") ?? undefined;
541
- return {
542
- label,
543
- role,
544
- checked: element.getAttribute("aria-checked") === "true",
545
- expanded: element.getAttribute("aria-expanded") === "true",
546
- hasPopup: element.getAttribute("aria-haspopup") ?? undefined,
547
- testId: element.getAttribute("data-testid") ?? undefined,
548
- ariaLabel: element.getAttribute("aria-label") ?? undefined,
549
- };
550
- }).filter(item => item.label.length > 0 || item.ariaLabel !== undefined || item.testId !== undefined);
551
- const radioLabels = items
552
- .filter(item => item.role === "menuitemradio" && item.label.length > 0)
553
- .map(item => item.label);
554
- const versionLabelPattern = /^(?:o\d+|\d+(?:\.\d+)?)$/i;
555
- const modelVersionLabels = radioLabels.filter(label => versionLabelPattern.test(label));
556
- const intelligenceLabels = radioLabels.filter(label => !versionLabelPattern.test(label));
557
- const selectedIntelligenceLabel = items.find(item => item.role === "menuitemradio"
558
- && item.checked
559
- && item.label.length > 0
560
- && !versionLabelPattern.test(item.label))?.label;
561
- const versionFamilyLabels = items
562
- .filter(item => item.role === "menuitem" && /^GPT[\s-]/i.test(item.label))
563
- .map(item => item.label);
564
- const result = {
565
- htmlLang: document.documentElement.lang,
566
- url: location.href,
567
- intelligenceLabels,
568
- versionFamilyLabels,
569
- modelVersionLabels,
570
- items,
571
- };
572
- const heading = menuTextLines.find(line => !radioLabels.includes(line) && !/^GPT[\s-]/i.test(line));
573
- if (heading !== undefined)
574
- result.menuHeading = heading;
575
- if (selectedIntelligenceLabel !== undefined)
576
- result.selectedIntelligenceLabel = selectedIntelligenceLabel;
577
- return result;
578
- }) ?? {
579
- htmlLang: "",
580
- url: "",
581
- intelligenceLabels: [],
582
- versionFamilyLabels: [],
583
- modelVersionLabels: []
584
- };
585
- }
586
- async function openVersionSubmenu(page) {
587
- const gptCandidates = [
588
- page.getByRole?.("menuitem", { name: /^GPT[\s-]/i })?.last?.(),
589
- page.locator?.("[role='menuitem']")?.filter?.({ hasText: /^GPT[\s-]/i })?.last?.(),
590
- ];
591
- for (const gptMenu of gptCandidates) {
592
- if (gptMenu?.click === undefined)
593
- continue;
594
- await gptMenu.click().catch(() => undefined);
595
- if ((await readPickerState(page)).modelVersionLabels.length > 0)
596
- return;
597
- }
598
- }
599
983
  async function captureGenerationStateLabels(page, options) {
600
984
  const warnings = [];
601
985
  const before = await readGenerationUiSnapshot(page).catch(() => ({ controls: [], shortLatestAssistantTexts: [] }));
602
986
  let submitted = false;
603
987
  let stopped = false;
988
+ let stopFailure;
604
989
  let active = before;
605
990
  let stopLabels = [];
606
991
  try {
@@ -623,11 +1008,14 @@ async function captureGenerationStateLabels(page, options) {
623
1008
  warnings.push(`Generation probe failed: ${error instanceof Error ? error.message : String(error)}`);
624
1009
  }
625
1010
  finally {
626
- if (submitted || stopLabels.length > 0 || snapshotLooksActive(active)) {
627
- stopped = await stopGenerationIfVisible(page, stopLabels).catch(error => {
1011
+ if (submitted || stopLabels.length > 0) {
1012
+ try {
1013
+ stopped = await stopGenerationIfVisible(page, stopLabels);
1014
+ }
1015
+ catch (error) {
1016
+ stopFailure = error;
628
1017
  warnings.push(`Unable to stop generation after probe: ${error instanceof Error ? error.message : String(error)}`);
629
- return false;
630
- });
1018
+ }
631
1019
  await wait(options.settleMs);
632
1020
  }
633
1021
  }
@@ -636,6 +1024,9 @@ async function captureGenerationStateLabels(page, options) {
636
1024
  if (submitted && !stopped) {
637
1025
  warnings.push("Generation probe was submitted but no stop action was confirmed.");
638
1026
  }
1027
+ if (stopFailure !== undefined) {
1028
+ throw new Error(`Generation probe cleanup could not be verified: ${stopFailure instanceof Error ? stopFailure.message : String(stopFailure)}`);
1029
+ }
639
1030
  return {
640
1031
  stopLabels,
641
1032
  stoppedLabels,
@@ -766,6 +1157,8 @@ async function readGenerationUiSnapshot(page) {
766
1157
  const visible = (element) => {
767
1158
  if (typeof element.getBoundingClientRect !== "function")
768
1159
  return false;
1160
+ if (element.closest("[hidden], [inert], [aria-hidden='true']") !== null)
1161
+ return false;
769
1162
  const rect = element.getBoundingClientRect();
770
1163
  const style = window.getComputedStyle(element);
771
1164
  return rect.width > 0
@@ -773,13 +1166,19 @@ async function readGenerationUiSnapshot(page) {
773
1166
  && style.display !== "none"
774
1167
  && style.visibility !== "hidden"
775
1168
  && style.opacity !== "0"
776
- && element.getAttribute("aria-hidden") !== "true";
1169
+ && style.pointerEvents !== "none";
777
1170
  };
778
- const relevantSurface = (element) => element.closest("main, form") !== null
779
- && element.closest("nav, aside") === null;
780
- const controls = Array.from(document.querySelectorAll("main button, main [role='button'], form button, form [role='button']"))
1171
+ const textboxes = Array.from(document.querySelectorAll("textarea, [contenteditable='true'], [role='textbox']")).filter(visible);
1172
+ const composers = [...new Set(textboxes
1173
+ .map(textbox => textbox.closest("form")
1174
+ ?? textbox.closest("[data-testid*='composer' i]")
1175
+ ?? textbox.closest("[aria-label*='composer' i]")
1176
+ ?? textbox.closest("[class*='composer' i]"))
1177
+ .filter((value) => value !== null))];
1178
+ const controls = composers.length !== 1 ? [] : Array.from(composers[0].querySelectorAll("button, [role='button']"))
781
1179
  .filter(visible)
782
- .filter(relevantSurface)
1180
+ .filter(element => !element.disabled
1181
+ && element.getAttribute("aria-disabled") !== "true")
783
1182
  .map(element => {
784
1183
  const html = element;
785
1184
  const text = normalize(html.innerText || html.textContent);
@@ -807,10 +1206,11 @@ async function readGenerationUiSnapshot(page) {
807
1206
  return { controls, shortLatestAssistantTexts };
808
1207
  }).catch(() => ({ controls: [], shortLatestAssistantTexts: [] }));
809
1208
  }
810
- function generationStopLabels(before, active) {
1209
+ export function generationStopLabels(before, active) {
811
1210
  const beforeLabels = new Set(before.controls.map(control => normalizedControlKey(control.label)));
812
1211
  const candidates = active.controls
813
- .filter(control => !beforeLabels.has(normalizedControlKey(control.label)) || looksLikeStopControl(control))
1212
+ .filter(control => looksLikeStopControl(control))
1213
+ .filter(control => !beforeLabels.has(normalizedControlKey(control.label)) || hasStableStopTestId(control))
814
1214
  .map(control => control.label)
815
1215
  .filter(isUsefulGenerationLabel);
816
1216
  return dedupeStrings(candidates);
@@ -822,11 +1222,11 @@ function generationStoppedLabels(before, active, afterStop) {
822
1222
  .filter(isUsefulStoppedText);
823
1223
  return dedupeStrings(candidates);
824
1224
  }
825
- function snapshotLooksActive(snapshot) {
1225
+ export function snapshotLooksActive(snapshot) {
826
1226
  return snapshot.controls.some(looksLikeStopControl);
827
1227
  }
828
1228
  function looksLikeStopControl(control) {
829
- return /stop|cancel|abort|interromp|unterbrech|gestoppt|arr[eê]t|detener|parar|interrumpir/i.test([
1229
+ return /stop|abort|interromp|unterbrech|gestoppt|arr[eê]t|detener|parar|interrumpir/i.test([
830
1230
  control.label,
831
1231
  control.text,
832
1232
  control.ariaLabel,
@@ -834,6 +1234,9 @@ function looksLikeStopControl(control) {
834
1234
  control.testId
835
1235
  ].filter(Boolean).join(" "));
836
1236
  }
1237
+ function hasStableStopTestId(control) {
1238
+ return /(?:^|[-_])stop(?:[-_]|$)/i.test(control.testId ?? "");
1239
+ }
837
1240
  function isUsefulGenerationLabel(label) {
838
1241
  const normalized = normalizedControlKey(label);
839
1242
  if (normalized.length < 2 || normalized.length > 80)
@@ -852,54 +1255,75 @@ function isUsefulStoppedText(text) {
852
1255
  return false;
853
1256
  return true;
854
1257
  }
855
- async function stopGenerationIfVisible(page, labels) {
1258
+ export async function stopGenerationIfVisible(page, labels) {
1259
+ if (labels.length === 0)
1260
+ return false;
1261
+ let selected;
856
1262
  for (const label of labels) {
857
- const roleButton = page.getByRole?.("button", { name: label, exact: true })?.last?.();
858
- if (roleButton?.click !== undefined) {
859
- await roleButton.click().catch(() => undefined);
860
- await wait(500);
861
- return true;
1263
+ const eligible = await scopedStopCandidates(page, label);
1264
+ if (eligible.length === 1 && eligible[0]?.click !== undefined) {
1265
+ selected = eligible[0];
1266
+ break;
862
1267
  }
863
1268
  }
864
- if (typeof page.evaluate !== "function")
1269
+ if (selected?.click === undefined)
865
1270
  return false;
866
- return page.evaluate((wantedLabels) => {
867
- const normalize = (value) => (value ?? "").replace(/\s+/g, " ").trim().toLowerCase();
868
- const wanted = new Set(wantedLabels.map(label => label.toLowerCase()));
869
- const relevantSurface = (element) => element.closest("main, form") !== null
870
- && element.closest("nav, aside") === null;
871
- const visible = (element) => {
872
- const rect = element.getBoundingClientRect();
873
- const style = window.getComputedStyle(element);
874
- return rect.width > 0
875
- && rect.height > 0
876
- && style.display !== "none"
877
- && style.visibility !== "hidden"
878
- && style.opacity !== "0";
879
- };
880
- const buttons = Array.from(document.querySelectorAll("main button, main [role='button'], form button, form [role='button']"))
881
- .filter((button) => typeof button.getBoundingClientRect === "function" && visible(button))
882
- .filter(relevantSurface);
883
- const match = buttons.find(button => {
884
- const label = [
885
- button.getAttribute("aria-label"),
886
- button.getAttribute("title"),
887
- button.innerText,
888
- button.textContent,
889
- button.getAttribute("data-testid")
890
- ].map(normalize).filter(Boolean).join(" ");
891
- return wanted.has(label)
892
- || /stop|cancel|abort|interromp|unterbrech|gestoppt|arr[eê]t|detener|parar|interrumpir/i.test(label);
1271
+ await selected.click();
1272
+ const expiresAt = Date.now() + 2_000;
1273
+ while (Date.now() < expiresAt) {
1274
+ const remaining = (await Promise.all(labels.map(label => scopedStopCandidates(page, label)))).flat();
1275
+ if (remaining.length === 0)
1276
+ return true;
1277
+ await wait(Math.min(100, Math.max(1, expiresAt - Date.now())));
1278
+ }
1279
+ throw new Error("The locale-capture Stop control was clicked, but generation did not become observably inactive.");
1280
+ }
1281
+ async function scopedStopCandidates(page, label) {
1282
+ const candidates = page.getByRole?.("button", { name: label, exact: true });
1283
+ if (candidates?.count === undefined) {
1284
+ throw new Error(`The locale-capture Stop candidates for ${JSON.stringify(label)} could not be enumerated.`);
1285
+ }
1286
+ const count = await candidates.count();
1287
+ const eligible = [];
1288
+ for (let index = 0; index < count; index += 1) {
1289
+ const candidate = count === 1 ? candidates : candidates?.nth?.(index);
1290
+ if (candidate?.isVisible === undefined || candidate.evaluate === undefined) {
1291
+ throw new Error(`Locale-capture Stop candidate ${index + 1} for ${JSON.stringify(label)} could not be inspected.`);
1292
+ }
1293
+ if (!await candidate.isVisible())
1294
+ continue;
1295
+ const scoped = await candidate.evaluate((element) => {
1296
+ const button = element;
1297
+ if (button.disabled || button.getAttribute("aria-disabled") === "true")
1298
+ return false;
1299
+ const visible = (node) => {
1300
+ if (node.hidden || node.closest("[hidden], [inert], [aria-hidden='true']") !== null)
1301
+ return false;
1302
+ const style = window.getComputedStyle(node);
1303
+ const rect = node.getBoundingClientRect();
1304
+ return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0"
1305
+ && style.pointerEvents !== "none" && (rect.width > 0 || rect.height > 0);
1306
+ };
1307
+ const textboxes = Array.from(document.querySelectorAll("textarea, [contenteditable='true'], [role='textbox']")).filter(visible);
1308
+ const composers = [...new Set(textboxes
1309
+ .map(textbox => textbox.closest("form")
1310
+ ?? textbox.closest("[data-testid*='composer' i]")
1311
+ ?? textbox.closest("[aria-label*='composer' i]")
1312
+ ?? textbox.closest("[class*='composer' i]"))
1313
+ .filter((value) => value !== null))];
1314
+ return composers.length === 1 && composers[0].contains(button);
893
1315
  });
894
- if (match === undefined)
895
- return false;
896
- match.click();
897
- return true;
898
- }, [...labels]).catch(() => false);
1316
+ if (scoped)
1317
+ eligible.push(candidate);
1318
+ }
1319
+ return eligible;
899
1320
  }
900
1321
  function normalizedControlKey(label) {
901
1322
  return label.replace(/\s+/g, " ").trim().toLowerCase();
902
1323
  }
1324
+ function normalized(value) {
1325
+ return (value ?? "").replace(/\s+/g, " ").trim();
1326
+ }
903
1327
  function dedupeStrings(values) {
904
1328
  const seen = new Set();
905
1329
  const result = [];
@@ -957,7 +1381,7 @@ async function appendRecord(path, record) {
957
1381
  }
958
1382
  function printCaptureRecord(record, out) {
959
1383
  if (record.status === "ok") {
960
- console.log(`captured ${record.requestedLocale} htmlLang=${record.htmlLang ?? "unknown"} intelligence=${record.intelligenceLabels?.length ?? 0} versions=${record.modelVersionLabels?.length ?? 0} generationStop=${record.generationStopLabels?.length ?? 0} generationStopped=${record.generationStoppedLabels?.length ?? 0} out=${out}`);
1384
+ console.log(`captured ${record.requestedLocale} htmlLang=${record.htmlLang ?? "unknown"} intelligence=${record.intelligenceLabels?.length ?? 0} versions=${record.modelVersionLabels?.length ?? 0} surfaces=${record.surfaceCapture?.status ?? "not-requested"} generationStop=${record.generationStopLabels?.length ?? 0} generationStopped=${record.generationStoppedLabels?.length ?? 0} out=${out}`);
961
1385
  }
962
1386
  else {
963
1387
  console.log(`blocked ${record.requestedLocale} htmlLang=${record.htmlLang ?? "unknown"} code=${record.blocker?.code ?? "unknown"} out=${out}`);
@@ -1029,9 +1453,6 @@ function packageRoot() {
1029
1453
  function wait(ms) {
1030
1454
  return new Promise(resolve => setTimeout(resolve, ms));
1031
1455
  }
1032
- function escapeRegExp(value) {
1033
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1034
- }
1035
1456
  if (typeof process !== "undefined" && process.argv[1] === fileURLToPath(import.meta.url)) {
1036
1457
  const exitCode = await main();
1037
1458
  process.exitCode = exitCode;