@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/README.md +40 -4
- package/SPEC-selector-resolution.md +51 -2
- package/TUTORIAL.md +17 -5
- package/dist/browser/extract.js +100 -0
- package/dist/browser/extract.js.map +1 -1
- package/dist/browser/match.d.ts +40 -12
- package/dist/browser/match.js +29 -2
- package/dist/browser/match.js.map +1 -1
- package/dist/browser/resolve.js +28 -7
- package/dist/browser/resolve.js.map +1 -1
- package/dist/cli/fix.js +18 -1
- package/dist/cli/fix.js.map +1 -1
- package/dist/cli/map.js +52 -2
- package/dist/cli/map.js.map +1 -1
- package/dist/engineProbe.d.ts +14 -0
- package/dist/engineProbe.js +133 -0
- package/dist/engineProbe.js.map +1 -0
- package/dist/fixEngine.d.ts +6 -1
- package/dist/fixEngine.js +8 -2
- package/dist/fixEngine.js.map +1 -1
- package/dist/ingest.js +15 -0
- package/dist/ingest.js.map +1 -1
- package/dist/nameLocators.js +4 -0
- package/dist/nameLocators.js.map +1 -1
- package/dist/resolution.d.ts +9 -0
- package/dist/resolution.js +12 -2
- package/dist/resolution.js.map +1 -1
- package/dist/selectorSyntax.d.ts +42 -0
- package/dist/selectorSyntax.js +98 -0
- package/dist/selectorSyntax.js.map +1 -0
- package/dist/ui/page.js +413 -5
- package/dist/ui/page.js.map +1 -1
- package/dist/ui/server.js +109 -16
- package/dist/ui/server.js.map +1 -1
- package/dist/ui/setup.d.ts +55 -0
- package/dist/ui/setup.js +216 -0
- package/dist/ui/setup.js.map +1 -0
- package/package.json +2 -1
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
|
-
|
|
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);
|
|
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
|
-
|
|
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.
|
|
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) {
|
package/dist/ui/page.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page.js","sourceRoot":"","sources":["../../src/ui/page.ts"],"names":[],"mappings":";;AAqBA,
|
|
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
|
-
|
|
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:
|
|
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
|
|
715
|
-
*
|
|
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
|
|
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 (
|
|
758
|
+
if (path.basename(dir) === config_1.CONFIG_DIR) {
|
|
733
759
|
send(res, 400, 'application/json', JSON.stringify({
|
|
734
|
-
error: `
|
|
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
|
-
|
|
742
|
-
let addedUrl;
|
|
764
|
+
let isFolder = false;
|
|
743
765
|
try {
|
|
744
|
-
|
|
766
|
+
isFolder = fs.statSync(dir).isDirectory();
|
|
745
767
|
}
|
|
746
768
|
catch {
|
|
747
|
-
|
|
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 {
|