@ia-qa/self-healing 1.8.2 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui/page.js CHANGED
@@ -207,6 +207,61 @@ th{color:var(--muted);font-weight:600;font-size:10.5px;text-transform:uppercase;
207
207
  td.n{text-align:right;font-variant-numeric:tabular-nums}
208
208
  .axis{fill:#71717a;font-size:10px}
209
209
  .note{color:var(--muted);font-size:12px;margin-top:8px}
210
+ /* The setup form, for a folder with no config. Same vocabulary as the rest of the
211
+ page: the card, the pill CTA, the dashed line that states what leaves the machine. */
212
+ [hidden]{display:none!important}
213
+ .lede-s{font-size:13px;margin-bottom:16px}
214
+ code.k{background:var(--surface2);border:1px solid var(--border);border-radius:5px;padding:1px 6px;font:11.5px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--orange2)}
215
+ .step{border-top:1px solid var(--border);padding:18px 0}
216
+ .st{display:flex;align-items:center;gap:10px;margin-bottom:10px}
217
+ .st-row{display:flex;align-items:center;gap:10px}
218
+ .st-row .v{font-size:12.5px;color:var(--muted);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
219
+ .st-row .aside{margin-left:auto;font-size:12px;color:var(--muted)}
220
+ .st-row a{margin-left:auto;font-size:12px;text-decoration:none}
221
+ .st-lock{opacity:.45}
222
+ .field{font:inherit;font-size:13px;width:100%;background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:9px;padding:9px 12px;outline:none}
223
+ .field:focus{border-color:var(--orange)}
224
+ .field.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
225
+ .rowf{display:flex;gap:8px;align-items:center}
226
+ .rowb{display:flex;gap:8px;margin-bottom:10px}
227
+ .cta:disabled{opacity:.5;cursor:default}
228
+ .su-found{display:flex;align-items:baseline;gap:10px;margin:16px 0 4px}
229
+ .su-all{margin-left:auto;display:flex;gap:10px;font-size:12px}
230
+ .su-all a,.su-rm{text-decoration:none}
231
+ .su-row{cursor:pointer;align-items:center}
232
+ .su-n{font-size:12.5px;font-weight:600;width:170px;flex:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
233
+ .su-p{font:11.5px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted);word-break:break-all;flex:1}
234
+ .su-rm{font-size:11.5px}
235
+ .chk{width:15px;height:15px;border-radius:4px;border:1.5px solid #3f3f46;flex:none;display:inline-flex;align-items:center;justify-content:center}
236
+ .chk.on{background:var(--orange);border-color:var(--orange)}
237
+ .su-more{margin-top:10px}
238
+ .su-add{margin-top:18px}
239
+ .su-add .note{margin:0 0 6px}
240
+ .btn-s{font-size:12.5px;padding:8px 12px;flex:none}
241
+ .opts{display:flex;flex-direction:column;gap:10px}
242
+ .opt{display:flex;gap:11px;border:1px solid var(--border);border-radius:12px;padding:14px 15px;background:var(--surface2);cursor:pointer}
243
+ .opt-on{border-color:var(--orange);background:#f9731610;cursor:default}
244
+ .opt-b{flex:1;min-width:0}
245
+ .opt-b b{font-size:13px}
246
+ .r{width:15px;height:15px;border-radius:50%;border:1.5px solid #3f3f46;flex:none;margin-top:3px}
247
+ .r-on{border-color:var(--orange);background:radial-gradient(circle,var(--orange) 0 3.5px,transparent 4px)}
248
+ .lbl{font-size:12px;color:var(--muted);margin-bottom:6px}
249
+ .su-sub{margin-top:14px}
250
+ .seg{display:inline-flex;border:1px solid var(--border);border-radius:99px;padding:3px;background:var(--bg);gap:3px}
251
+ .seg span{font-size:12.5px;padding:4px 12px;border-radius:99px;color:var(--muted);border:1px solid transparent;cursor:pointer}
252
+ .seg span.on{font-weight:600;background:#f9731618;color:var(--orange2);border-color:var(--orange)}
253
+ .g2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:14px}
254
+ .g3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:10px}
255
+ @media(max-width:700px){.g2,.g3{grid-template-columns:1fr}}
256
+ .su-dash{margin-top:12px;font-size:12px;color:var(--muted);border:1px dashed var(--border);border-radius:8px;padding:9px 11px}
257
+ .su-foot{border-top:1px solid var(--border);padding-top:16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
258
+ .su-err{margin-top:10px;font-size:12.5px;color:#fda4af;white-space:pre-wrap}
259
+ .su-file{font-size:12.5px;margin-bottom:10px;word-break:break-all}
260
+ .su-json{white-space:pre;max-height:none;overflow:auto;margin-top:0}
261
+ .su-miss{margin-top:10px;font-size:12px;color:#fcd34d}
262
+ .su-miss li{margin-top:3px}
263
+ .su-promise{display:flex;flex-direction:column;gap:8px;margin-top:14px;font-size:12px;color:var(--muted)}
264
+ .su-promise b{color:var(--text)}
210
265
  </style>
211
266
  </head><body>
212
267
  <header><div class="wrap head">
@@ -224,7 +279,24 @@ td.n{text-align:right;font-variant-numeric:tabular-nums}
224
279
  </div></header>
225
280
  <main><div class="wrap">
226
281
  <div id="update"></div>
227
- <section class="grid cols">
282
+ <!-- Shown instead of everything below when the folder has no config: nothing
283
+ else on this page can say anything true about an app it has no address for. -->
284
+ <section class="grid cols" id="setupSec" hidden>
285
+ <div class="card" id="setupForm"></div>
286
+ <div class="card">
287
+ <h2>What this writes</h2>
288
+ <div class="su-file">One file: <code class="k" id="setupPath"></code></div>
289
+ <div class="log su-json" id="setupJson"></div>
290
+ <div class="note" id="setupMore"></div>
291
+ <div id="setupProblems"></div>
292
+ <div class="su-promise">
293
+ <div>🔑 <b>References only.</b> A password, a token or a cookie is never written to this file, and no field on this page asks for one.</div>
294
+ <div>📦 This file is meant to be committed.</div>
295
+ <div>🔒 Setting up sends nothing anywhere — except the sitemap request in step 2, and only if you click it.</div>
296
+ </div>
297
+ </div>
298
+ </section>
299
+ <section class="grid cols" id="mainGrid">
228
300
  <div class="card" id="verdictCard"><div class="empty">loading…</div></div>
229
301
  <div class="card">
230
302
  <h2>Actions</h2>
@@ -281,7 +353,13 @@ let LAST_STATE = {};
281
353
 
282
354
  function render(s) {
283
355
  LAST_STATE = s || {};
284
- renderPicker(s); renderUpdate(s); renderHead(s); renderVerdict(s); renderActions(s);
356
+ renderPicker(s); renderUpdate(s); renderHead(s);
357
+ const setup = Boolean(s.project && s.project.needsSetup);
358
+ q('#setupSec').hidden = !setup;
359
+ q('#mainGrid').hidden = setup;
360
+ ['#decisions', '#project', '#history'].forEach((id) => { q(id).closest('section').hidden = setup; });
361
+ if (setup) { q('#findingsCard').hidden = true; renderSetup(s); return; }
362
+ renderVerdict(s); renderActions(s);
285
363
  renderDecisions(s); renderHistory(s); renderGoto(s); bindCopy();
286
364
  }
287
365
 
@@ -329,8 +407,12 @@ function renderPicker(s) {
329
407
  // terminal, the feature could never start existing.
330
408
  wrap.style.display = '';
331
409
  const sel = q('#picker');
410
+ // A folder being set up is not in the list yet — it joins once its config is
411
+ // written — but it is the one on screen, so the menu has to say so.
412
+ const listed = list.some((p) => p.dir === s.activeDir);
332
413
  sel.innerHTML =
333
- list.map((p) =>
414
+ (listed ? '' : '<option value="' + esc(s.activeDir) + '" selected>' + esc(shortDir(s.activeDir)) + ' — not set up yet</option>')
415
+ + list.map((p) =>
334
416
  '<option value="' + esc(p.dir) + '"' + (p.dir === s.activeDir ? ' selected' : '') + '>'
335
417
  + esc(shortDir(p.dir)) + (p.baseUrl ? ' — ' + esc(p.baseUrl) : '') + '</option>'
336
418
  ).join('')
@@ -364,8 +446,7 @@ function askForFolder() {
364
446
  const v = prompt(
365
447
  'Add a project to this console.\\n\\n'
366
448
  + 'Paste the full path of the project folder — the one you would open in your '
367
- + 'editor. It must already contain a .ia-qa folder (run "ia-qa-heal init" there '
368
- + 'if it does not).',
449
+ + 'editor. If it has never been set up, the console walks you through it.',
369
450
  ''
370
451
  );
371
452
  return v && v.trim() ? v.trim() : null;
@@ -782,6 +863,333 @@ function bindCopy() {
782
863
  });
783
864
  }
784
865
 
866
+ /* ---- setup: a folder with no .ia-qa/config.json ------------------------ */
867
+
868
+ /* The file ia-qa-heal init writes, asked for in three steps. The panel on the
869
+ right is the server's own plan for the answers so far — the function the write
870
+ goes through — so it cannot show one file and write another. No field here
871
+ takes a secret: the login answers are the NAMES of variables. */
872
+ const ENV_DEFAULTS = { usernameKey: 'E2E_USERNAME', passwordKey: 'E2E_PASSWORD' };
873
+ const SSM_DEFAULTS = { usernameKey: '/staging/app/username', passwordKey: '/staging/app/password' };
874
+ const CHECK_SVG = '<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="#09090b" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8.5l3 3 6-7"/></svg>';
875
+ const setupBlank = () => ({
876
+ step: 1, baseUrl: '', found: null, foundFor: '', reason: [], capped: false, selected: {}, showAll: false, manual: [],
877
+ mode: '', store: 'env', usernameKey: ENV_DEFAULTS.usernameKey, passwordKey: ENV_DEFAULTS.passwordKey, region: 'eu-west-3',
878
+ loginUrl: '/login', usernameSelector: 'input[name="email"]', passwordSelector: 'input[type="password"]',
879
+ submitSelector: 'button[type="submit"]', busy: false, error: '',
880
+ });
881
+ let SETUP = setupBlank();
882
+ let setupDir = null;
883
+ let previewTimer = null;
884
+
885
+ async function postJson(pathname, body) {
886
+ const r = await fetch(pathname + '?t=' + T, {
887
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
888
+ });
889
+ return r.json();
890
+ }
891
+
892
+ function setupPages() {
893
+ return (SETUP.found || []).filter((p) => SETUP.selected[p.url]).concat(SETUP.manual);
894
+ }
895
+
896
+ function setupBody() {
897
+ const S = SETUP;
898
+ const login = S.mode === 'form'
899
+ ? { mode: 'form', store: S.store, usernameKey: S.usernameKey, passwordKey: S.passwordKey, region: S.region,
900
+ loginUrl: S.loginUrl, usernameSelector: S.usernameSelector, passwordSelector: S.passwordSelector,
901
+ submitSelector: S.submitSelector }
902
+ : { mode: S.mode };
903
+ return { baseUrl: S.baseUrl, pages: setupPages().map((p) => ({ name: p.name, url: p.url })), login: login };
904
+ }
905
+
906
+ function renderSetup(s) {
907
+ // Answers typed for one folder must never be written into the next one picked.
908
+ if (setupDir !== s.activeDir) { SETUP = setupBlank(); setupDir = s.activeDir; }
909
+ drawSetup();
910
+ refreshPreview();
911
+ }
912
+
913
+ function sitemapUrl(base) {
914
+ try { return new URL('/sitemap.xml', base).toString(); } catch (e) { return base + '/sitemap.xml'; }
915
+ }
916
+
917
+ function stepRow(n, label, value) {
918
+ return '<div class="step st-row"><span class="num">' + n + ' of 3</span><b>' + esc(label) + '</b>'
919
+ + '<span class="v">' + esc(value) + '</span><a href="#" data-goto="' + n + '">change</a></div>';
920
+ }
921
+
922
+ function drawSetup() {
923
+ const S = SETUP;
924
+ let h = '<h2>Set up this folder</h2>'
925
+ + '<div class="lede-s">This folder has no <code class="k">.ia-qa/config.json</code> yet, so there is nothing to look at. '
926
+ + 'Answer three questions and the console writes it for you — the same file <code class="k">ia-qa-heal init</code> would.</div>';
927
+
928
+ if (S.step === 1) {
929
+ h += '<div class="step"><div class="st"><span class="num">1 of 3</span><b>Where does the app live?</b></div>'
930
+ + '<div class="rowf"><input class="field" id="su-url" placeholder="https://staging.example.com" autocomplete="off" spellcheck="false" value="' + esc(S.baseUrl) + '">'
931
+ + '<button class="cta" id="su-next1">Next</button></div>'
932
+ + '<div class="note">The address your tests run against. A staging server beats production if you have one.</div></div>'
933
+ + '<div class="step st-row st-lock"><span class="num">2 of 3</span><b>Which pages should it watch?</b><span class="aside">needs the address first</span></div>';
934
+ } else {
935
+ h += stepRow(1, 'App', S.baseUrl);
936
+ if (S.step === 2) h += pagesStep();
937
+ else { const n = setupPages().length; h += stepRow(2, 'Pages', n + ' page' + (n === 1 ? '' : 's')); }
938
+ }
939
+
940
+ if (S.step === 3) h += loginStep();
941
+ else h += '<div class="step st-row' + (S.step === 1 ? ' st-lock' : '') + '"><span class="num">3 of 3</span><b>Does the app ask you to log in?</b>'
942
+ + (S.step === 2 ? '<span class="aside">not answered yet</span>' : '') + '</div>';
943
+
944
+ const ready = S.step === 3 && S.mode && !S.busy;
945
+ h += '<div class="su-foot"><button class="cta" id="su-write"' + (ready ? '' : ' disabled') + '>'
946
+ + (S.busy && S.step === 3 ? 'Writing…' : 'Write the config ✎') + '</button>'
947
+ + '<span class="note" style="margin:0">Changes one file on your computer. Nothing is committed.</span></div>'
948
+ + (S.error ? '<div class="su-err">' + esc(S.error) + '</div>' : '');
949
+ q('#setupForm').innerHTML = h;
950
+ bindSetup();
951
+ }
952
+
953
+ function pagesStep() {
954
+ const S = SETUP;
955
+ let h = '<div class="step"><div class="st"><span class="num">2 of 3</span><b>Which pages should it watch?</b></div>'
956
+ + '<div class="rowb"><button class="cta" id="su-sitemap"' + (S.busy ? ' disabled' : '') + '>'
957
+ + (S.busy ? 'Reading the sitemap…' : S.found ? 'Read the sitemap again' : 'Find them in the sitemap') + '</button></div>'
958
+ + '<div class="net-line" style="margin-top:0">🔗 One request to <b>' + esc(sitemapUrl(S.baseUrl)) + '</b> (and robots.txt if the sitemap lives elsewhere) — plain GET, no browser, no login, nothing else sent.</div>';
959
+ if (S.found && S.found.length === 0) {
960
+ h += '<div class="note">' + S.reason.map(esc).join('<br>') + '<br>Add the pages by hand below.</div>';
961
+ } else if (S.found) {
962
+ const n = S.found.length;
963
+ const sel = S.found.filter((p) => S.selected[p.url]).length;
964
+ h += '<div class="su-found"><b>Found ' + n + ' page' + (n === 1 ? '' : 's') + (S.capped ? ' — the first ' + n + ', the sitemap lists more' : '') + '.</b>'
965
+ + '<span class="note" style="margin:0">' + sel + ' selected</span>'
966
+ + '<span class="su-all"><a href="#" id="su-all">select all</a><a href="#" id="su-none">none</a></span></div>'
967
+ + (S.showAll ? S.found : S.found.slice(0, 8)).map((p) => pageRow(p, Boolean(S.selected[p.url]), 'u', p.url)).join('');
968
+ if (!S.showAll && n > 8) h += '<button class="cta su-more" id="su-more">Show the ' + (n - 8) + ' others</button>';
969
+ }
970
+ h += S.manual.map((p, i) => pageRow(p, true, 'm', String(i))).join('');
971
+ h += '<div class="su-add"><div class="note">A page the sitemap does not list — a route behind the login, say — can be added by hand.</div>'
972
+ + '<div class="rowf"><input class="field" id="su-mname" placeholder="name" style="width:170px;flex:none" autocomplete="off">'
973
+ + '<input class="field mono" id="su-mpath" placeholder="/path" autocomplete="off" spellcheck="false">'
974
+ + '<button class="btn-s" id="su-madd">Add</button></div></div>'
975
+ + '<div class="rowb" style="margin:14px 0 0"><button class="cta" id="su-next2"' + (setupPages().length ? '' : ' disabled') + '>Next</button></div></div>';
976
+ return h;
977
+ }
978
+
979
+ function pageRow(p, on, kind, key) {
980
+ return '<div class="frow su-row" data-kind="' + kind + '" data-key="' + esc(key) + '">'
981
+ + '<span class="chk' + (on ? ' on' : '') + '">' + (on ? CHECK_SVG : '') + '</span>'
982
+ + '<span class="su-n" title="' + esc(p.name) + '">' + esc(p.name) + '</span><span class="su-p">' + esc(p.url) + '</span>'
983
+ + (kind === 'm' ? '<a href="#" class="su-rm">remove</a>' : '') + '</div>';
984
+ }
985
+
986
+ function loginStep() {
987
+ const S = SETUP;
988
+ const opt = (mode, title, body) => '<div class="opt' + (S.mode === mode ? ' opt-on' : '') + '" data-mode="' + mode + '">'
989
+ + '<span class="r' + (S.mode === mode ? ' r-on' : '') + '"></span><div class="opt-b"><b>' + title + '</b>' + body + '</div></div>';
990
+ return '<div class="step"><div class="st"><span class="num">3 of 3</span><b>Does the app ask you to log in?</b></div><div class="opts">'
991
+ + opt('none', 'No — the pages are public', '')
992
+ + opt('session', 'Yes — I will log in myself, in a browser',
993
+ '<div class="note">Works with any login: single sign-on, two-factor, a code by email. A browser window opens, you sign in the way you always do, and the session is kept for the next captures.</div>'
994
+ + '<div class="upd-cmd"><code>ia-qa-heal login</code><button class="copy" data-copy="ia-qa-heal login" data-copy-label="copy command">copy command</button></div>'
995
+ + '<div class="note">Run it once in a terminal, after setup. The console cannot open that window for you: it waits for a person at the keyboard. The session file holds live cookies, so it is kept out of git.</div>')
996
+ + opt('form', 'Yes — fill a plain login form from stored credentials',
997
+ '<div class="note">For pipelines: no human needed. A username, a password and a submit button, nothing fancier. Single sign-on and two-factor will not get through this way.</div>'
998
+ + (S.mode === 'form' ? formFields() : ''))
999
+ + '</div></div>';
1000
+ }
1001
+
1002
+ function formFields() {
1003
+ const S = SETUP;
1004
+ const env = S.store === 'env';
1005
+ const field = (key, label) => '<div><div class="lbl">' + label + '</div><input class="field mono" data-f="' + key + '" value="' + esc(S[key]) + '" autocomplete="off" spellcheck="false"></div>';
1006
+ return '<div class="su-sub"><div class="lbl">Where are the credentials kept?</div><div class="seg">'
1007
+ + '<span data-store="env"' + (env ? ' class="on"' : '') + '>Environment variables</span>'
1008
+ + '<span data-store="aws-ssm"' + (env ? '' : ' class="on"') + '>AWS SSM Parameter Store</span></div></div>'
1009
+ + '<div class="g2">' + field('usernameKey', env ? 'Variable holding the username' : 'SSM parameter holding the username')
1010
+ + field('passwordKey', env ? 'Variable holding the password' : 'SSM parameter holding the password') + '</div>'
1011
+ + (env ? '' : '<div class="su-sub" style="max-width:260px">' + field('region', 'AWS region') + '</div>')
1012
+ + '<div class="su-dash">These are the <b style="color:var(--text)">names</b> of ' + (env ? 'variables' : 'parameters') + ', not their values. '
1013
+ + 'There is no password field on this page, on purpose: the value stays ' + (env ? 'in your environment' : 'in SSM') + ' and is only read when a capture logs in.</div>'
1014
+ + '<div class="su-sub" style="max-width:260px">' + field('loginUrl', 'Login page') + '</div>'
1015
+ + '<details class="tech"><summary>Form fields — the defaults fit most plain forms</summary><div class="g3">'
1016
+ + field('usernameSelector', 'Username field') + field('passwordSelector', 'Password field') + field('submitSelector', 'Submit button')
1017
+ + '</div></details>';
1018
+ }
1019
+
1020
+ function bindSetup() {
1021
+ const S = SETUP;
1022
+ const box = q('#setupForm');
1023
+ const on = (sel, fn) => { const el = q(sel); if (el) el.onclick = (e) => { e.preventDefault(); fn(); }; };
1024
+ const url = q('#su-url');
1025
+ if (url) {
1026
+ url.oninput = () => { S.baseUrl = url.value.trim(); schedulePreview(); };
1027
+ url.onkeydown = (e) => { if (e.key === 'Enter') nextFromUrl(); };
1028
+ url.focus();
1029
+ }
1030
+ on('#su-next1', nextFromUrl);
1031
+ on('#su-sitemap', readSitemapNow);
1032
+ on('#su-all', () => { S.found.forEach((p) => { S.selected[p.url] = true; }); drawSetup(); schedulePreview(); });
1033
+ on('#su-none', () => { S.selected = {}; drawSetup(); schedulePreview(); });
1034
+ on('#su-more', () => { S.showAll = true; drawSetup(); });
1035
+ on('#su-madd', addManualPage);
1036
+ on('#su-next2', () => { S.step = 3; S.error = ''; drawSetup(); schedulePreview(); });
1037
+ on('#su-write', writeSetupNow);
1038
+ const mpath = q('#su-mpath');
1039
+ if (mpath) mpath.onkeydown = (e) => { if (e.key === 'Enter') addManualPage(); };
1040
+ box.querySelectorAll('[data-goto]').forEach((a) => {
1041
+ a.onclick = (e) => { e.preventDefault(); S.step = Number(a.dataset.goto); S.error = ''; drawSetup(); schedulePreview(); };
1042
+ });
1043
+ box.querySelectorAll('.su-row').forEach((row) => {
1044
+ row.onclick = (e) => {
1045
+ if (row.dataset.kind === 'm') {
1046
+ if (e.target.classList.contains('su-rm')) {
1047
+ e.preventDefault();
1048
+ S.manual.splice(Number(row.dataset.key), 1);
1049
+ drawSetup(); schedulePreview();
1050
+ }
1051
+ return;
1052
+ }
1053
+ const k = row.dataset.key;
1054
+ if (S.selected[k]) delete S.selected[k]; else S.selected[k] = true;
1055
+ drawSetup(); schedulePreview();
1056
+ };
1057
+ });
1058
+ box.querySelectorAll('[data-mode]').forEach((el) => {
1059
+ el.onclick = () => {
1060
+ if (S.mode === el.dataset.mode) return;
1061
+ S.mode = el.dataset.mode; S.error = '';
1062
+ drawSetup(); schedulePreview();
1063
+ };
1064
+ });
1065
+ box.querySelectorAll('[data-store]').forEach((el) => {
1066
+ el.onclick = (e) => { e.stopPropagation(); switchStore(el.dataset.store); };
1067
+ });
1068
+ box.querySelectorAll('input[data-f]').forEach((el) => {
1069
+ el.oninput = () => { S[el.dataset.f] = el.value.trim(); schedulePreview(); };
1070
+ });
1071
+ bindCopy();
1072
+ }
1073
+
1074
+ function nextFromUrl() {
1075
+ const S = SETUP;
1076
+ let ok = false;
1077
+ try { const u = new URL(S.baseUrl); ok = u.protocol === 'http:' || u.protocol === 'https:'; } catch (e) { ok = false; }
1078
+ if (!ok) {
1079
+ S.error = 'Type the full address, starting with http:// or https:// — for example https://staging.example.com';
1080
+ drawSetup();
1081
+ return;
1082
+ }
1083
+ // Pages read from another address belong to another app.
1084
+ if (S.foundFor && S.foundFor !== S.baseUrl) { S.found = null; S.selected = {}; S.foundFor = ''; }
1085
+ S.error = ''; S.step = 2;
1086
+ drawSetup(); schedulePreview();
1087
+ }
1088
+
1089
+ async function readSitemapNow() {
1090
+ const S = SETUP;
1091
+ S.busy = true; S.error = ''; drawSetup();
1092
+ try {
1093
+ const r = await postJson('/api/setup/sitemap', { baseUrl: S.baseUrl });
1094
+ if (r.error) S.error = r.error;
1095
+ else {
1096
+ S.found = r.pages; S.reason = r.reason || []; S.capped = Boolean(r.capped);
1097
+ S.foundFor = S.baseUrl; S.showAll = false; S.selected = {};
1098
+ r.pages.forEach((p) => { S.selected[p.url] = true; });
1099
+ }
1100
+ } catch (e) {
1101
+ S.error = 'The console lost contact with its server: ' + e;
1102
+ }
1103
+ S.busy = false;
1104
+ drawSetup(); schedulePreview();
1105
+ }
1106
+
1107
+ function addManualPage() {
1108
+ const S = SETUP;
1109
+ const name = q('#su-mname').value.trim();
1110
+ let where = q('#su-mpath').value.trim();
1111
+ if (!name || !where) { S.error = 'A page needs a name and a path.'; drawSetup(); return; }
1112
+ const low = where.toLowerCase();
1113
+ if (where.charAt(0) !== '/' && low.indexOf('http://') !== 0 && low.indexOf('https://') !== 0) where = '/' + where;
1114
+ S.manual.push({ name: name, url: where });
1115
+ S.error = '';
1116
+ drawSetup(); schedulePreview();
1117
+ }
1118
+
1119
+ function switchStore(store) {
1120
+ const S = SETUP;
1121
+ if (S.store === store) return;
1122
+ const from = store === 'env' ? SSM_DEFAULTS : ENV_DEFAULTS;
1123
+ const to = store === 'env' ? ENV_DEFAULTS : SSM_DEFAULTS;
1124
+ if (S.usernameKey === from.usernameKey) S.usernameKey = to.usernameKey;
1125
+ if (S.passwordKey === from.passwordKey) S.passwordKey = to.passwordKey;
1126
+ S.store = store;
1127
+ drawSetup(); schedulePreview();
1128
+ }
1129
+
1130
+ function schedulePreview() {
1131
+ clearTimeout(previewTimer);
1132
+ previewTimer = setTimeout(refreshPreview, 250);
1133
+ }
1134
+
1135
+ async function refreshPreview() {
1136
+ let r;
1137
+ try { r = await postJson('/api/setup/preview', setupBody()); } catch (e) { return; }
1138
+ const cfg = r.config || {};
1139
+ const pages = cfg.pages || [];
1140
+ const shown = Object.assign({}, cfg, { pages: pages.slice(0, 7) });
1141
+ q('#setupPath').textContent = r.file || '';
1142
+ q('#setupJson').textContent = JSON.stringify(shown, null, 2);
1143
+ const more = pages.length - 7;
1144
+ q('#setupMore').textContent = more > 0 ? '… and ' + more + ' more page' + (more === 1 ? '' : 's') + ' in the file.' : '';
1145
+ const missing = SETUP.step === 3 ? (r.problems || []) : [];
1146
+ q('#setupProblems').innerHTML = missing.length
1147
+ ? '<ul class="su-miss">' + missing.map((p) => '<li>' + esc(p) + '</li>').join('') + '</ul>'
1148
+ : '';
1149
+ }
1150
+
1151
+ async function writeSetupNow() {
1152
+ const S = SETUP;
1153
+ S.busy = true; S.error = ''; drawSetup();
1154
+ let r;
1155
+ try { r = await postJson('/api/setup/write', setupBody()); }
1156
+ catch (e) { r = { error: 'The console lost contact with its server: ' + e }; }
1157
+ S.busy = false;
1158
+ if (!r.written) { S.error = r.error || 'The config could not be written.'; drawSetup(); return; }
1159
+ SETUP = setupBlank(); setupDir = null;
1160
+ render(r.state);
1161
+ showSetupDone(r);
1162
+ }
1163
+
1164
+ /* The result lands where every other result does — in the Actions card — and
1165
+ says what the chosen login still needs from a person, because that is the one
1166
+ step this form could not take for them. */
1167
+ function showSetupDone(r) {
1168
+ const missing = r.missingEnv || [];
1169
+ let what;
1170
+ if (r.login === 'session') {
1171
+ what = 'Next, log in once from a terminal in this folder. A browser opens, you sign in the way you always do, and the session is kept for every capture after that.';
1172
+ } else if (r.login === 'form' && r.store === 'aws-ssm') {
1173
+ what = 'Only the SSM parameter names were saved. Your local AWS credentials fetch the values when a capture logs in.';
1174
+ } else if (r.login === 'form') {
1175
+ what = 'Only the variable names were saved.' + (missing.length
1176
+ ? ' ' + missing.join(' and ') + (missing.length === 1 ? ' is' : ' are') + ' not set in the environment this console was started from — set '
1177
+ + (missing.length === 1 ? 'it' : 'them') + ' there and reopen the console before the first capture, or it will stop at the login page and say so.'
1178
+ : ' Both are set in the environment this console runs in.');
1179
+ } else {
1180
+ what = 'No login — the pages will be read the way a visitor sees them.';
1181
+ }
1182
+ q('#result').innerHTML = '<div class="res res-ok"><div class="res-head">✅ <span>Done</span></div>'
1183
+ + '<div class="res-what"><b style="color:var(--text)">' + esc(r.headline) + '</b></div>'
1184
+ + '<div class="res-what">' + esc(what) + '</div>'
1185
+ + (r.login === 'session'
1186
+ ? '<div class="upd-cmd"><code>ia-qa-heal login</code><button class="copy" data-copy="ia-qa-heal login" data-copy-label="copy command">copy command</button></div>'
1187
+ : '')
1188
+ + '<div id="next"></div></div>';
1189
+ renderNext(r.next);
1190
+ bindCopy();
1191
+ }
1192
+
785
1193
  /* ---- history ---------------------------------------------------------- */
786
1194
 
787
1195
  function renderHistory(s) {
@@ -1 +1 @@
1
- {"version":3,"file":"page.js","sourceRoot":"","sources":["../../src/ui/page.ts"],"names":[],"mappings":";;AAqBA,8CAi2BC;AAt3BD;;;;;;;;;;;;;;;;GAgBG;AACH,qCAAiD;AACjD,sCAAyD;AACzD,iCAAuC;AAEvC,SAAgB,iBAAiB,CAAC,KAAa;IAC7C,yEAAyE;IACzE,EAAE;IACF,+EAA+E;IAC/E,8EAA8E;IAC9E,4EAA4E;IAC5E,4DAA4D;IAC5D,EAAE;IACF,+EAA+E;IAC/E,2EAA2E;IAC3E,8EAA8E;IAC9E,oEAAoE;IACpE,EAAE;IACF,iFAAiF;IACjF,kFAAkF;IAClF,4EAA4E;IAC5E,MAAM,OAAO,GAAG,IAAA,uBAAc,GAAE,CAAC;IACjC,uFAAuF;IACvF,wFAAwF;IACxF,uFAAuF;IACvF,sFAAsF;IACtF,MAAM,IAAI,GAAG,IAAA,oBAAa,GAAE,CAAC;IAC7B,MAAM,OAAO,GAAG,IAAI;QAClB,CAAC,CAAC,gEAAgE,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,GAAG,IAAI;QACnG,CAAC,CAAC,EAAE,CAAC;IACP,OAAO;;;;EAIP,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gHAiKuG,qBAAY,IAAI,OAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YAqDxI,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBA2lBf,8BAAqB;;;;;;;;;;;;;;;;;;eAkBxB,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"page.js","sourceRoot":"","sources":["../../src/ui/page.ts"],"names":[],"mappings":";;AAqBA,8CAyvCC;AA9wCD;;;;;;;;;;;;;;;;GAgBG;AACH,qCAAiD;AACjD,sCAAyD;AACzD,iCAAuC;AAEvC,SAAgB,iBAAiB,CAAC,KAAa;IAC7C,yEAAyE;IACzE,EAAE;IACF,+EAA+E;IAC/E,8EAA8E;IAC9E,4EAA4E;IAC5E,4DAA4D;IAC5D,EAAE;IACF,+EAA+E;IAC/E,2EAA2E;IAC3E,8EAA8E;IAC9E,oEAAoE;IACpE,EAAE;IACF,iFAAiF;IACjF,kFAAkF;IAClF,4EAA4E;IAC5E,MAAM,OAAO,GAAG,IAAA,uBAAc,GAAE,CAAC;IACjC,uFAAuF;IACvF,wFAAwF;IACxF,uFAAuF;IACvF,sFAAsF;IACtF,MAAM,IAAI,GAAG,IAAA,oBAAa,GAAE,CAAC;IAC7B,MAAM,OAAO,GAAG,IAAI;QAClB,CAAC,CAAC,gEAAgE,GAAG,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,GAAG,IAAI;QACnG,CAAC,CAAC,EAAE,CAAC;IACP,OAAO;;;;EAIP,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gHAwNuG,qBAAY,IAAI,OAAO,MAAM,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YAsExI,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBA26Bf,8BAAqB;;;;;;;;;;;;;;;;;;eAkBxB,CAAC;AAChB,CAAC"}
package/dist/ui/server.js CHANGED
@@ -51,6 +51,7 @@ const crypto = __importStar(require("crypto"));
51
51
  const child_process_1 = require("child_process");
52
52
  const config_1 = require("../config");
53
53
  const registry_1 = require("./registry");
54
+ const setup_1 = require("./setup");
54
55
  const diff_1 = require("../cli/diff");
55
56
  const ingest_1 = require("../ingest");
56
57
  const evidence_1 = require("../evidence");
@@ -413,6 +414,20 @@ function setActiveProject(dir) {
413
414
  function getActiveProject() {
414
415
  return activeDir ?? (0, config_1.baseDir)();
415
416
  }
417
+ /**
418
+ * A capture is at least one page contract, not a directory. `saveConfig` creates `mapping/`
419
+ * empty, and reading that as a capture told a freshly set-up folder "You have a capture of
420
+ * the app" and offered to promote it — a baseline of nothing. `_`-prefixed entries are
421
+ * layouts, shots and resolutions, never pages.
422
+ */
423
+ function hasContracts(dir) {
424
+ try {
425
+ return fs.readdirSync(dir).some((f) => f.endsWith('.json') && !f.startsWith('_'));
426
+ }
427
+ catch {
428
+ return false;
429
+ }
430
+ }
416
431
  function buildState() {
417
432
  if (activeDir)
418
433
  (0, config_1.setBaseDir)(activeDir);
@@ -436,9 +451,17 @@ function buildState() {
436
451
  configFile: path.join((0, config_1.baseDir)(), config_1.CONFIG_DIR, 'config.json'),
437
452
  };
438
453
  }
439
- catch {
454
+ catch (e) {
440
455
  project.dir = (0, config_1.baseDir)();
441
- project.error = 'No .ia-qa/config.json in this folder run `ia-qa-heal init` here.';
456
+ // Absent and broken are two different answers. An absent config gets the setup form; a
457
+ // broken one is the user's file and gets its error — never a form that would replace it.
458
+ if (fs.existsSync((0, config_1.configPath)())) {
459
+ project.error = `.ia-qa/config.json is here but cannot be read — ${e instanceof Error ? e.message : String(e)}`;
460
+ }
461
+ else {
462
+ project.needsSetup = true;
463
+ project.error = 'No .ia-qa/config.json in this folder yet — set it up below.';
464
+ }
442
465
  }
443
466
  // Which frameworks the suite is actually written in, read off the inventory
444
467
  // rather than guessed from a folder name — `ingest` already recorded the call
@@ -459,7 +482,7 @@ function buildState() {
459
482
  hasBaseline: fs.existsSync(base),
460
483
  // Told apart because the empty state's advice differs: with a capture and no
461
484
  // reference the next step is to promote one, not to "capture the app" again.
462
- hasMapping: fs.existsSync(current),
485
+ hasMapping: hasContracts(current),
463
486
  history: { entries: history, summary: (0, history_1.summarize)(history) },
464
487
  actions: actionCatalogue(),
465
488
  };
@@ -711,8 +734,11 @@ async function handle(req, res, token, onBye) {
711
734
  * the console exists for people who do not have one.
712
735
  *
713
736
  * So consent can also be given here — but it is still consent, not discovery:
714
- * a path is only accepted if it actually holds `.ia-qa/config.json`, and it is
715
- * *registered*, which means `ui --forget` can take it back. Nothing scans.
737
+ * a project is *registered* only once it holds `.ia-qa/config.json`, which means
738
+ * `ui --forget` can take it back. Nothing scans. A folder that has never been set
739
+ * up opens on the setup form instead of a refusal — that refusal used to say "run
740
+ * `ia-qa-heal init` there", a terminal instruction for the people without one —
741
+ * and joins the list the moment the form has written its config.
716
742
  */
717
743
  if (req.method === 'POST' && url.pathname === '/api/project/add') {
718
744
  let body;
@@ -725,31 +751,98 @@ async function handle(req, res, token, onBye) {
725
751
  }
726
752
  const raw = typeof body.dir === 'string' ? body.dir.trim().replace(/^["']|["']$/g, '') : '';
727
753
  if (raw === '') {
728
- send(res, 400, 'application/json', JSON.stringify({ error: 'Give the folder that holds your .ia-qa directory.' }));
754
+ send(res, 400, 'application/json', JSON.stringify({ error: 'Give the path of the project folder.' }));
729
755
  return;
730
756
  }
731
757
  const dir = path.resolve(raw);
732
- if (!(0, registry_1.isProjectDir)(dir)) {
758
+ if (path.basename(dir) === config_1.CONFIG_DIR) {
733
759
  send(res, 400, 'application/json', JSON.stringify({
734
- error: `No .ia-qa/config.json in ${dir}\n\n` +
735
- `Pick the project's top folder — the one you would open in your editor, ` +
736
- `not the .ia-qa folder itself. If that project has never been set up, run ` +
737
- `\`ia-qa-heal init\` in it first.`,
760
+ error: `That is the ${config_1.CONFIG_DIR} folder itself. Pick the project's top folder — the one you would open in your editor.`,
738
761
  }));
739
762
  return;
740
763
  }
741
- setActiveProject(dir);
742
- let addedUrl;
764
+ let isFolder = false;
743
765
  try {
744
- addedUrl = (0, config_1.loadConfig)().baseUrl;
766
+ isFolder = fs.statSync(dir).isDirectory();
745
767
  }
746
768
  catch {
747
- /* a project whose config will not parse can still be selected, and shown as broken */
769
+ isFolder = false;
770
+ }
771
+ if (!isFolder) {
772
+ send(res, 400, 'application/json', JSON.stringify({ error: `${dir} is not a folder on this machine.` }));
773
+ return;
774
+ }
775
+ setActiveProject(dir);
776
+ if ((0, registry_1.isProjectDir)(dir)) {
777
+ let addedUrl;
778
+ try {
779
+ addedUrl = (0, config_1.loadConfig)().baseUrl;
780
+ }
781
+ catch {
782
+ /* a project whose config will not parse can still be selected, and shown as broken */
783
+ }
784
+ (0, registry_1.rememberProject)(dir, addedUrl);
748
785
  }
749
- (0, registry_1.rememberProject)(dir, addedUrl);
750
786
  send(res, 200, 'application/json; charset=utf-8', JSON.stringify(buildState()));
751
787
  return;
752
788
  }
789
+ /**
790
+ * The setup form's three calls — see ui/setup.ts. `preview` and `write` run the same
791
+ * `planSetup`, so the panel on the page is the file that would be written. None of them
792
+ * spawns anything. The one new authority is writing `.ia-qa/config.json` into the folder
793
+ * this console is showing, and only where none exists; the sitemap read is the same
794
+ * plain fetch `init --discover` makes, to an address the user typed.
795
+ */
796
+ if (req.method === 'POST' && url.pathname.startsWith('/api/setup/')) {
797
+ let body;
798
+ try {
799
+ body = JSON.parse(await readBody(req));
800
+ }
801
+ catch {
802
+ send(res, 400, 'application/json', JSON.stringify({ error: 'bad request body' }));
803
+ return;
804
+ }
805
+ const dir = getActiveProject();
806
+ if (url.pathname === '/api/setup/sitemap') {
807
+ try {
808
+ const found = await (0, setup_1.readSitemap)(typeof body.baseUrl === 'string' ? body.baseUrl.trim() : '');
809
+ send(res, 200, 'application/json; charset=utf-8', JSON.stringify(found));
810
+ }
811
+ catch (e) {
812
+ send(res, 400, 'application/json', JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
813
+ }
814
+ return;
815
+ }
816
+ if (url.pathname === '/api/setup/preview') {
817
+ send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ ...(0, setup_1.planSetup)(body, dir), file: (0, config_1.configPath)(dir) }));
818
+ return;
819
+ }
820
+ if (url.pathname === '/api/setup/write') {
821
+ const result = (0, setup_1.writeSetup)(body, dir);
822
+ if (!result.written) {
823
+ send(res, 400, 'application/json', JSON.stringify({ error: result.error, problems: result.plan?.problems ?? [] }));
824
+ return;
825
+ }
826
+ const { config, mode, missingEnv } = result.plan;
827
+ (0, registry_1.rememberProject)(dir, config.baseUrl);
828
+ const n = config.pages.length;
829
+ // `map` stops at a login wall it cannot pass — before `login` has run, or with the
830
+ // form's variables absent from this process — so it is offered only where it can work.
831
+ const canLook = mode === 'none' || (mode === 'form' && missingEnv.length === 0);
832
+ send(res, 200, 'application/json; charset=utf-8', JSON.stringify({
833
+ written: result.file,
834
+ headline: `.ia-qa/config.json written — ${n} page${n === 1 ? '' : 's'}`,
835
+ login: mode,
836
+ store: config.secrets.username?.source ?? null,
837
+ missingEnv,
838
+ next: canLook
839
+ ? [{ id: 'map', label: 'Look at the app', means: ACTIONS.map.means, writes: ACTIONS.map.writes }]
840
+ : [],
841
+ state: buildState(),
842
+ }));
843
+ return;
844
+ }
845
+ }
753
846
  if (req.method === 'POST' && url.pathname === '/api/run') {
754
847
  let body;
755
848
  try {