@veolab/discoverylab 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/chunk-2OGZX6C4.js +588 -0
  2. package/dist/chunk-43U6UYV7.js +590 -0
  3. package/dist/chunk-4H2E3K2G.js +7638 -0
  4. package/dist/chunk-4KLG6DDE.js +334 -0
  5. package/dist/chunk-4NNTRJOI.js +7791 -0
  6. package/dist/chunk-5F76VWME.js +6397 -0
  7. package/dist/chunk-5NEFN42O.js +7791 -0
  8. package/dist/chunk-63MEQ6UH.js +7673 -0
  9. package/dist/chunk-C7QUR7XX.js +6397 -0
  10. package/dist/chunk-GGJJUCFK.js +7160 -0
  11. package/dist/chunk-GLHOY3NN.js +7805 -0
  12. package/dist/chunk-GSWHWEYC.js +1346 -0
  13. package/dist/chunk-HDKEQOF5.js +7788 -0
  14. package/dist/chunk-HZGSWVVS.js +7111 -0
  15. package/dist/chunk-I6YD3QFM.js +500 -0
  16. package/dist/chunk-KV7KDJ43.js +7639 -0
  17. package/dist/chunk-L4SA5F5W.js +6397 -0
  18. package/dist/chunk-MJS2YKNR.js +6397 -0
  19. package/dist/chunk-NDBW6ELQ.js +7638 -0
  20. package/dist/chunk-P4S7ZY6G.js +7638 -0
  21. package/dist/chunk-PMTGGZ7R.js +6397 -0
  22. package/dist/chunk-PYUCY3U6.js +1340 -0
  23. package/dist/chunk-RDZDSOAL.js +7750 -0
  24. package/dist/chunk-SLNJEF32.js +91 -0
  25. package/dist/chunk-SR67SRIT.js +1336 -0
  26. package/dist/chunk-TAODYZ52.js +1393 -0
  27. package/dist/chunk-TBG76CYG.js +6395 -0
  28. package/dist/chunk-TJ3H23LL.js +362 -0
  29. package/dist/chunk-XIBF5LBD.js +6395 -0
  30. package/dist/chunk-XUKWS2CE.js +7805 -0
  31. package/dist/cli.js +6 -6
  32. package/dist/db-ADBEBNH6.js +35 -0
  33. package/dist/index.d.ts +170 -1
  34. package/dist/index.html +1019 -84
  35. package/dist/index.js +9 -7
  36. package/dist/playwright-ATDC4NYW.js +38 -0
  37. package/dist/playwright-E6EUFIJG.js +38 -0
  38. package/dist/playwright-R7Y5HREH.js +39 -0
  39. package/dist/server-2VKO76UK.js +14 -0
  40. package/dist/server-3BK2VFU7.js +13 -0
  41. package/dist/server-6IPHVUYT.js +14 -0
  42. package/dist/server-73P7M3QB.js +14 -0
  43. package/dist/server-BPVRW5LJ.js +14 -0
  44. package/dist/server-IOOZK4NP.js +14 -0
  45. package/dist/server-NPZN3FWO.js +14 -0
  46. package/dist/server-O5FIAHSY.js +14 -0
  47. package/dist/server-P27BZXBL.js +14 -0
  48. package/dist/server-S6B5WUBT.js +14 -0
  49. package/dist/server-SRYNSGSP.js +14 -0
  50. package/dist/server-X3TLP6DX.js +14 -0
  51. package/dist/server-ZBPQ33V6.js +14 -0
  52. package/dist/setup-AQX4JQVR.js +17 -0
  53. package/dist/tools-2KPB37GK.js +178 -0
  54. package/dist/tools-3H6IOWXV.js +178 -0
  55. package/dist/tools-BUVCUCRL.js +178 -0
  56. package/dist/tools-HDNODRS6.js +178 -0
  57. package/dist/tools-L6PKKQPY.js +179 -0
  58. package/dist/tools-N5N2IO7V.js +178 -0
  59. package/dist/tools-TLCKABUW.js +178 -0
  60. package/package.json +1 -1
package/dist/index.html CHANGED
@@ -1824,12 +1824,22 @@
1824
1824
  justify-content: center;
1825
1825
  padding-top: 15vh;
1826
1826
  z-index: 1000;
1827
+ overscroll-behavior: contain;
1827
1828
  }
1828
1829
 
1829
1830
  .modal-overlay.active {
1830
1831
  display: flex;
1831
1832
  }
1832
1833
 
1834
+ .modal-overlay.code-modal-overlay {
1835
+ align-items: center;
1836
+ justify-content: center;
1837
+ padding: clamp(10px, 3vh, 24px);
1838
+ padding-top: clamp(10px, 3vh, 24px);
1839
+ background: rgba(4, 6, 10, 0.78);
1840
+ backdrop-filter: blur(2px);
1841
+ }
1842
+
1833
1843
  .modal {
1834
1844
  background: var(--bg-surface);
1835
1845
  border: 1px solid var(--border);
@@ -2014,10 +2024,12 @@
2014
2024
  max-width: 900px;
2015
2025
  width: min(92vw, 900px);
2016
2026
  max-height: 82vh;
2027
+ height: min(86vh, 860px);
2017
2028
  display: flex;
2018
2029
  flex-direction: column;
2019
2030
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.02)), var(--bg-surface);
2020
2031
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
2032
+ overscroll-behavior: contain;
2021
2033
  }
2022
2034
 
2023
2035
  .code-modal .modal-header {
@@ -2053,12 +2065,21 @@
2053
2065
  }
2054
2066
 
2055
2067
  .code-modal-body {
2056
- padding: 14px 18px 0;
2068
+ padding: 12px 16px 0;
2057
2069
  flex: 1;
2058
2070
  overflow: hidden;
2059
2071
  display: flex;
2060
2072
  flex-direction: column;
2061
- gap: 12px;
2073
+ gap: 10px;
2074
+ min-height: 0;
2075
+ }
2076
+
2077
+ .code-modal-panel {
2078
+ display: flex;
2079
+ flex-direction: column;
2080
+ flex: 1;
2081
+ min-height: 0;
2082
+ min-width: 0;
2062
2083
  }
2063
2084
 
2064
2085
  .code-editor {
@@ -2081,17 +2102,20 @@
2081
2102
  .code-editor-container {
2082
2103
  display: flex;
2083
2104
  flex: 1;
2084
- background: var(--bg-primary);
2105
+ background: #070b11;
2085
2106
  border: 1px solid var(--border);
2086
2107
  border-radius: 10px;
2087
2108
  overflow: hidden;
2088
2109
  min-height: 320px;
2110
+ height: 100%;
2111
+ min-width: 0;
2112
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
2089
2113
  }
2090
2114
 
2091
2115
  .code-line-numbers {
2092
- background: var(--bg-tertiary);
2116
+ background: #0b1017;
2093
2117
  color: var(--text-muted);
2094
- padding: 14px 12px 14px 14px;
2118
+ padding: 14px 12px calc(14px + 4.8em) 14px;
2095
2119
  font-family: 'SF Mono', Monaco, Consolas, monospace;
2096
2120
  font-size: 12px;
2097
2121
  line-height: 1.6;
@@ -2099,16 +2123,16 @@
2099
2123
  user-select: none;
2100
2124
  border-right: 1px solid var(--border);
2101
2125
  min-width: 45px;
2102
- overflow: hidden;
2126
+ overflow-y: hidden;
2103
2127
  white-space: pre;
2104
2128
  }
2105
2129
 
2106
2130
  .code-editor-textarea {
2107
2131
  flex: 1;
2108
- background: transparent;
2132
+ background: #070b11;
2109
2133
  color: var(--text-primary);
2110
2134
  border: none;
2111
- padding: 14px 16px;
2135
+ padding: 14px 16px calc(14px + 4.8em) 16px;
2112
2136
  font-family: 'SF Mono', Monaco, Consolas, monospace;
2113
2137
  font-size: 12px;
2114
2138
  line-height: 1.6;
@@ -2116,12 +2140,22 @@
2116
2140
  tab-size: 2;
2117
2141
  outline: none;
2118
2142
  overflow-y: auto;
2143
+ height: 100%;
2144
+ min-height: 0;
2145
+ box-sizing: border-box;
2146
+ scroll-padding-bottom: 4.8em;
2147
+ overscroll-behavior: contain;
2148
+ -webkit-overflow-scrolling: touch;
2119
2149
  }
2120
2150
 
2121
2151
  .code-editor-textarea:focus {
2122
2152
  outline: none;
2123
2153
  }
2124
2154
 
2155
+ .code-editor-textarea::selection {
2156
+ background: rgba(96, 165, 250, 0.28);
2157
+ }
2158
+
2125
2159
  /* YAML Syntax Highlighting (via overlay) */
2126
2160
  .yaml-key { color: #7dd3fc; }
2127
2161
  .yaml-value { color: #fde68a; }
@@ -2140,6 +2174,16 @@
2140
2174
  max-height: 180px;
2141
2175
  overflow-y: auto;
2142
2176
  color: #8b949e;
2177
+ overscroll-behavior: contain;
2178
+ -webkit-overflow-scrolling: touch;
2179
+ }
2180
+
2181
+ .code-vars-table-wrap {
2182
+ overflow: auto;
2183
+ border: 1px solid var(--border);
2184
+ border-radius: 8px;
2185
+ overscroll-behavior: contain;
2186
+ -webkit-overflow-scrolling: touch;
2143
2187
  }
2144
2188
 
2145
2189
  .code-modal-output .output-success { color: #4ade80; }
@@ -2179,6 +2223,7 @@
2179
2223
  @media (max-width: 720px) {
2180
2224
  .code-modal {
2181
2225
  max-height: 88vh;
2226
+ height: min(92vh, 920px);
2182
2227
  }
2183
2228
 
2184
2229
  .code-modal-footer {
@@ -7049,7 +7094,8 @@
7049
7094
  <div style="font-size: 10px; color: var(--text-muted); margin-top: 4px;">
7050
7095
  <a href="https://ollama.ai" target="_blank" style="color: var(--accent);">Install Ollama</a> - Used for local text/coding and optional visual fallback. Recommended starting points:
7051
7096
  <a href="https://ollama.com/library/qwen2.5-coder" target="_blank" style="color: var(--accent);">qwen2.5-coder</a> (scripts/text) and
7052
- <a href="https://ollama.com/library/qwen2.5vl" target="_blank" style="color: var(--accent);">qwen2.5vl</a> (screenshots/vision). You must download and test on your machine.
7097
+ <a href="https://ollama.com/library/qwen2.5vl" target="_blank" style="color: var(--accent);">qwen2.5vl</a> or
7098
+ <a href="https://ollama.com/library/gemma3" target="_blank" style="color: var(--accent);">gemma3</a> (for example <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">gemma3:4b</code>) for screenshots/vision. You must download and test on your machine.
7053
7099
  </div>
7054
7100
  </div>
7055
7101
  <div class="setting-item">
@@ -8324,7 +8370,7 @@
8324
8370
  </div>
8325
8371
  <div style="font-size: 10px; color: var(--text-muted); line-height: 1.4;">
8326
8372
  Recommended: <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">qwen2.5-coder:7b</code> for scripts/text and
8327
- <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">qwen2.5vl:7b</code> for screenshots/vision.
8373
+ <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">qwen2.5vl:7b</code> or <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">gemma3:4b</code> for screenshots/vision.
8328
8374
  </div>
8329
8375
  ${selectedTextModelAvailable ? '' : `
8330
8376
  <div style="padding: 8px; background: rgba(245, 158, 11, 0.08); border: 1px solid rgba(245, 158, 11, 0.2); border-radius: 6px; color: var(--warning); font-size: 11px;">
@@ -8338,12 +8384,12 @@
8338
8384
  `}
8339
8385
  ${selectedVisionModelAvailable && !visionModelLooksCapable ? `
8340
8386
  <div style="padding: 8px; background: rgba(245, 158, 11, 0.08); border: 1px solid rgba(245, 158, 11, 0.2); border-radius: 6px; color: var(--warning); font-size: 11px;">
8341
- Vision model "${selectedVisionModel}" may not support images well. A multimodal model like <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">qwen2.5vl:7b</code> is recommended.
8387
+ Vision model "${selectedVisionModel}" may not support images well. A multimodal model like <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">qwen2.5vl:7b</code> or <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">gemma3:4b</code> is recommended.
8342
8388
  </div>
8343
8389
  ` : ''}
8344
8390
  ${preferOllamaVisionForActionDetection && visionCapableModels.length === 0 ? `
8345
8391
  <div style="padding: 8px; background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.18); border-radius: 6px; color: var(--error); font-size: 11px;">
8346
- "Prefer Ollama Vision" is enabled, but no installed Ollama models look multimodal/vision-capable. Install <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">qwen2.5vl:7b</code> (or another vision model).
8392
+ "Prefer Ollama Vision" is enabled, but no installed Ollama models look multimodal/vision-capable. Install <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">qwen2.5vl:7b</code> or <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">gemma3:4b</code> (or another vision model).
8347
8393
  </div>
8348
8394
  ` : ''}
8349
8395
  </div>
@@ -8363,7 +8409,7 @@
8363
8409
  <div style="color: var(--text-muted);">
8364
8410
  Install and test models (examples):
8365
8411
  <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">ollama pull qwen2.5-coder:7b</code> and
8366
- <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">ollama pull qwen2.5vl:7b</code>
8412
+ <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">ollama pull qwen2.5vl:7b</code> (or <code style="background: var(--bg-elevated); padding: 1px 4px; border-radius: 3px;">ollama pull gemma3:4b</code>)
8367
8413
  </div>
8368
8414
  </div>
8369
8415
  `;
@@ -14860,7 +14906,7 @@
14860
14906
 
14861
14907
  // If we have the session locally, generate code directly
14862
14908
  if (recorderSession.actions && recorderSession.actions.length > 0) {
14863
- showCodeModal(generateSpecCode(recorderSession));
14909
+ showCodeModal(generateSpecCode(recorderSession), 'typescript', recorderSession.id, 'web');
14864
14910
  return;
14865
14911
  }
14866
14912
 
@@ -14879,7 +14925,7 @@
14879
14925
  }
14880
14926
 
14881
14927
  // Show code in a modal
14882
- showCodeModal(data.specCode || generateSpecCode(data.recording));
14928
+ showCodeModal(data.specCode || generateSpecCode(data.recording), 'typescript', recorderSession.id, 'web');
14883
14929
 
14884
14930
  } catch (error) {
14885
14931
  showToast('Failed to load spec code', 'error');
@@ -16421,20 +16467,557 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16421
16467
  }
16422
16468
  }
16423
16469
 
16470
+ let codeModalState = null;
16471
+ let codeModalScrollLock = null;
16472
+ const CODE_MODAL_MOBILE_DEVICE_STORAGE_KEY = 'discoverylab.codeModal.mobileRunDeviceId';
16473
+
16474
+ function lockPageScrollForCodeModal() {
16475
+ if (codeModalScrollLock) return;
16476
+ const body = document.body;
16477
+ const docEl = document.documentElement;
16478
+ const scrollX = window.scrollX || docEl.scrollLeft || 0;
16479
+ const scrollY = window.scrollY || docEl.scrollTop || 0;
16480
+
16481
+ codeModalScrollLock = {
16482
+ scrollX,
16483
+ scrollY,
16484
+ body: {
16485
+ position: body.style.position,
16486
+ top: body.style.top,
16487
+ left: body.style.left,
16488
+ right: body.style.right,
16489
+ width: body.style.width,
16490
+ overflow: body.style.overflow
16491
+ },
16492
+ htmlOverflow: docEl.style.overflow
16493
+ };
16494
+
16495
+ docEl.style.overflow = 'hidden';
16496
+ body.style.overflow = 'hidden';
16497
+ body.style.position = 'fixed';
16498
+ body.style.top = `-${scrollY}px`;
16499
+ body.style.left = `-${scrollX}px`;
16500
+ body.style.right = '0';
16501
+ body.style.width = '100%';
16502
+ }
16503
+
16504
+ function unlockPageScrollForCodeModal() {
16505
+ if (!codeModalScrollLock) return;
16506
+ const body = document.body;
16507
+ const docEl = document.documentElement;
16508
+ const lock = codeModalScrollLock;
16509
+
16510
+ docEl.style.overflow = lock.htmlOverflow || '';
16511
+ body.style.position = lock.body.position || '';
16512
+ body.style.top = lock.body.top || '';
16513
+ body.style.left = lock.body.left || '';
16514
+ body.style.right = lock.body.right || '';
16515
+ body.style.width = lock.body.width || '';
16516
+ body.style.overflow = lock.body.overflow || '';
16517
+
16518
+ window.scrollTo(lock.scrollX || 0, lock.scrollY || 0);
16519
+ codeModalScrollLock = null;
16520
+ }
16521
+
16522
+ function getPreferredCodeModalMobileDeviceId() {
16523
+ if (codeModalState?.mobileRunSelectedDeviceId) return codeModalState.mobileRunSelectedDeviceId;
16524
+ try {
16525
+ const fromStorage = localStorage.getItem(CODE_MODAL_MOBILE_DEVICE_STORAGE_KEY);
16526
+ if (fromStorage) return fromStorage;
16527
+ } catch {}
16528
+ const deviceDropdown = document.getElementById('deviceDropdown');
16529
+ if (deviceDropdown && deviceDropdown.value) return deviceDropdown.value;
16530
+ return '';
16531
+ }
16532
+
16533
+ function formatCodeModalMobileDeviceOptionLabel(device) {
16534
+ const platform = String(device?.platform || '').toUpperCase() || 'MOBILE';
16535
+ const name = String(device?.name || device?.id || 'Unknown device');
16536
+ const status = String(device?.status || '').toLowerCase();
16537
+ return status ? `${platform} · ${name} (${status})` : `${platform} · ${name}`;
16538
+ }
16539
+
16540
+ function renderCodeModalMobileDevicePicker() {
16541
+ const selectEl = document.getElementById('codeModalMobileDeviceSelect');
16542
+ const statusEl = document.getElementById('codeModalMobileDeviceStatus');
16543
+ if (!selectEl || !statusEl || !codeModalState) return;
16544
+
16545
+ const devices = Array.isArray(codeModalState.mobileRunDevices) ? codeModalState.mobileRunDevices : [];
16546
+ const loading = codeModalState.mobileRunDevicesLoading === true;
16547
+ const selectedId = codeModalState.mobileRunSelectedDeviceId || '';
16548
+
16549
+ const options = ['<option value="">Auto-detect device (Maestro default)</option>'];
16550
+ for (const device of devices) {
16551
+ const id = String(device?.id || '').trim();
16552
+ if (!id) continue;
16553
+ const selectedAttr = selectedId === id ? ' selected' : '';
16554
+ options.push(`<option value="${escapeAttr(id)}"${selectedAttr}>${escapeHtml(formatCodeModalMobileDeviceOptionLabel(device))}</option>`);
16555
+ }
16556
+ selectEl.innerHTML = options.join('');
16557
+
16558
+ if (loading) {
16559
+ statusEl.textContent = 'Loading devices...';
16560
+ statusEl.style.color = 'var(--text-muted)';
16561
+ } else if (devices.length === 0) {
16562
+ statusEl.textContent = 'No connected/booted devices found';
16563
+ statusEl.style.color = 'var(--warning)';
16564
+ } else if (selectedId) {
16565
+ const selected = devices.find(d => String(d.id || '') === selectedId);
16566
+ statusEl.textContent = selected ? `Selected: ${selected.name}` : 'Selected device unavailable (using auto)';
16567
+ statusEl.style.color = selected ? 'var(--success)' : 'var(--warning)';
16568
+ } else {
16569
+ statusEl.textContent = 'Using auto-detect unless a device is selected';
16570
+ statusEl.style.color = 'var(--text-muted)';
16571
+ }
16572
+ }
16573
+
16574
+ async function loadCodeModalMobileRunDevices(forceRefresh = false) {
16575
+ if (!codeModalState || codeModalState.type !== 'mobile') return;
16576
+ codeModalState.mobileRunDevicesLoading = true;
16577
+ renderCodeModalMobileDevicePicker();
16578
+ try {
16579
+ const url = forceRefresh
16580
+ ? '/api/testing/mobile/maestro-devices?refresh=1'
16581
+ : '/api/testing/mobile/maestro-devices';
16582
+ const response = await fetch(url);
16583
+ const data = await response.json();
16584
+ if (!response.ok) throw new Error(data.error || 'Failed to load devices');
16585
+ const devices = Array.isArray(data.devices) ? data.devices : [];
16586
+ codeModalState.mobileRunDevices = devices;
16587
+
16588
+ const preferred = codeModalState.mobileRunSelectedDeviceId || getPreferredCodeModalMobileDeviceId();
16589
+ if (preferred && devices.some(d => String(d.id || '') === preferred)) {
16590
+ codeModalState.mobileRunSelectedDeviceId = preferred;
16591
+ } else {
16592
+ codeModalState.mobileRunSelectedDeviceId = '';
16593
+ }
16594
+ renderCodeModalMobileDevicePicker();
16595
+ } catch (error) {
16596
+ console.error('Failed to load Maestro devices for code modal:', error);
16597
+ codeModalState.mobileRunDevices = [];
16598
+ codeModalState.mobileRunSelectedDeviceId = '';
16599
+ renderCodeModalMobileDevicePicker();
16600
+ const statusEl = document.getElementById('codeModalMobileDeviceStatus');
16601
+ if (statusEl) {
16602
+ statusEl.textContent = `Failed to load devices: ${error.message || 'Unknown error'}`;
16603
+ statusEl.style.color = 'var(--error)';
16604
+ }
16605
+ } finally {
16606
+ if (codeModalState) codeModalState.mobileRunDevicesLoading = false;
16607
+ renderCodeModalMobileDevicePicker();
16608
+ }
16609
+ }
16610
+
16611
+ function onCodeModalMobileDeviceChanged(value) {
16612
+ if (!codeModalState) return;
16613
+ const normalized = String(value || '').trim();
16614
+ codeModalState.mobileRunSelectedDeviceId = normalized;
16615
+ try {
16616
+ if (normalized) localStorage.setItem(CODE_MODAL_MOBILE_DEVICE_STORAGE_KEY, normalized);
16617
+ else localStorage.removeItem(CODE_MODAL_MOBILE_DEVICE_STORAGE_KEY);
16618
+ } catch {}
16619
+ renderCodeModalMobileDevicePicker();
16620
+ }
16621
+
16622
+ function findScrollableAncestorInModal(target, modalRoot) {
16623
+ let el = target instanceof Element ? target : null;
16624
+ while (el && el !== modalRoot) {
16625
+ const style = window.getComputedStyle(el);
16626
+ const overflowY = style.overflowY || style.overflow;
16627
+ const overflowX = style.overflowX || style.overflow;
16628
+ const canScrollY = /(auto|scroll)/.test(overflowY) && el.scrollHeight > (el.clientHeight + 1);
16629
+ const canScrollX = /(auto|scroll)/.test(overflowX) && el.scrollWidth > (el.clientWidth + 1);
16630
+ if (canScrollY || canScrollX) return el;
16631
+ el = el.parentElement;
16632
+ }
16633
+ return null;
16634
+ }
16635
+
16636
+ function attachCodeModalScrollTrap(modalEl) {
16637
+ if (!modalEl) return;
16638
+ const wheelHandler = (e) => {
16639
+ if (!modalEl.contains(e.target)) return;
16640
+ const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
16641
+ const firstElementInPath = path.find((node) => node instanceof Element);
16642
+ const targetEl = (e.target instanceof Element ? e.target : null) || (firstElementInPath instanceof Element ? firstElementInPath : null);
16643
+
16644
+ // Only intercept wheel when cursor is on the line-number gutter.
16645
+ // Everything else should use native scrolling in Chrome (textarea/vars/output).
16646
+ if (targetEl?.closest('#codeLineNumbers')) {
16647
+ const textarea = modalEl.querySelector('#codeEditorTextarea');
16648
+ if (textarea) {
16649
+ textarea.scrollBy({
16650
+ top: e.deltaY || 0,
16651
+ left: e.deltaX || 0,
16652
+ behavior: 'auto'
16653
+ });
16654
+ }
16655
+ e.preventDefault();
16656
+ e.stopPropagation();
16657
+ return;
16658
+ }
16659
+ };
16660
+
16661
+ modalEl.addEventListener('wheel', wheelHandler, { passive: false, capture: true });
16662
+ modalEl._wheelTrapHandler = wheelHandler;
16663
+ }
16664
+
16665
+ function getCodeModalOwnerType(type) {
16666
+ return type === 'mobile' ? 'mobile-recording' : 'web-recording';
16667
+ }
16668
+
16669
+ function extractCodeModalPlaceholders(code) {
16670
+ const matches = new Set();
16671
+ const regex = /\$\{([A-Z][A-Z0-9_]*)\}/g;
16672
+ let match;
16673
+ const text = String(code || '');
16674
+ while ((match = regex.exec(text)) !== null) {
16675
+ if (match[1]) matches.add(match[1]);
16676
+ }
16677
+ return Array.from(matches).sort();
16678
+ }
16679
+
16680
+ function escapeAttr(value) {
16681
+ return String(value ?? '')
16682
+ .replace(/&/g, '&amp;')
16683
+ .replace(/"/g, '&quot;')
16684
+ .replace(/</g, '&lt;')
16685
+ .replace(/>/g, '&gt;');
16686
+ }
16687
+
16688
+ function parseEnvTestTextClient(rawText) {
16689
+ const rows = [];
16690
+ const lines = String(rawText || '').split(/\r?\n/);
16691
+ let pendingNotes = [];
16692
+ let pendingPlatform = 'both';
16693
+ for (const line of lines) {
16694
+ const trimmed = line.trim();
16695
+ if (!trimmed) {
16696
+ pendingNotes = [];
16697
+ pendingPlatform = 'both';
16698
+ continue;
16699
+ }
16700
+ if (trimmed.startsWith('#')) {
16701
+ const comment = trimmed.slice(1).trim();
16702
+ const m = comment.match(/^platform:\s*(mobile|web|both)$/i);
16703
+ if (m) {
16704
+ pendingPlatform = m[1].toLowerCase();
16705
+ } else if (comment) {
16706
+ pendingNotes.push(comment);
16707
+ }
16708
+ continue;
16709
+ }
16710
+ const eqIndex = line.indexOf('=');
16711
+ if (eqIndex <= 0) continue;
16712
+ const key = line.slice(0, eqIndex).trim().toUpperCase();
16713
+ if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(key)) continue;
16714
+ rows.push({
16715
+ id: 'var_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
16716
+ key,
16717
+ value: line.slice(eqIndex + 1).replace(/\\n/g, '\n'),
16718
+ isSecret: /PASSWORD|SECRET|TOKEN|KEY|OTP|PIN/.test(key),
16719
+ platform: ['mobile', 'web', 'both'].includes(pendingPlatform) ? pendingPlatform : 'both',
16720
+ notes: pendingNotes.join(' ') || ''
16721
+ });
16722
+ pendingNotes = [];
16723
+ pendingPlatform = 'both';
16724
+ }
16725
+ return rows;
16726
+ }
16727
+
16728
+ function renderEnvTestTextClient(rows) {
16729
+ const lines = [];
16730
+ for (const row of (rows || [])) {
16731
+ const key = String(row.key || '').trim().toUpperCase();
16732
+ if (!key) continue;
16733
+ if (row.notes) lines.push(`# ${String(row.notes).trim()}`);
16734
+ if (row.platform && row.platform !== 'both') lines.push(`# platform: ${row.platform}`);
16735
+ lines.push(`${key}=${String(row.value || '').replace(/\n/g, '\\n')}`);
16736
+ lines.push('');
16737
+ }
16738
+ return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
16739
+ }
16740
+
16741
+ function collectCodeModalVariablesFromTable() {
16742
+ const rows = Array.from(document.querySelectorAll('#codeModalVarsTableBody tr[data-var-row]'));
16743
+ return rows.map((tr) => ({
16744
+ id: tr.dataset.id || '',
16745
+ key: (tr.querySelector('[data-field="key"]')?.value || '').trim().toUpperCase(),
16746
+ value: tr.querySelector('[data-field="value"]')?.value || '',
16747
+ isSecret: tr.querySelector('[data-field="isSecret"]')?.checked === true,
16748
+ platform: tr.querySelector('[data-field="platform"]')?.value || 'both',
16749
+ notes: (tr.querySelector('[data-field="notes"]')?.value || '').trim(),
16750
+ })).filter((row) => row.key);
16751
+ }
16752
+
16753
+ function getCodeModalVariablesSignature(rows) {
16754
+ return JSON.stringify((rows || []).map((row) => ({
16755
+ key: String(row.key || '').trim().toUpperCase(),
16756
+ value: String(row.value || ''),
16757
+ isSecret: row.isSecret === true,
16758
+ platform: ['mobile', 'web', 'both'].includes(row.platform) ? row.platform : 'both',
16759
+ notes: String(row.notes || '').trim(),
16760
+ })));
16761
+ }
16762
+
16763
+ function syncCodeModalEnvFromTable() {
16764
+ const envTextarea = document.getElementById('codeModalEnvTextarea');
16765
+ if (!envTextarea) return;
16766
+ const rows = collectCodeModalVariablesFromTable();
16767
+ envTextarea.value = renderEnvTestTextClient(rows);
16768
+ if (codeModalState) codeModalState.variables = rows;
16769
+ updateCodeModalVariablesStatus();
16770
+ }
16771
+
16772
+ function updateCodeModalVariablesStatus() {
16773
+ if (!codeModalState) return;
16774
+ const codeText = document.getElementById('codeEditorTextarea')?.value || codeModalState.code || '';
16775
+ const placeholders = extractCodeModalPlaceholders(codeText);
16776
+ codeModalState.placeholders = placeholders;
16777
+ const rows = collectCodeModalVariablesFromTable();
16778
+ const activePlatform = codeModalState.type === 'mobile' ? 'mobile' : 'web';
16779
+ const scoped = rows.filter((row) => (row.platform || 'both') === 'both' || row.platform === activePlatform);
16780
+ const rowKeys = new Set(scoped.map((row) => String(row.key || '').trim().toUpperCase()).filter(Boolean));
16781
+ const missing = placeholders.filter((key) => !rowKeys.has(key));
16782
+ const unused = scoped.map((row) => row.key).filter((key) => key && !placeholders.includes(key));
16783
+ codeModalState.missingPlaceholders = missing;
16784
+
16785
+ const summaryEl = document.getElementById('codeModalVarSummary');
16786
+ if (summaryEl) {
16787
+ summaryEl.innerHTML = `
16788
+ <span>${placeholders.length} placeholder${placeholders.length === 1 ? '' : 's'} in script</span>
16789
+ <span style="color:${missing.length ? 'var(--warning)' : 'var(--success)'};">${missing.length} missing</span>
16790
+ <span>${unused.length} unused</span>
16791
+ `;
16792
+ }
16793
+
16794
+ const badgeEl = document.getElementById('codeModalVarsTabBadge');
16795
+ if (badgeEl) {
16796
+ badgeEl.textContent = missing.length > 0 ? `${missing.length} missing` : 'Ready';
16797
+ badgeEl.style.color = missing.length > 0 ? 'var(--warning)' : 'var(--success)';
16798
+ }
16799
+
16800
+ const missingEl = document.getElementById('codeModalMissingList');
16801
+ if (missingEl) {
16802
+ missingEl.textContent = missing.length > 0 ? `Missing: ${missing.join(', ')}` : 'All placeholders have values for this platform.';
16803
+ missingEl.style.color = missing.length > 0 ? 'var(--warning)' : 'var(--text-muted)';
16804
+ }
16805
+ }
16806
+
16807
+ function renderCodeModalVariablesTable() {
16808
+ if (!codeModalState) return;
16809
+ const body = document.getElementById('codeModalVarsTableBody');
16810
+ if (!body) return;
16811
+ const rows = Array.isArray(codeModalState.variables) ? codeModalState.variables : [];
16812
+ body.innerHTML = rows.length > 0 ? rows.map((row, index) => `
16813
+ <tr data-var-row data-id="${escapeAttr(row.id || '')}">
16814
+ <td><input data-field="key" class="setting-input" style="min-width: 120px;" value="${escapeAttr(row.key || '')}" placeholder="PASSWORD"></td>
16815
+ <td><input data-field="value" type="${row.isSecret ? 'password' : 'text'}" class="setting-input" style="min-width: 180px;" value="${escapeAttr(row.value || '')}" placeholder="value"></td>
16816
+ <td>
16817
+ <select data-field="platform" class="setting-input" style="min-width: 90px;">
16818
+ <option value="both" ${row.platform === 'both' ? 'selected' : ''}>both</option>
16819
+ <option value="mobile" ${row.platform === 'mobile' ? 'selected' : ''}>mobile</option>
16820
+ <option value="web" ${row.platform === 'web' ? 'selected' : ''}>web</option>
16821
+ </select>
16822
+ </td>
16823
+ <td style="text-align: center;">
16824
+ <input data-field="isSecret" type="checkbox" ${row.isSecret ? 'checked' : ''} onchange="toggleCodeModalVarSecret(${index}, this.checked)">
16825
+ </td>
16826
+ <td><input data-field="notes" class="setting-input" style="min-width: 160px;" value="${escapeAttr(row.notes || '')}" placeholder="optional note"></td>
16827
+ <td style="text-align: right;">
16828
+ <button class="btn btn-secondary" style="padding: 6px 8px;" onclick="removeCodeModalVariableRow(${index})">Remove</button>
16829
+ </td>
16830
+ </tr>
16831
+ `).join('') : `<tr><td colspan="6" style="color: var(--text-muted); padding: 12px; text-align: center;">No variables saved yet</td></tr>`;
16832
+
16833
+ body.querySelectorAll('input, select').forEach((el) => {
16834
+ el.addEventListener('input', () => syncCodeModalEnvFromTable());
16835
+ el.addEventListener('change', () => syncCodeModalEnvFromTable());
16836
+ });
16837
+ updateCodeModalVariablesStatus();
16838
+ }
16839
+
16840
+ function addCodeModalVariableRow(defaults = {}) {
16841
+ if (!codeModalState) return;
16842
+ codeModalState.variables = Array.isArray(codeModalState.variables) ? codeModalState.variables : [];
16843
+ codeModalState.variables.push({
16844
+ id: defaults.id || ('var_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7)),
16845
+ key: (defaults.key || '').toUpperCase(),
16846
+ value: defaults.value || '',
16847
+ isSecret: defaults.isSecret !== false,
16848
+ platform: defaults.platform || 'both',
16849
+ notes: defaults.notes || ''
16850
+ });
16851
+ renderCodeModalVariablesTable();
16852
+ syncCodeModalEnvFromTable();
16853
+ }
16854
+
16855
+ function removeCodeModalVariableRow(index) {
16856
+ if (!codeModalState || !Array.isArray(codeModalState.variables)) return;
16857
+ codeModalState.variables.splice(index, 1);
16858
+ renderCodeModalVariablesTable();
16859
+ syncCodeModalEnvFromTable();
16860
+ }
16861
+
16862
+ function toggleCodeModalVarSecret(index, checked) {
16863
+ if (!codeModalState || !Array.isArray(codeModalState.variables) || !codeModalState.variables[index]) return;
16864
+ codeModalState.variables[index].isSecret = !!checked;
16865
+ renderCodeModalVariablesTable();
16866
+ }
16867
+
16868
+ function addMissingCodeModalVariablesFromScript() {
16869
+ if (!codeModalState) return;
16870
+ const codeText = document.getElementById('codeEditorTextarea')?.value || '';
16871
+ const placeholders = extractCodeModalPlaceholders(codeText);
16872
+ const existing = new Set((codeModalState.variables || []).map((row) => String(row.key || '').toUpperCase()));
16873
+ let added = 0;
16874
+ placeholders.forEach((key) => {
16875
+ if (existing.has(key)) return;
16876
+ addCodeModalVariableRow({
16877
+ key,
16878
+ value: '',
16879
+ isSecret: /PASSWORD|SECRET|TOKEN|KEY|OTP|PIN/.test(key),
16880
+ platform: 'both',
16881
+ notes: ''
16882
+ });
16883
+ existing.add(key);
16884
+ added += 1;
16885
+ });
16886
+ if (added === 0) {
16887
+ showToast('No new placeholders to add', 'info');
16888
+ } else {
16889
+ showToast(`${added} variable${added === 1 ? '' : 's'} added from script`, 'success');
16890
+ }
16891
+ }
16892
+
16893
+ function applyEnvTestToCodeModalTable() {
16894
+ const envTextarea = document.getElementById('codeModalEnvTextarea');
16895
+ if (!envTextarea || !codeModalState) return;
16896
+ const parsed = parseEnvTestTextClient(envTextarea.value);
16897
+ const previousByKey = new Map((codeModalState.variables || []).map((row) => [String(row.key || '').toUpperCase(), row]));
16898
+ codeModalState.variables = parsed.map((row) => ({
16899
+ ...row,
16900
+ isSecret: previousByKey.get(row.key)?.isSecret ?? row.isSecret,
16901
+ }));
16902
+ renderCodeModalVariablesTable();
16903
+ syncCodeModalEnvFromTable();
16904
+ showToast('Applied .env.test to variables table', 'success');
16905
+ }
16906
+
16907
+ async function loadCodeModalVariables() {
16908
+ if (!codeModalState?.recordingId || !codeModalState?.ownerType) return;
16909
+ try {
16910
+ const response = await fetch(`/api/test-variables/${codeModalState.ownerType}/${codeModalState.recordingId}`);
16911
+ const data = await response.json();
16912
+ if (!response.ok) throw new Error(data.error || 'Failed to load variables');
16913
+ codeModalState.variables = Array.isArray(data.variables) ? data.variables : [];
16914
+ codeModalState.variablesLoaded = true;
16915
+ codeModalState.lastSavedVariablesSignature = getCodeModalVariablesSignature(codeModalState.variables);
16916
+ renderCodeModalVariablesTable();
16917
+ const envTextarea = document.getElementById('codeModalEnvTextarea');
16918
+ if (envTextarea) {
16919
+ envTextarea.value = typeof data.envTest === 'string' ? data.envTest : renderEnvTestTextClient(codeModalState.variables);
16920
+ }
16921
+ updateCodeModalVariablesStatus();
16922
+ return true;
16923
+ } catch (error) {
16924
+ console.error('Failed to load code modal variables:', error);
16925
+ const missingEl = document.getElementById('codeModalMissingList');
16926
+ if (missingEl) {
16927
+ missingEl.textContent = `Failed to load variables: ${error.message || 'Unknown error'}`;
16928
+ missingEl.style.color = 'var(--error)';
16929
+ }
16930
+ return false;
16931
+ }
16932
+ }
16933
+
16934
+ async function saveCodeModalVariables(options = {}) {
16935
+ if (!codeModalState?.recordingId || !codeModalState?.ownerType) return false;
16936
+ const { silent = false } = options;
16937
+ try {
16938
+ if (!codeModalState.variablesLoaded) {
16939
+ const loaded = await loadCodeModalVariables();
16940
+ if (!loaded) {
16941
+ throw new Error('Unable to load existing variables');
16942
+ }
16943
+ }
16944
+ const rows = collectCodeModalVariablesFromTable().map((row) => ({
16945
+ ...row,
16946
+ key: String(row.key || '').trim().toUpperCase(),
16947
+ platform: ['mobile', 'web', 'both'].includes(row.platform) ? row.platform : 'both'
16948
+ })).filter((row) => row.key);
16949
+ const response = await fetch(`/api/test-variables/${codeModalState.ownerType}/${codeModalState.recordingId}`, {
16950
+ method: 'PUT',
16951
+ headers: { 'Content-Type': 'application/json' },
16952
+ body: JSON.stringify({ variables: rows })
16953
+ });
16954
+ const data = await response.json();
16955
+ if (!response.ok) throw new Error(data.error || 'Failed to save variables');
16956
+ codeModalState.variables = Array.isArray(data.variables) ? data.variables : rows;
16957
+ codeModalState.lastSavedVariablesSignature = getCodeModalVariablesSignature(codeModalState.variables);
16958
+ renderCodeModalVariablesTable();
16959
+ const envTextarea = document.getElementById('codeModalEnvTextarea');
16960
+ if (envTextarea) envTextarea.value = typeof data.envTest === 'string' ? data.envTest : renderEnvTestTextClient(codeModalState.variables);
16961
+ if (!silent) showToast('Test variables saved', 'success');
16962
+ return true;
16963
+ } catch (error) {
16964
+ if (!silent) showToast(`Failed to save variables: ${error.message || 'Unknown error'}`, 'error');
16965
+ return false;
16966
+ }
16967
+ }
16968
+
16969
+ function switchCodeModalTab(tab) {
16970
+ const codePanel = document.getElementById('codeModalCodePanel');
16971
+ const varsPanel = document.getElementById('codeModalVarsPanel');
16972
+ const codeBtn = document.getElementById('codeModalTabCode');
16973
+ const varsBtn = document.getElementById('codeModalTabVars');
16974
+ if (codePanel) codePanel.style.display = tab === 'code' ? 'flex' : 'none';
16975
+ if (varsPanel) varsPanel.style.display = tab === 'vars' ? 'flex' : 'none';
16976
+ if (codeBtn) codeBtn.style.opacity = tab === 'code' ? '1' : '0.7';
16977
+ if (varsBtn) varsBtn.style.opacity = tab === 'vars' ? '1' : '0.7';
16978
+ if (codeModalState) codeModalState.activeTab = tab;
16979
+ if (tab === 'vars') {
16980
+ updateCodeModalVariablesStatus();
16981
+ }
16982
+ }
16983
+
16424
16984
  // Generic code modal for both Web (TypeScript) and Mobile (YAML)
16425
16985
  function showCodeModal(code, language, recordingId, type) {
16426
- const isYaml = language === 'yaml';
16986
+ const resolvedLanguage = language || 'typescript';
16987
+ const resolvedType = type || (resolvedLanguage === 'yaml' ? 'mobile' : 'web');
16988
+ const resolvedRecordingId = recordingId || (resolvedType === 'web' ? recorderSession?.id : currentProject?.id || lastMobileRecording?.projectId);
16989
+ const isYaml = resolvedLanguage === 'yaml';
16427
16990
  const title = isYaml ? 'Maestro Flow (YAML)' : 'Playwright Test (TypeScript)';
16428
16991
  const lineCount = (code || '').split('\n').length;
16429
16992
  const lineNumbers = Array.from({length: Math.max(lineCount, 20)}, (_, i) => i + 1).join('\n');
16993
+ const hasVarsTab = !!resolvedRecordingId && (resolvedType === 'mobile' || resolvedType === 'web');
16994
+
16995
+ codeModalState = {
16996
+ code: code || '',
16997
+ language: resolvedLanguage,
16998
+ type: resolvedType,
16999
+ recordingId: resolvedRecordingId || null,
17000
+ ownerType: hasVarsTab ? getCodeModalOwnerType(resolvedType) : null,
17001
+ variables: [],
17002
+ variablesLoaded: false,
17003
+ lastSavedVariablesSignature: '',
17004
+ placeholders: extractCodeModalPlaceholders(code || ''),
17005
+ missingPlaceholders: [],
17006
+ activeTab: 'code',
17007
+ mobileRunDevices: [],
17008
+ mobileRunDevicesLoading: resolvedType === 'mobile',
17009
+ mobileRunSelectedDeviceId: resolvedType === 'mobile' ? getPreferredCodeModalMobileDeviceId() : '',
17010
+ lastSavedCode: code || ''
17011
+ };
16430
17012
 
16431
17013
  const existing = document.getElementById('codeModal');
16432
17014
  if (existing) {
16433
- existing.remove();
17015
+ closeCodeModal();
16434
17016
  }
16435
17017
 
16436
17018
  const modal = document.createElement('div');
16437
17019
  modal.className = 'modal-overlay';
17020
+ modal.classList.add('code-modal-overlay');
16438
17021
  modal.id = 'codeModal';
16439
17022
  modal.innerHTML = `
16440
17023
  <div class="modal code-modal">
@@ -16442,21 +17025,95 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16442
17025
  <h3>${title}</h3>
16443
17026
  <button class="modal-close" onclick="closeCodeModal()">&times;</button>
16444
17027
  </div>
17028
+ <div style="display:flex; gap:8px; padding: 10px 16px 0 16px; border-bottom: 1px solid var(--border);">
17029
+ <button id="codeModalTabCode" class="btn btn-secondary" style="padding: 6px 10px; min-width: auto;" onclick="switchCodeModalTab('code')">Script</button>
17030
+ ${hasVarsTab ? `
17031
+ <button id="codeModalTabVars" class="btn btn-secondary" style="padding: 6px 10px; min-width: auto; opacity: .7;" onclick="switchCodeModalTab('vars')">
17032
+ Test Variables
17033
+ <span id="codeModalVarsTabBadge" style="margin-left:6px; font-size: 10px; color: var(--text-muted);">...</span>
17034
+ </button>
17035
+ ` : ''}
17036
+ </div>
17037
+ ${resolvedType === 'mobile' ? `
17038
+ <div style="display:flex; align-items:center; justify-content:space-between; gap:10px; padding: 10px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; background: rgba(255,255,255,0.01);">
17039
+ <div style="display:flex; align-items:center; gap:8px; flex-wrap: wrap; min-width: 0;">
17040
+ <label for="codeModalMobileDeviceSelect" style="font-size: 12px; color: var(--text-secondary);">Run on device</label>
17041
+ <select id="codeModalMobileDeviceSelect" class="setting-input" style="min-width: 280px; max-width: 100%; padding: 6px 10px; height: auto;" onchange="onCodeModalMobileDeviceChanged(this.value)">
17042
+ <option value="">Auto-detect device (Maestro default)</option>
17043
+ </select>
17044
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="loadCodeModalMobileRunDevices(true)">Refresh</button>
17045
+ </div>
17046
+ <div id="codeModalMobileDeviceStatus" style="font-size: 11px; color: var(--text-muted);">Loading devices...</div>
17047
+ </div>
17048
+ ` : ''}
16445
17049
  <div class="code-modal-body">
16446
- <div class="code-editor-container">
16447
- <div id="codeLineNumbers" class="code-line-numbers">${lineNumbers}</div>
16448
- <textarea id="codeEditorTextarea" class="code-editor-textarea" spellcheck="false">${escapeHtml(code)}</textarea>
17050
+ <div id="codeModalCodePanel" class="code-modal-panel">
17051
+ <div class="code-editor-container">
17052
+ <div id="codeLineNumbers" class="code-line-numbers">${lineNumbers}</div>
17053
+ <textarea id="codeEditorTextarea" class="code-editor-textarea" spellcheck="false">${escapeHtml(code)}</textarea>
17054
+ </div>
17055
+ <div id="codeModalOutput" class="code-modal-output" style="display: none;"></div>
16449
17056
  </div>
16450
- <div id="codeModalOutput" class="code-modal-output" style="display: none;"></div>
17057
+ ${hasVarsTab ? `
17058
+ <div id="codeModalVarsPanel" class="code-modal-panel" style="display:none; padding: 12px 0 0 0; overflow:auto;">
17059
+ <div style="display:grid; gap:10px;">
17060
+ <div style="display:flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap;">
17061
+ <div id="codeModalVarSummary" style="font-size: 12px; color: var(--text-secondary); display:flex; gap:10px; flex-wrap:wrap;">
17062
+ <span>Loading variables...</span>
17063
+ </div>
17064
+ <div style="display:flex; gap:8px; flex-wrap:wrap;">
17065
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="addMissingCodeModalVariablesFromScript()">Add Missing From Script</button>
17066
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="addCodeModalVariableRow()">Add Variable</button>
17067
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="loadCodeModalVariables()">Reload</button>
17068
+ <button class="btn btn-primary" style="padding:6px 10px; min-width:auto;" onclick="saveCodeModalVariables()">Save Variables</button>
17069
+ </div>
17070
+ </div>
17071
+ <div id="codeModalMissingList" style="font-size:11px; color: var(--text-muted);">Checking placeholders...</div>
17072
+ <div class="code-vars-table-wrap">
17073
+ <table style="width:100%; border-collapse: collapse; min-width: 860px; background: var(--bg-secondary);">
17074
+ <thead>
17075
+ <tr style="text-align:left; font-size: 11px; color: var(--text-secondary); background: var(--bg-tertiary);">
17076
+ <th style="padding:8px;">Key</th>
17077
+ <th style="padding:8px;">Value</th>
17078
+ <th style="padding:8px;">Platform</th>
17079
+ <th style="padding:8px;">Secret</th>
17080
+ <th style="padding:8px;">Notes</th>
17081
+ <th style="padding:8px;"></th>
17082
+ </tr>
17083
+ </thead>
17084
+ <tbody id="codeModalVarsTableBody"></tbody>
17085
+ </table>
17086
+ </div>
17087
+ <div style="display:grid; gap:6px;">
17088
+ <div style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
17089
+ <label style="font-size: 12px; color: var(--text-secondary);">.env.test (saved in DB as structured variables)</label>
17090
+ <div style="display:flex; gap:8px;">
17091
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="syncCodeModalEnvFromTable()">Regenerate</button>
17092
+ <button class="btn btn-secondary" style="padding:6px 10px; min-width:auto;" onclick="applyEnvTestToCodeModalTable()">Apply .env</button>
17093
+ </div>
17094
+ </div>
17095
+ <textarea id="codeModalEnvTextarea" class="code-editor-textarea" spellcheck="false" style="min-height: 140px; height: 140px;"></textarea>
17096
+ </div>
17097
+ </div>
17098
+ </div>
17099
+ ` : ''}
16451
17100
  </div>
16452
17101
  <div class="code-modal-footer">
16453
- ${isYaml && recordingId ? `
16454
- <button id="runCodeBtn" class="btn btn-run" onclick="runMaestroFromEditor('${recordingId}')">
17102
+ ${resolvedRecordingId ? `
17103
+ <button id="runCodeBtn" class="btn btn-run" onclick="${isYaml ? `runMaestroFromEditor('${resolvedRecordingId}')` : `runPlaywrightFromEditor('${resolvedRecordingId}')`}">
16455
17104
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px;">
16456
17105
  <polygon points="5 3 19 12 5 21 5 3"/>
16457
17106
  </svg>
16458
- Run Test
17107
+ ${isYaml ? 'Run on Device' : 'Run Test'}
16459
17108
  </button>
17109
+ ${isYaml ? `
17110
+ <button id="stopCodeBtn" class="btn btn-secondary" onclick="stopMaestroRunFromEditor()" style="display:none; min-width: 120px; border-color: rgba(239,68,68,0.25); color: #fca5a5;">
17111
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="margin-right: 4px;">
17112
+ <rect x="6" y="6" width="12" height="12" rx="1"/>
17113
+ </svg>
17114
+ Stop Run
17115
+ </button>
17116
+ ` : ''}
16460
17117
  ` : ''}
16461
17118
  <button class="btn btn-secondary" onclick="copyCodeToClipboard()">
16462
17119
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px;">
@@ -16465,8 +17122,8 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16465
17122
  </svg>
16466
17123
  Copy
16467
17124
  </button>
16468
- ${recordingId ? `
16469
- <button class="btn btn-primary" onclick="saveCodeChanges('${recordingId}', '${type}')">
17125
+ ${resolvedRecordingId ? `
17126
+ <button class="btn btn-primary" onclick="saveCodeChanges('${resolvedRecordingId}', '${resolvedType}')">
16470
17127
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px;">
16471
17128
  <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
16472
17129
  <polyline points="17 21 17 13 7 13 7 21"/>
@@ -16479,6 +17136,8 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16479
17136
  </div>
16480
17137
  `;
16481
17138
  document.body.appendChild(modal);
17139
+ lockPageScrollForCodeModal();
17140
+ attachCodeModalScrollTrap(modal);
16482
17141
  requestAnimationFrame(() => modal.classList.add('active'));
16483
17142
 
16484
17143
  // Sync line numbers with textarea scroll and content
@@ -16491,9 +17150,17 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16491
17150
  lineNumbersEl.scrollTop = textarea.scrollTop;
16492
17151
  });
16493
17152
 
17153
+ // If the pointer is over line numbers, scroll the editor instead of the page behind
17154
+ lineNumbersEl.addEventListener('wheel', (e) => {
17155
+ e.preventDefault();
17156
+ textarea.scrollTop += e.deltaY;
17157
+ textarea.scrollLeft += e.deltaX;
17158
+ }, { passive: false });
17159
+
16494
17160
  // Update line numbers when content changes
16495
17161
  textarea.addEventListener('input', () => {
16496
17162
  updateLineNumbers();
17163
+ updateCodeModalVariablesStatus();
16497
17164
  });
16498
17165
  }
16499
17166
 
@@ -16507,6 +17174,14 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16507
17174
  };
16508
17175
  document.addEventListener('keydown', escHandler);
16509
17176
  modal._escHandler = escHandler;
17177
+
17178
+ if (hasVarsTab) {
17179
+ loadCodeModalVariables();
17180
+ }
17181
+ if (resolvedType === 'mobile') {
17182
+ loadCodeModalMobileRunDevices();
17183
+ }
17184
+ switchCodeModalTab('code');
16510
17185
  }
16511
17186
 
16512
17187
  function updateLineNumbers() {
@@ -16519,15 +17194,277 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16519
17194
  lineNumbersEl.textContent = Array.from({length: maxLines}, (_, i) => i + 1).join('\n');
16520
17195
  }
16521
17196
 
17197
+ function setCodeModalRunButtonBusy(runBtn, label) {
17198
+ if (!runBtn) return;
17199
+ runBtn.disabled = true;
17200
+ runBtn.innerHTML = `
17201
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px; animation: spin 1s linear infinite;">
17202
+ <circle cx="12" cy="12" r="10"/>
17203
+ <path d="M12 6v6l4 2"/>
17204
+ </svg>
17205
+ ${escapeHtml(label || 'Running...')}
17206
+ `;
17207
+ }
17208
+
17209
+ function setCodeModalStopButtonState(stopBtn, options = {}) {
17210
+ if (!stopBtn) return;
17211
+ const {
17212
+ visible = false,
17213
+ disabled = false,
17214
+ label = 'Stop Run'
17215
+ } = options;
17216
+ stopBtn.style.display = visible ? '' : 'none';
17217
+ stopBtn.disabled = !!disabled;
17218
+ stopBtn.innerHTML = `
17219
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="margin-right: 4px;">
17220
+ <rect x="6" y="6" width="12" height="12" rx="1"/>
17221
+ </svg>
17222
+ ${escapeHtml(label)}
17223
+ `;
17224
+ }
17225
+
17226
+ function resetCodeModalRunButton(runBtn, type = 'mobile') {
17227
+ if (!runBtn) return;
17228
+ runBtn.disabled = false;
17229
+ runBtn.innerHTML = `
17230
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px;">
17231
+ <polygon points="5 3 19 12 5 21 5 3"/>
17232
+ </svg>
17233
+ ${type === 'mobile' ? 'Run on Device' : 'Run Test'}
17234
+ `;
17235
+ }
17236
+
17237
+ function appendCodeModalOutputLine(outputEl, htmlLine) {
17238
+ if (!outputEl) return;
17239
+ outputEl.innerHTML += '\n' + htmlLine;
17240
+ outputEl.scrollTop = outputEl.scrollHeight;
17241
+ }
17242
+
17243
+ async function pollMaestroRunStatus(runId, options = {}) {
17244
+ const {
17245
+ outputEl = null,
17246
+ runBtn = null,
17247
+ pollMs = 1200,
17248
+ timeoutMs = 360000
17249
+ } = options;
17250
+ const startedAt = Date.now();
17251
+ let runningAnnounced = false;
17252
+
17253
+ while ((Date.now() - startedAt) < timeoutMs) {
17254
+ const response = await fetch(`/api/testing/mobile/replays/${encodeURIComponent(runId)}`);
17255
+ const data = await response.json().catch(() => ({}));
17256
+ if (!response.ok) {
17257
+ throw new Error(data.error || 'Failed to read Maestro run status');
17258
+ }
17259
+
17260
+ const elapsedSec = Math.max(0, Math.round((Number(data.elapsedMs || 0) / 1000)));
17261
+ if (data.status === 'running') {
17262
+ if (!runningAnnounced && outputEl) {
17263
+ appendCodeModalOutputLine(outputEl, '<span class="output-running">⏳ Maestro is running on device...</span>');
17264
+ runningAnnounced = true;
17265
+ }
17266
+ if (runBtn) {
17267
+ setCodeModalRunButtonBusy(runBtn, elapsedSec > 0 ? `Running... ${elapsedSec}s` : 'Running...');
17268
+ }
17269
+ await new Promise(resolve => setTimeout(resolve, pollMs));
17270
+ continue;
17271
+ }
17272
+
17273
+ return data;
17274
+ }
17275
+
17276
+ throw new Error('Timed out waiting for Maestro run to finish');
17277
+ }
17278
+
16522
17279
  // Run Maestro test from editor
16523
17280
  let codeModalRunning = false;
17281
+ async function stopMaestroRunFromEditor() {
17282
+ const stopBtn = document.getElementById('stopCodeBtn');
17283
+ const outputEl = document.getElementById('codeModalOutput');
17284
+ const runId = String(codeModalState?.activeMaestroRunId || '').trim();
17285
+ if (!runId) return;
17286
+
17287
+ try {
17288
+ setCodeModalStopButtonState(stopBtn, { visible: true, disabled: true, label: 'Stopping...' });
17289
+ appendCodeModalOutputLine(outputEl, '<span class="output-running">⏹ Requesting stop...</span>');
17290
+ const response = await fetch(`/api/testing/mobile/replays/${encodeURIComponent(runId)}/stop`, {
17291
+ method: 'POST'
17292
+ });
17293
+ const data = await response.json().catch(() => ({}));
17294
+ if (!response.ok && response.status !== 409) {
17295
+ throw new Error(data.error || 'Failed to stop run');
17296
+ }
17297
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">⏳ Stop requested. Waiting for runner status...</span>');
17298
+ showToast('Stop requested', 'info');
17299
+ } catch (error) {
17300
+ appendCodeModalOutputLine(outputEl, '<span class="output-error">✗ Stop failed: ' + escapeHtml(error.message || 'Unknown error') + '</span>');
17301
+ setCodeModalStopButtonState(stopBtn, { visible: true, disabled: false, label: 'Stop Run' });
17302
+ showToast('Failed to stop run', 'error');
17303
+ }
17304
+ }
17305
+
16524
17306
  async function runMaestroFromEditor(recordingId) {
16525
17307
  if (codeModalRunning) return;
16526
17308
 
16527
17309
  const textarea = document.getElementById('codeEditorTextarea');
16528
17310
  const outputEl = document.getElementById('codeModalOutput');
16529
17311
  const runBtn = document.getElementById('runCodeBtn');
17312
+ const stopBtn = document.getElementById('stopCodeBtn');
17313
+
17314
+ if (!textarea || !outputEl) return;
17315
+
17316
+ codeModalRunning = true;
17317
+ if (codeModalState) codeModalState.activeMaestroRunId = null;
17318
+ if (runBtn) {
17319
+ setCodeModalRunButtonBusy(runBtn, 'Loading...');
17320
+ }
17321
+ setCodeModalStopButtonState(stopBtn, { visible: false });
17322
+
17323
+ // Show output area
17324
+ outputEl.style.display = 'block';
17325
+ outputEl.innerHTML = '<span class="output-running">▶ Saving and running Maestro test on device...</span>';
17326
+
17327
+ const selectedDeviceId = String(codeModalState?.mobileRunSelectedDeviceId || '').trim();
17328
+ const selectedDevice = Array.isArray(codeModalState?.mobileRunDevices)
17329
+ ? codeModalState.mobileRunDevices.find((d) => String(d?.id || '').trim() === selectedDeviceId)
17330
+ : null;
17331
+
17332
+ try {
17333
+ const currentVars = collectCodeModalVariablesFromTable().map((row) => ({
17334
+ ...row,
17335
+ key: String(row.key || '').trim().toUpperCase(),
17336
+ platform: ['mobile', 'web', 'both'].includes(row.platform) ? row.platform : 'both'
17337
+ })).filter((row) => row.key);
17338
+ const currentVarsSignature = getCodeModalVariablesSignature(currentVars);
17339
+ const shouldSaveVars = !codeModalState?.variablesLoaded
17340
+ || currentVarsSignature !== String(codeModalState?.lastSavedVariablesSignature || '');
17341
+ if (shouldSaveVars) {
17342
+ const varsSaved = await saveCodeModalVariables({ silent: true });
17343
+ if (!varsSaved) throw new Error('Failed to save test variables');
17344
+ appendCodeModalOutputLine(outputEl, '<span class="output-success">✓ Test variables saved</span>');
17345
+ } else {
17346
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">↺ Test variables unchanged (skipped save)</span>');
17347
+ }
17348
+
17349
+ // First, save the code
17350
+ const currentCode = textarea.value;
17351
+ const shouldSaveCode = currentCode !== String(codeModalState?.lastSavedCode ?? codeModalState?.code ?? '');
17352
+ if (shouldSaveCode) {
17353
+ const saveResponse = await fetch(`/api/testing/mobile/recordings/${recordingId}/flow`, {
17354
+ method: 'PUT',
17355
+ headers: { 'Content-Type': 'application/json' },
17356
+ body: JSON.stringify({ flowCode: currentCode })
17357
+ });
17358
+
17359
+ if (!saveResponse.ok) {
17360
+ throw new Error('Failed to save flow code');
17361
+ }
17362
+ if (codeModalState) {
17363
+ codeModalState.code = currentCode;
17364
+ codeModalState.lastSavedCode = currentCode;
17365
+ }
17366
+ outputEl.innerHTML += '\n<span class="output-success">✓ Flow saved</span>';
17367
+ } else {
17368
+ outputEl.innerHTML += '\n<span class="output-step">↺ Flow unchanged (skipped save)</span>';
17369
+ }
17370
+ outputEl.innerHTML += '\n<span class="output-running">▶ Starting Maestro test on device...</span>';
17371
+
17372
+ // Now run the test
17373
+ const runRequestBody = {};
17374
+ if (selectedDeviceId) {
17375
+ runRequestBody.deviceId = selectedDeviceId;
17376
+ if (selectedDevice?.platform === 'ios' || selectedDevice?.platform === 'android') {
17377
+ runRequestBody.devicePlatform = selectedDevice.platform;
17378
+ runRequestBody.deviceName = String(selectedDevice?.name || selectedDeviceId);
17379
+ runRequestBody.skipDeviceValidation = true;
17380
+ }
17381
+ }
17382
+ const runResponse = await fetch(`/api/testing/mobile/recordings/${recordingId}/replay`, {
17383
+ method: 'POST',
17384
+ headers: { 'Content-Type': 'application/json' },
17385
+ body: JSON.stringify(runRequestBody)
17386
+ });
17387
+
17388
+ const runData = await runResponse.json();
17389
+
17390
+ if (!runResponse.ok) {
17391
+ if (Array.isArray(runData?.missingKeys) && runData.missingKeys.length > 0) {
17392
+ outputEl.innerHTML += '\n<span class="output-error">✗ Missing variables: ' + runData.missingKeys.join(', ') + '</span>';
17393
+ switchCodeModalTab('vars');
17394
+ }
17395
+ throw new Error(runData.error || 'Failed to start test');
17396
+ }
17397
+
17398
+ appendCodeModalOutputLine(outputEl, '<span class="output-success">✓ Maestro test started!</span>');
17399
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">📍 Flow: ' + escapeHtml(runData.flowPath || recordingId) + '</span>');
17400
+ if (runData?.deviceName || runData?.deviceId) {
17401
+ const deviceLabel = runData.deviceName || runData.deviceId;
17402
+ const platformLabel = runData.devicePlatform ? ` (${String(runData.devicePlatform).toUpperCase()})` : '';
17403
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">📱 Device: ' + escapeHtml(deviceLabel + platformLabel) + '</span>');
17404
+ if (runData?.deviceSelectionSource === 'trusted-client') {
17405
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">⚡ Fast start: reused selected device from modal</span>');
17406
+ }
17407
+ } else {
17408
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">📱 Device: Auto-detect (Maestro default)</span>');
17409
+ }
17410
+ if (Array.isArray(runData.usedKeys) && runData.usedKeys.length > 0) {
17411
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">🔐 Vars: ' + escapeHtml(runData.usedKeys.join(', ')) + '</span>');
17412
+ }
17413
+ appendCodeModalOutputLine(outputEl, '<span style="color: #fbbf24;">💡 Watch the selected device for test execution.</span>');
17414
+
17415
+ const runId = typeof runData?.runId === 'string' ? runData.runId : '';
17416
+ if (runId) {
17417
+ if (codeModalState) codeModalState.activeMaestroRunId = runId;
17418
+ setCodeModalRunButtonBusy(runBtn, 'Running...');
17419
+ setCodeModalStopButtonState(stopBtn, { visible: true, disabled: false, label: 'Stop Run' });
17420
+ const finalStatus = await pollMaestroRunStatus(runId, { outputEl, runBtn });
17421
+ const durationMs = Number(finalStatus?.durationMs);
17422
+ const durationLabel = Number.isFinite(durationMs) && durationMs > 0
17423
+ ? ` (${Math.round(durationMs / 1000)}s)`
17424
+ : '';
17425
+
17426
+ if (finalStatus.status === 'completed') {
17427
+ appendCodeModalOutputLine(outputEl, '<span class="output-success">✓ Maestro test completed' + escapeHtml(durationLabel) + '</span>');
17428
+ if (finalStatus.output) {
17429
+ const preview = String(finalStatus.output).slice(-2500);
17430
+ appendCodeModalOutputLine(outputEl, '<pre style="white-space: pre-wrap; margin: 8px 0 0 0; color: var(--text-secondary);">' + escapeHtml(preview) + '</pre>');
17431
+ }
17432
+ showToast('Maestro test completed', 'success');
17433
+ } else if (finalStatus.status === 'failed') {
17434
+ appendCodeModalOutputLine(outputEl, '<span class="output-error">✗ Maestro test failed' + escapeHtml(durationLabel) + '</span>');
17435
+ if (finalStatus.error) {
17436
+ appendCodeModalOutputLine(outputEl, '<span class="output-error">' + escapeHtml(String(finalStatus.error)) + '</span>');
17437
+ }
17438
+ if (finalStatus.output) {
17439
+ const preview = String(finalStatus.output).slice(-2500);
17440
+ appendCodeModalOutputLine(outputEl, '<pre style="white-space: pre-wrap; margin: 8px 0 0 0; color: var(--text-secondary);">' + escapeHtml(preview) + '</pre>');
17441
+ }
17442
+ showToast('Maestro test failed', 'error');
17443
+ } else if (finalStatus.status === 'canceled') {
17444
+ appendCodeModalOutputLine(outputEl, '<span class="output-step">⏹ Maestro run canceled' + escapeHtml(durationLabel) + '</span>');
17445
+ showToast('Maestro run canceled', 'info');
17446
+ }
17447
+ } else {
17448
+ showToast('Maestro test started on device', 'success');
17449
+ }
16530
17450
 
17451
+ } catch (error) {
17452
+ outputEl.innerHTML += '\n<span class="output-error">✗ Error: ' + (error.message || 'Unknown error') + '</span>';
17453
+ showToast('Failed to run test', 'error');
17454
+ } finally {
17455
+ codeModalRunning = false;
17456
+ if (codeModalState) codeModalState.activeMaestroRunId = null;
17457
+ setCodeModalStopButtonState(stopBtn, { visible: false });
17458
+ resetCodeModalRunButton(runBtn, 'mobile');
17459
+ }
17460
+ }
17461
+
17462
+ async function runPlaywrightFromEditor(recordingId) {
17463
+ if (codeModalRunning) return;
17464
+
17465
+ const textarea = document.getElementById('codeEditorTextarea');
17466
+ const outputEl = document.getElementById('codeModalOutput');
17467
+ const runBtn = document.getElementById('runCodeBtn');
16531
17468
  if (!textarea || !outputEl) return;
16532
17469
 
16533
17470
  codeModalRunning = true;
@@ -16542,84 +17479,66 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16542
17479
  `;
16543
17480
  }
16544
17481
 
16545
- // Show output area
16546
17482
  outputEl.style.display = 'block';
16547
- outputEl.innerHTML = '<span class="output-running">▶ Saving and running Maestro test...</span>';
16548
-
16549
- // Detect platform from YAML appId
16550
- const yamlContent = textarea.value;
16551
- const appIdMatch = yamlContent.match(/appId:\s*([^\s\n]+)/);
16552
- const appId = appIdMatch ? appIdMatch[1] : '';
16553
- const platform = appId.includes('com.apple') || appId.startsWith('com.') && !appId.includes('android') ? 'ios' : 'android';
17483
+ outputEl.innerHTML = '<span class="output-running">▶ Saving Playwright script and running test...</span>';
16554
17484
 
16555
17485
  try {
16556
- // First, save the code
16557
- const saveResponse = await fetch(`/api/testing/mobile/recordings/${recordingId}/flow`, {
17486
+ const varsSaved = await saveCodeModalVariables({ silent: true });
17487
+ if (varsSaved === false) {
17488
+ throw new Error('Failed to save test variables');
17489
+ }
17490
+
17491
+ const saveResponse = await fetch(`/api/recorder/recordings/${recordingId}/spec`, {
16558
17492
  method: 'PUT',
16559
17493
  headers: { 'Content-Type': 'application/json' },
16560
- body: JSON.stringify({ flowCode: textarea.value })
17494
+ body: JSON.stringify({ specCode: textarea.value })
16561
17495
  });
16562
-
17496
+ const saveData = await saveResponse.json().catch(() => ({}));
16563
17497
  if (!saveResponse.ok) {
16564
- throw new Error('Failed to save flow code');
17498
+ throw new Error(saveData.error || 'Failed to save spec');
16565
17499
  }
16566
17500
 
16567
- outputEl.innerHTML += '\n<span class="output-success">✓ Flow saved</span>';
16568
- outputEl.innerHTML += '\n<span class="output-running">▶ Starting auto-capture and Maestro test...</span>';
17501
+ outputEl.innerHTML += '\n<span class="output-success">✓ Spec saved</span>';
16569
17502
 
16570
- // Start auto-capture before running the test
16571
- try {
16572
- await fetch('/api/testing/mobile/auto-capture/start', {
16573
- method: 'POST',
16574
- headers: { 'Content-Type': 'application/json' },
16575
- body: JSON.stringify({ recordingId, platform })
16576
- });
16577
- outputEl.innerHTML += '\n<span class="output-success">✓ Auto-capture started</span>';
16578
- } catch (captureErr) {
16579
- console.log('Auto-capture not available:', captureErr);
16580
- }
16581
-
16582
- // Now run the test
16583
- const runResponse = await fetch(`/api/testing/mobile/recordings/${recordingId}/replay`, {
17503
+ const runResponse = await fetch(`/api/recorder/recordings/${recordingId}/run`, {
16584
17504
  method: 'POST',
16585
- headers: { 'Content-Type': 'application/json' }
17505
+ headers: { 'Content-Type': 'application/json' },
17506
+ body: JSON.stringify({
17507
+ headless: false,
17508
+ browser: 'chromium',
17509
+ video: 'retain-on-failure',
17510
+ screenshot: 'only-on-failure',
17511
+ trace: 'retain-on-failure'
17512
+ })
16586
17513
  });
16587
-
16588
17514
  const runData = await runResponse.json();
16589
17515
 
16590
17516
  if (!runResponse.ok) {
16591
- throw new Error(runData.error || 'Failed to start test');
17517
+ if (Array.isArray(runData?.missingKeys) && runData.missingKeys.length > 0) {
17518
+ outputEl.innerHTML += '\n<span class="output-error">✗ Missing variables: ' + runData.missingKeys.join(', ') + '</span>';
17519
+ switchCodeModalTab('vars');
17520
+ }
17521
+ throw new Error(runData.error || runData.result?.error || 'Failed to run Playwright test');
16592
17522
  }
16593
17523
 
16594
- outputEl.innerHTML += '\n<span class="output-success">✓ Maestro test started!</span>';
16595
- outputEl.innerHTML += '\n<span class="output-step">📍 Flow: ' + (runData.flowPath || recordingId) + '</span>';
16596
- outputEl.innerHTML += '\n\n<span style="color: #4ade80;">📸 Screenshots are being captured automatically.</span>';
16597
- outputEl.innerHTML += '\n<span style="color: #fbbf24;">💡 Watch the device screen for test execution.</span>';
16598
-
16599
- showToast('Maestro test started with auto-capture!', 'success');
16600
-
16601
- // Schedule auto-capture stop after estimated test duration (30s default)
16602
- setTimeout(async () => {
16603
- try {
16604
- await fetch('/api/testing/mobile/auto-capture/stop', {
16605
- method: 'POST',
16606
- headers: { 'Content-Type': 'application/json' },
16607
- body: JSON.stringify({ recordingId })
16608
- });
16609
- } catch {}
16610
- }, 30000);
17524
+ const result = runData.result || {};
17525
+ outputEl.innerHTML += '\n<span class="output-success">✓ Playwright run completed</span>';
17526
+ outputEl.innerHTML += '\n<span class="output-step">✅ Passed: ' + (result.passed ?? 0) + ' | ❌ Failed: ' + (result.failed ?? 0) + ' | ⏭️ Skipped: ' + (result.skipped ?? 0) + '</span>';
17527
+ if (Array.isArray(runData.usedKeys) && runData.usedKeys.length > 0) {
17528
+ outputEl.innerHTML += '\n<span class="output-step">🔐 Vars: ' + runData.usedKeys.join(', ') + '</span>';
17529
+ }
17530
+ if (result.reportPath) {
17531
+ outputEl.innerHTML += '\n<span class="output-step">📄 Report: ' + result.reportPath + '</span>';
17532
+ }
17533
+ if (result.output) {
17534
+ const preview = String(result.output).slice(0, 2500);
17535
+ outputEl.innerHTML += '\n\n<pre style="white-space: pre-wrap; margin: 8px 0 0 0; color: var(--text-secondary);">' + escapeHtml(preview) + '</pre>';
17536
+ }
16611
17537
 
17538
+ showToast(result.success === false ? 'Playwright test failed' : 'Playwright test finished', result.success === false ? 'warning' : 'success');
16612
17539
  } catch (error) {
16613
17540
  outputEl.innerHTML += '\n<span class="output-error">✗ Error: ' + (error.message || 'Unknown error') + '</span>';
16614
- showToast('Failed to run test', 'error');
16615
- // Stop auto-capture on error
16616
- try {
16617
- await fetch('/api/testing/mobile/auto-capture/stop', {
16618
- method: 'POST',
16619
- headers: { 'Content-Type': 'application/json' },
16620
- body: JSON.stringify({ recordingId })
16621
- });
16622
- } catch {}
17541
+ showToast('Failed to run Playwright test', 'error');
16623
17542
  } finally {
16624
17543
  codeModalRunning = false;
16625
17544
  if (runBtn) {
@@ -16640,8 +17559,13 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16640
17559
  if (modal._escHandler) {
16641
17560
  document.removeEventListener('keydown', modal._escHandler);
16642
17561
  }
17562
+ if (modal._wheelTrapHandler) {
17563
+ modal.removeEventListener('wheel', modal._wheelTrapHandler, { capture: true });
17564
+ }
16643
17565
  modal.remove();
16644
17566
  }
17567
+ unlockPageScrollForCodeModal();
17568
+ codeModalState = null;
16645
17569
  }
16646
17570
 
16647
17571
  function copyCodeToClipboard() {
@@ -16659,6 +17583,11 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16659
17583
  const code = textarea.value;
16660
17584
 
16661
17585
  try {
17586
+ const varsSaved = await saveCodeModalVariables({ silent: true });
17587
+ if (varsSaved === false) {
17588
+ throw new Error('Failed to save test variables');
17589
+ }
17590
+
16662
17591
  if (type === 'mobile') {
16663
17592
  const response = await fetch(`/api/testing/mobile/recordings/${recordingId}/flow`, {
16664
17593
  method: 'PUT',
@@ -16676,6 +17605,12 @@ appId: ${platform === 'ios' ? 'com.apple.Preferences' : 'com.android.settings'}
16676
17605
  if (!response.ok) throw new Error('Failed to save');
16677
17606
  }
16678
17607
 
17608
+ if (codeModalState) {
17609
+ codeModalState.code = code;
17610
+ codeModalState.lastSavedCode = code;
17611
+ codeModalState.placeholders = extractCodeModalPlaceholders(code);
17612
+ }
17613
+ updateCodeModalVariablesStatus();
16679
17614
  showToast('Code saved!', 'success');
16680
17615
  } catch (error) {
16681
17616
  showToast('Failed to save code', 'error');