@ia-qa/pal 1.4.0 → 1.7.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.
- package/README.md +31 -1
- package/TUTORIAL.md +26 -2
- package/dist/cli/index.js +38 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/history.d.ts +2 -0
- package/dist/history.js +1 -0
- package/dist/history.js.map +1 -1
- package/dist/mcp/server.d.ts +6 -0
- package/dist/mcp/server.js +33 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/project.d.ts +11 -0
- package/dist/project.js +2 -0
- package/dist/project.js.map +1 -1
- package/dist/report.js +17 -0
- package/dist/report.js.map +1 -1
- package/dist/run.d.ts +2 -0
- package/dist/run.js +7 -0
- package/dist/run.js.map +1 -1
- package/dist/session.d.ts +5 -0
- package/dist/session.js +3 -3
- package/dist/session.js.map +1 -1
- package/dist/tour.d.ts +37 -0
- package/dist/tour.js +73 -2
- package/dist/tour.js.map +1 -1
- package/dist/ui/page.d.ts +15 -0
- package/dist/ui/page.js +362 -0
- package/dist/ui/page.js.map +1 -0
- package/dist/ui/server.d.ts +100 -0
- package/dist/ui/server.js +362 -0
- package/dist/ui/server.js.map +1 -0
- package/package.json +2 -2
- package/skills/ia-qa-pal/SKILL.md +11 -0
package/dist/ui/page.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderConsolePage = renderConsolePage;
|
|
4
|
+
/**
|
|
5
|
+
* The console page: one self-contained document, no network resource, polling its own server.
|
|
6
|
+
*
|
|
7
|
+
* Every value that comes from the app or the disk is written with `textContent` — never HTML —
|
|
8
|
+
* because page names, persona names and runner output are data, and the report's rule that
|
|
9
|
+
* app-derived strings are escaped applies here with even less excuse: this page is live.
|
|
10
|
+
*
|
|
11
|
+
* Four panels, in the order somebody uses them: who the tour runs as, start one, watch it, see
|
|
12
|
+
* what it found. Counts are "X of Y", never a percentage — the same rule as everywhere else.
|
|
13
|
+
*
|
|
14
|
+
* It is allowed to be friendly, never to flatter: the confetti fires only when a tour that
|
|
15
|
+
* COMPARED something comes back clean. A first tour is "Baseline established" and gets none —
|
|
16
|
+
* celebrating a run that measured nothing would be the false green in a party hat.
|
|
17
|
+
*/
|
|
18
|
+
function renderConsolePage(token) {
|
|
19
|
+
return `<!doctype html>
|
|
20
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
21
|
+
<title>ia-qa-pal console</title>
|
|
22
|
+
<style>
|
|
23
|
+
:root{--bg:#fff7ed;--bg2:#f5f3ff;--card:#ffffff;--ink:#1c1917;--muted:#78716c;--line:#f1e7dc;--code:#faf5ef;--accent:#f97316;--accent2:#ec4899;--accent3:#8b5cf6;--ok:#10b981;--warn:#f59e0b;--bad:#ef4444;--info:#8b5cf6;--shadow:0 1px 2px #0000000a,0 8px 24px -12px #7c2d1233}
|
|
24
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0c0a09;--bg2:#110d1c;--card:#18151f;--ink:#fafaf9;--muted:#a8a29e;--line:#2a2433;--code:#221d2b;--shadow:0 1px 2px #0006,0 10px 30px -14px #000}}
|
|
25
|
+
*{box-sizing:border-box}
|
|
26
|
+
body{margin:0;min-height:100vh;color:var(--ink);font:15px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;background:radial-gradient(1200px 500px at 10% -10%,#fdba7433,transparent 60%),radial-gradient(900px 500px at 100% 0%,#c4b5fd33,transparent 60%),linear-gradient(180deg,var(--bg),var(--bg2))}
|
|
27
|
+
main{max-width:1000px;margin:0 auto;padding:22px 16px 60px}
|
|
28
|
+
.hero{position:relative;overflow:hidden;border-radius:20px;padding:22px 24px;color:#fff;background:linear-gradient(120deg,var(--accent) 0%,var(--accent2) 55%,var(--accent3) 100%);box-shadow:0 18px 40px -20px #db277799}
|
|
29
|
+
.hero:after{content:"";position:absolute;right:-60px;top:-60px;width:220px;height:220px;border-radius:50%;background:#ffffff22}
|
|
30
|
+
.hero .top{display:flex;align-items:center;gap:14px;flex-wrap:wrap;position:relative;z-index:1}
|
|
31
|
+
.mascot{width:54px;height:54px;border-radius:16px;background:#ffffff2e;display:grid;place-items:center;font-size:30px;animation:bob 3.2s ease-in-out infinite}
|
|
32
|
+
@keyframes bob{0%,100%{transform:translateY(0) rotate(-3deg)}50%{transform:translateY(-4px) rotate(3deg)}}
|
|
33
|
+
h1{font-size:24px;margin:0;letter-spacing:-.02em}
|
|
34
|
+
.hello{opacity:.92;font-size:14px}
|
|
35
|
+
.where{margin-top:10px;font-size:14px;opacity:.95;overflow-wrap:anywhere;position:relative;z-index:1}
|
|
36
|
+
.chip{margin-left:auto;display:inline-flex;align-items:center;gap:8px;background:#ffffff2e;border-radius:999px;padding:5px 12px;font-size:13px;font-weight:600}
|
|
37
|
+
.dot{width:9px;height:9px;border-radius:50%;background:#fff;opacity:.7}
|
|
38
|
+
.dot.live{background:#4ade80;opacity:1;box-shadow:0 0 0 0 #4ade80aa;animation:pulse 1.4s infinite}
|
|
39
|
+
@keyframes pulse{0%{box-shadow:0 0 0 0 #4ade80aa}100%{box-shadow:0 0 0 10px #4ade8000}}
|
|
40
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
|
|
41
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:18px 20px;margin:16px 0;box-shadow:var(--shadow)}
|
|
42
|
+
.grid .card{margin:0}
|
|
43
|
+
.grid{margin:16px 0}
|
|
44
|
+
.card h2{font-size:16px;margin:0 0 12px;display:flex;align-items:center;gap:8px}
|
|
45
|
+
.card h2 .ic{width:30px;height:30px;border-radius:10px;display:grid;place-items:center;font-size:16px;background:var(--code)}
|
|
46
|
+
.row{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:8px 0}
|
|
47
|
+
button{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:7px 14px;cursor:pointer;transition:transform .12s,box-shadow .12s,border-color .12s}
|
|
48
|
+
button:hover:not(:disabled){transform:translateY(-1px);border-color:var(--accent);box-shadow:0 6px 14px -8px #f9731699}
|
|
49
|
+
button.primary{background:linear-gradient(120deg,var(--accent),var(--accent2));border:0;color:#fff;font-weight:700;padding:10px 20px;font-size:16px}
|
|
50
|
+
button:disabled{opacity:.5;cursor:not-allowed}
|
|
51
|
+
select,input[type=text],input[type=password]{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--ink);border-radius:10px;padding:7px 10px}
|
|
52
|
+
input:focus,select:focus{outline:2px solid #f9731666;border-color:var(--accent)}
|
|
53
|
+
label.check{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font-size:14px}
|
|
54
|
+
.pill{display:inline-block;border-radius:999px;padding:1px 10px;font-size:12px;font-weight:600;border:1px solid var(--line)}
|
|
55
|
+
.pill.ok{color:var(--ok);border-color:var(--ok);background:#10b98112}.pill.bad{color:var(--bad);border-color:var(--bad);background:#ef444412}
|
|
56
|
+
.pill.warn{color:var(--warn);border-color:var(--warn);background:#f59e0b12}.pill.info{color:var(--info);border-color:var(--info);background:#8b5cf612}
|
|
57
|
+
.muted{color:var(--muted)}
|
|
58
|
+
.avatar{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;font-size:14px;flex:none}
|
|
59
|
+
.idle{display:flex;align-items:center;gap:12px;color:var(--muted)}
|
|
60
|
+
.idle .big{font-size:30px}
|
|
61
|
+
.bar{height:14px;border-radius:999px;background:var(--code);overflow:hidden;margin:12px 0 6px}
|
|
62
|
+
.bar>i{display:block;height:100%;width:0;transition:width .5s;background:repeating-linear-gradient(45deg,var(--accent) 0 12px,var(--accent2) 12px 24px);background-size:34px 34px;animation:stripes 1s linear infinite}
|
|
63
|
+
@keyframes stripes{to{background-position:34px 0}}
|
|
64
|
+
.mood{display:inline-flex;align-items:center;gap:10px;border-radius:14px;padding:8px 14px;font-weight:800;font-size:18px}
|
|
65
|
+
.mood.ok{background:#10b9811a;color:var(--ok)}.mood.warn{background:#f59e0b1a;color:var(--warn)}
|
|
66
|
+
.mood.bad{background:#ef44441a;color:var(--bad)}.mood.info{background:#8b5cf61a;color:var(--info)}
|
|
67
|
+
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;margin:12px 0}
|
|
68
|
+
.stat{background:var(--code);border-radius:12px;padding:10px 12px}
|
|
69
|
+
.stat b{display:block;font-size:22px;line-height:1.1}
|
|
70
|
+
.stat span{font-size:12px;color:var(--muted)}
|
|
71
|
+
.stat.ok b{color:var(--ok)}.stat.bad b{color:var(--bad)}.stat.warn b{color:var(--warn)}.stat.info b{color:var(--info)}
|
|
72
|
+
.strip{display:flex;gap:6px;flex-wrap:wrap;margin:6px 0 10px}
|
|
73
|
+
.strip i{width:14px;height:14px;border-radius:4px;display:block}
|
|
74
|
+
.strip i.ok{background:var(--ok)}.strip i.warn{background:var(--warn)}.strip i.bad{background:var(--bad)}.strip i.info{background:var(--info)}
|
|
75
|
+
a{color:var(--accent);font-weight:600}
|
|
76
|
+
code,.line{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;background:var(--code);border-radius:6px;padding:2px 6px;overflow-wrap:anywhere}
|
|
77
|
+
.line{display:block;padding:8px 10px;margin-top:8px;white-space:pre-wrap}
|
|
78
|
+
ul{list-style:none;margin:0;padding:0}
|
|
79
|
+
li{padding:9px 0;border-top:1px dashed var(--line);display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
|
80
|
+
li:first-child{border-top:0}
|
|
81
|
+
.notice{border-left:4px solid var(--warn);background:#f59e0b14;border-radius:10px;padding:10px 12px;margin:10px 0;white-space:pre-wrap}
|
|
82
|
+
.notice.bad{border-color:var(--bad);background:#ef444414}
|
|
83
|
+
.notice.ok{border-color:var(--ok);background:#10b98114}
|
|
84
|
+
.hidden{display:none!important}
|
|
85
|
+
footer{color:var(--muted);font-size:13px;margin-top:24px;text-align:center}
|
|
86
|
+
#confetti{position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:9}
|
|
87
|
+
#confetti i{position:absolute;top:-12px;width:9px;height:14px;border-radius:2px;animation:fall 2.6s ease-in forwards}
|
|
88
|
+
@keyframes fall{to{transform:translateY(105vh) rotate(720deg);opacity:.2}}
|
|
89
|
+
@media (prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}
|
|
90
|
+
</style></head>
|
|
91
|
+
<body><div id="confetti"></div><main>
|
|
92
|
+
<header class="hero">
|
|
93
|
+
<div class="top">
|
|
94
|
+
<div class="mascot">🧭</div>
|
|
95
|
+
<div><h1>ia-qa-pal</h1><div class="hello" id="hello">Your QA pal — it walks your app and tells you what moved.</div></div>
|
|
96
|
+
<span class="chip"><span class="dot" id="liveDot"></span><span id="liveWord">Idle</span></span>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="where" id="where">loading…</div>
|
|
99
|
+
</header>
|
|
100
|
+
|
|
101
|
+
<section class="card" id="whoCard">
|
|
102
|
+
<h2><span class="ic">👥</span>Who the tour runs as</h2>
|
|
103
|
+
<p class="muted" id="whoHelp">An admin and a guest see different apps. A tour never picks one for you.</p>
|
|
104
|
+
<ul id="personas"></ul>
|
|
105
|
+
<div class="row">
|
|
106
|
+
<input type="text" id="newPersona" placeholder="new user name, e.g. admin" maxlength="64" autocomplete="off" spellcheck="false">
|
|
107
|
+
<button id="addPersona">🔑 Log in as this user</button>
|
|
108
|
+
</div>
|
|
109
|
+
<div id="loginBox" class="notice hidden"></div>
|
|
110
|
+
</section>
|
|
111
|
+
|
|
112
|
+
<div class="grid">
|
|
113
|
+
<section class="card">
|
|
114
|
+
<h2><span class="ic">🚀</span>Run a tour</h2>
|
|
115
|
+
<div class="row"><span class="muted">As</span><select id="tourPersona"></select></div>
|
|
116
|
+
<div class="row">
|
|
117
|
+
<label class="check"><input type="checkbox" id="optSuite"> also run my tests (which pages they visit)</label>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="row">
|
|
120
|
+
<label class="check"><input type="checkbox" id="optStrict"> strict (drift behind clicks fails too)</label>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="row"><button class="primary" id="startTour">Start the tour ▶</button></div>
|
|
123
|
+
<div id="tourNotice" class="notice hidden"></div>
|
|
124
|
+
</section>
|
|
125
|
+
|
|
126
|
+
<section class="card">
|
|
127
|
+
<h2><span class="ic">📡</span>Now</h2>
|
|
128
|
+
<div id="nowIdle" class="idle"><span class="big">💤</span><span id="nowIdleText">Nothing running. Start a tour and watch it walk your app here.</span></div>
|
|
129
|
+
<div id="nowRun" class="hidden">
|
|
130
|
+
<div class="row"><span class="pill info" id="phase"></span><span class="muted" id="count"></span></div>
|
|
131
|
+
<div class="bar"><i id="barFill"></i></div>
|
|
132
|
+
<div class="muted" id="page"></div>
|
|
133
|
+
<div class="line hidden" id="lastLine"></div>
|
|
134
|
+
<div class="row muted" id="beat"></div>
|
|
135
|
+
</div>
|
|
136
|
+
</section>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<section class="card">
|
|
140
|
+
<h2><span class="ic">🏁</span>Last result</h2>
|
|
141
|
+
<div id="resultEmpty" class="idle"><span class="big">🗺️</span><span>No finished tour yet — the first one draws the map everything else is compared with.</span></div>
|
|
142
|
+
<div id="result" class="hidden">
|
|
143
|
+
<div class="row"><span class="mood" id="mood"></span><span id="resultHead"></span></div>
|
|
144
|
+
<div class="stats" id="stats"></div>
|
|
145
|
+
<div class="muted" id="resultCounts"></div>
|
|
146
|
+
<div class="row"><a id="reportLink" target="_blank" rel="noopener">Open the full report →</a></div>
|
|
147
|
+
</div>
|
|
148
|
+
<h2 style="margin-top:18px"><span class="ic">📈</span>History</h2>
|
|
149
|
+
<div class="strip" id="strip"></div>
|
|
150
|
+
<ul id="history"></ul>
|
|
151
|
+
</section>
|
|
152
|
+
|
|
153
|
+
<section class="card">
|
|
154
|
+
<h2><span class="ic">🔐</span>Secrets</h2>
|
|
155
|
+
<p class="muted">Listed by name only — a value is never shown here. You can type one in once, to store it in <span id="store"></span>; it goes nowhere else, and <code>.ia-qa/config.json</code> is not changed. A variable set in your shell still wins, so CI is unaffected. Secrets kept in AWS are not checked from this page — that would call AWS.</p>
|
|
156
|
+
<ul id="secrets"></ul>
|
|
157
|
+
<div class="row"><button id="checkSecrets">🔎 Check they resolve</button></div>
|
|
158
|
+
<div id="storeForm">
|
|
159
|
+
<div class="row">
|
|
160
|
+
<input type="text" id="secretName" maxlength="128" placeholder="Name, e.g. APP_PASSWORD" autocomplete="off" spellcheck="false">
|
|
161
|
+
<input id="secretValue" type="password" placeholder="Value" autocomplete="new-password" spellcheck="false">
|
|
162
|
+
<button id="storeSecret">💾 Store on this machine</button>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
<p class="muted hidden" id="noStore">This machine has no credential store — export the variable in the shell that starts the console, or use <code id="setCmd"></code> where one exists.</p>
|
|
166
|
+
<div class="notice hidden" id="secretNotice"></div>
|
|
167
|
+
</section>
|
|
168
|
+
|
|
169
|
+
<footer>🏠 Local only · 127.0.0.1 · nothing on this page is sent anywhere · it stops by itself when you close the tab</footer>
|
|
170
|
+
</main>
|
|
171
|
+
<script>
|
|
172
|
+
(() => {
|
|
173
|
+
const TOKEN = ${JSON.stringify(token)};
|
|
174
|
+
const q = (id) => document.getElementById(id);
|
|
175
|
+
const api = (p, body) => fetch(p + (p.includes('?') ? '&' : '?') + 't=' + TOKEN, body === undefined
|
|
176
|
+
? { headers: { 'x-iaqa-token': TOKEN } }
|
|
177
|
+
: { method: 'POST', headers: { 'content-type': 'application/json', 'x-iaqa-token': TOKEN }, body: JSON.stringify(body) });
|
|
178
|
+
const el = (tag, text, cls) => { const e = document.createElement(tag); if (text != null) e.textContent = String(text); if (cls) e.className = cls; return e; };
|
|
179
|
+
const show = (id, on) => q(id).classList.toggle('hidden', !on);
|
|
180
|
+
const note = (id, text, tone) => { const n = q(id); n.textContent = text; n.className = 'notice' + (tone ? ' ' + tone : ''); n.classList.toggle('hidden', !text); };
|
|
181
|
+
const ago = (iso) => { const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000)); return s < 60 ? s + 's ago' : Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's ago'; };
|
|
182
|
+
const span = (ms) => { const s = Math.round(ms / 1000); return s < 60 ? s + 's' : Math.floor(s / 60) + 'm' + String(s % 60).padStart(2, '0') + 's'; };
|
|
183
|
+
const MOOD = { clean: ['ok', '✅', 'Clean'], look: ['warn', '👀', 'Worth a look'], broken: ['bad', '🔥', 'Broken'], baseline: ['info', '📸', 'Baseline established'], 'no-answer': ['warn', '🤷', 'Could not answer'] };
|
|
184
|
+
const hue = (name) => { let h = 0; for (const c of name) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
|
|
185
|
+
const avatar = (name) => { const a = el('span', name.slice(0, 1).toUpperCase(), 'avatar'); a.style.background = 'linear-gradient(135deg,hsl(' + hue(name) + ' 80% 55%),hsl(' + ((hue(name) + 50) % 360) + ' 75% 50%))'; return a; };
|
|
186
|
+
|
|
187
|
+
const hour = new Date().getHours();
|
|
188
|
+
q('hello').textContent = (hour < 12 ? 'Good morning 👋' : hour < 18 ? 'Good afternoon 👋' : 'Good evening 👋') + ' Your QA pal walks your app and tells you what moved.';
|
|
189
|
+
|
|
190
|
+
let state = null;
|
|
191
|
+
let wasRunning = false;
|
|
192
|
+
|
|
193
|
+
function confetti() {
|
|
194
|
+
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
195
|
+
const box = q('confetti'); const colors = ['#f97316', '#ec4899', '#8b5cf6', '#10b981', '#f59e0b'];
|
|
196
|
+
for (let i = 0; i < 90; i++) {
|
|
197
|
+
const p = el('i'); p.style.left = Math.random() * 100 + 'vw'; p.style.background = colors[i % colors.length];
|
|
198
|
+
p.style.animationDelay = Math.random() * 0.6 + 's'; p.style.animationDuration = 2 + Math.random() * 1.5 + 's';
|
|
199
|
+
box.appendChild(p);
|
|
200
|
+
}
|
|
201
|
+
setTimeout(() => { box.textContent = ''; }, 4500);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function renderPersonas(p) {
|
|
205
|
+
const list = q('personas'); list.textContent = '';
|
|
206
|
+
const people = (p.personas || []);
|
|
207
|
+
if (people.length === 0) list.appendChild(el('li', 'No named users yet — the tour runs with the single session, if there is one.', 'muted'));
|
|
208
|
+
for (const person of people) {
|
|
209
|
+
const li = el('li');
|
|
210
|
+
li.appendChild(avatar(person.name));
|
|
211
|
+
li.appendChild(el('strong', person.name));
|
|
212
|
+
li.appendChild(el('span', person.ready ? 'session saved' : 'never logged in', 'pill ' + (person.ready ? 'ok' : 'warn')));
|
|
213
|
+
const check = el('button', 'Check'); check.onclick = () => checkPersona(person.name, li);
|
|
214
|
+
const relog = el('button', person.ready ? 'Log in again' : 'Log in'); relog.onclick = () => startLogin(person.name);
|
|
215
|
+
li.append(check, relog);
|
|
216
|
+
list.appendChild(li);
|
|
217
|
+
}
|
|
218
|
+
const sel = q('tourPersona'); const keep = sel.value; sel.textContent = '';
|
|
219
|
+
if (people.length > 1) sel.appendChild(el('option', '— choose a user —')).value = '';
|
|
220
|
+
else sel.appendChild(el('option', people.length === 1 ? people[0].name : 'the single session')).value = people.length === 1 ? people[0].name : '';
|
|
221
|
+
if (people.length > 1) for (const person of people) sel.appendChild(el('option', person.name)).value = person.name;
|
|
222
|
+
if ([...sel.options].some((o) => o.value === keep)) sel.value = keep;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renderRun(run) {
|
|
226
|
+
const running = run && run.status === 'running';
|
|
227
|
+
show('nowIdle', !running); show('nowRun', running);
|
|
228
|
+
q('liveDot').className = 'dot' + (running ? ' live' : ''); q('liveWord').textContent = running ? 'Tour running' : 'Idle';
|
|
229
|
+
q('startTour').disabled = !!running || !(state && state.project.setUp);
|
|
230
|
+
if (!running) { if (run && run.status === 'interrupted') q('nowIdleText').textContent = 'The last tour stopped before finishing (interrupted).'; return; }
|
|
231
|
+
const p = run.progress || {};
|
|
232
|
+
q('phase').textContent = p.phase || 'starting';
|
|
233
|
+
q('count').textContent = p.total ? (p.done || 0) + ' of ' + p.total + ' pages' : '';
|
|
234
|
+
q('barFill').style.width = p.total ? Math.round(((p.done || 0) / p.total) * 100) + '%' : '8%';
|
|
235
|
+
q('page').textContent = p.page ? '🚶 On ' + p.page : '';
|
|
236
|
+
show('lastLine', !!p.line); q('lastLine').textContent = p.line || '';
|
|
237
|
+
const quiet = run.updatedAt ? (Date.now() - Date.parse(run.updatedAt)) / 1000 : 0;
|
|
238
|
+
q('beat').textContent = 'Running ' + span(Date.now() - Date.parse(run.startedAt)) + ' · last heartbeat ' + ago(run.updatedAt) + (quiet > 90 ? ' — nothing heard for a while; it may be stuck on one long step' : '');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function stat(n, label, tone) { const s = el('div', null, 'stat ' + (tone || '')); s.appendChild(el('b', n)); s.appendChild(el('span', label)); return s; }
|
|
242
|
+
|
|
243
|
+
function renderResult(s) {
|
|
244
|
+
show('resultEmpty', !s); show('result', !!s);
|
|
245
|
+
if (!s) return;
|
|
246
|
+
const [tone, icon, word] = MOOD[s.mood] || ['info', '•', s.mood];
|
|
247
|
+
q('mood').className = 'mood ' + tone; q('mood').textContent = icon + ' ' + word;
|
|
248
|
+
q('resultHead').textContent = (s.refused ? 'Refused: ' + s.refused.split('\\n')[0] : s.mode + ' tour') + (s.persona ? ' · as ' + s.persona : '');
|
|
249
|
+
const box = q('stats'); box.textContent = '';
|
|
250
|
+
box.appendChild(stat(s.underContract + ' of ' + s.configured, 'pages under contract', 'info'));
|
|
251
|
+
const t = s.totals;
|
|
252
|
+
if (t) { box.appendChild(stat(t.ok, 'ok', 'ok')); box.appendChild(stat(t.lost, 'lost', t.lost ? 'bad' : '')); box.appendChild(stat(t.ambiguous, 'ambiguous', t.ambiguous ? 'warn' : '')); box.appendChild(stat(t.rebound, 'rebound', t.rebound ? 'bad' : ''));
|
|
253
|
+
// A row the verdict does not fail is still a row: a relabel that passed is shown, never folded into "ok".
|
|
254
|
+
for (const [k, tone] of [['renamed', 'info'], ['healable', 'warn'], ['added', '']]) if (t[k]) box.appendChild(stat(t[k], k, tone)); }
|
|
255
|
+
if (s.notMapped) box.appendChild(stat(s.notMapped, 'not mapped', 'warn'));
|
|
256
|
+
q('resultCounts').textContent = '⏱ took ' + span(s.durationMs);
|
|
257
|
+
q('reportLink').href = '/report?t=' + TOKEN;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderHistory(h) {
|
|
261
|
+
const list = q('history'); list.textContent = ''; const strip = q('strip'); strip.textContent = '';
|
|
262
|
+
if (!h.length) { list.appendChild(el('li', 'Nothing yet — every finished tour lands here.', 'muted')); return; }
|
|
263
|
+
for (const e of h) { const [tone, icon, word] = MOOD[e.mood] || ['info', '•', e.mood]; const d = el('i', null, tone); d.title = icon + ' ' + word + ' · ' + new Date(e.at).toLocaleString(); strip.appendChild(d); }
|
|
264
|
+
for (const e of h.slice().reverse().slice(0, 8)) {
|
|
265
|
+
const [tone, icon, word] = MOOD[e.mood] || ['info', '•', e.mood];
|
|
266
|
+
const li = el('li');
|
|
267
|
+
li.appendChild(el('span', icon + ' ' + word, 'pill ' + tone));
|
|
268
|
+
li.appendChild(el('span', new Date(e.at).toLocaleString()));
|
|
269
|
+
li.appendChild(el('span', e.underContract + ' of ' + e.configured + ' pages' + (e.persona ? ' · as ' + e.persona : ''), 'muted'));
|
|
270
|
+
list.appendChild(li);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function renderLogin(job) {
|
|
275
|
+
if (!job) { note('loginBox', ''); return; }
|
|
276
|
+
if (job.status === 'waiting') {
|
|
277
|
+
const box = q('loginBox'); box.className = 'notice'; box.textContent = '🪟 A browser window opened for ' + job.persona + '. Log in there — it is saved by itself the moment the app opens. ';
|
|
278
|
+
const done = el('button', 'I have logged in'); done.onclick = () => api('/api/persona/login/done', {});
|
|
279
|
+
box.appendChild(done);
|
|
280
|
+
} else note('loginBox', job.message || '', job.status === 'done' ? 'ok' : 'bad');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function refresh() {
|
|
284
|
+
try {
|
|
285
|
+
state = await (await api('/api/state')).json();
|
|
286
|
+
const p = state.project;
|
|
287
|
+
q('where').textContent = p.setUp ? '🌐 ' + p.baseUrl + ' · ' + p.pages + ' pages' + (p.baseline ? '' : ' · no baseline yet') : 'This folder is not set up yet — run ia-qa-pal setup here.';
|
|
288
|
+
if (p.setUp) renderPersonas(p);
|
|
289
|
+
const running = !!(state.run && state.run.status === 'running');
|
|
290
|
+
renderRun(state.run); renderResult(state.summary); renderHistory(state.history || []); renderLogin(state.login);
|
|
291
|
+
// Only a tour that compared something and came back clean. A baseline measured nothing.
|
|
292
|
+
if (wasRunning && !running && state.summary && state.summary.mood === 'clean') confetti();
|
|
293
|
+
wasRunning = running;
|
|
294
|
+
} catch { q('where').textContent = 'The console stopped — start it again with ia-qa-pal ui.'; }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function checkPersona(name, li) {
|
|
298
|
+
const r = await (await api('/api/persona/check', { persona: name })).json();
|
|
299
|
+
const old = li.querySelector('.verdict'); if (old) old.remove();
|
|
300
|
+
li.appendChild(el('span', (r.holds ? '✅ ' : '⚠️ ') + r.headline, 'verdict ' + (r.holds ? 'muted' : '')));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function startLogin(name) {
|
|
304
|
+
const r = await api('/api/persona/login', { persona: name });
|
|
305
|
+
if (!r.ok) note('loginBox', (await r.json()).reason, 'bad'); else refresh();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
q('addPersona').onclick = () => { const n = q('newPersona').value.trim(); if (n) { startLogin(n); q('newPersona').value = ''; } };
|
|
309
|
+
|
|
310
|
+
q('startTour').onclick = async () => {
|
|
311
|
+
note('tourNotice', '');
|
|
312
|
+
const body = { persona: q('tourPersona').value || undefined, suite: q('optSuite').checked, strict: q('optStrict').checked };
|
|
313
|
+
const r = await api('/api/tour', body); const out = await r.json();
|
|
314
|
+
if (out.started) note('tourNotice', '🚀 Started. Follow it on the right — you can close this tab, the tour keeps going.', 'ok');
|
|
315
|
+
else note('tourNotice', out.reason || 'Not started.', out.ask === 'persona' ? '' : 'bad');
|
|
316
|
+
refresh();
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
q('checkSecrets').onclick = async () => {
|
|
320
|
+
const r = await (await api('/api/secrets/check', {})).json();
|
|
321
|
+
const list = q('secrets'); list.textContent = '';
|
|
322
|
+
for (const c of r.checks) {
|
|
323
|
+
const li = el('li'); li.appendChild(el('strong', c.name)); li.appendChild(el('span', c.source, 'muted'));
|
|
324
|
+
li.appendChild(el('span', c.resolves === null ? 'not checked' : c.resolves ? (c.from === 'store' ? 'resolves · stored on this machine' : 'resolves') : 'missing', 'pill ' + (c.resolves === null ? 'info' : c.resolves ? 'ok' : 'bad')));
|
|
325
|
+
if (c.why) li.appendChild(el('span', c.why, 'muted'));
|
|
326
|
+
list.appendChild(li);
|
|
327
|
+
}
|
|
328
|
+
if (!r.checks.length) list.appendChild(el('li', 'No secret is referenced in .ia-qa/config.json.', 'muted'));
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
q('storeSecret').onclick = async () => {
|
|
332
|
+
const name = q('secretName').value.trim(); const value = q('secretValue').value;
|
|
333
|
+
// The field is emptied before the answer arrives: the value lives in this page only for the one request.
|
|
334
|
+
q('secretValue').value = '';
|
|
335
|
+
const out = await (await api('/api/secrets/set', { name, value })).json();
|
|
336
|
+
note('secretNotice', out.stored ? '🔒 "' + out.name + '" stored on this machine. A reference to that name now resolves, unless your shell sets the variable itself.' : (out.reason || 'Not stored.'), out.stored ? 'ok' : 'bad');
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
(async () => {
|
|
340
|
+
const s = await (await api('/api/secrets')).json();
|
|
341
|
+
q('setCmd').textContent = s.set; q('store').textContent = s.store;
|
|
342
|
+
const list = q('secrets');
|
|
343
|
+
show('storeForm', s.canStore); show('noStore', !s.canStore);
|
|
344
|
+
for (const ref of s.references) {
|
|
345
|
+
const li = el('li'); li.appendChild(el('strong', ref.name)); li.appendChild(el('span', ref.source + ' · ' + ref.key, 'muted'));
|
|
346
|
+
if (s.canStore && (ref.source === 'env' || ref.source === 'keychain')) {
|
|
347
|
+
const b = el('button', 'Set a value'); b.onclick = () => { q('secretName').value = ref.key; q('secretValue').focus(); }; li.appendChild(b);
|
|
348
|
+
}
|
|
349
|
+
list.appendChild(li);
|
|
350
|
+
}
|
|
351
|
+
if (!s.references.length) list.appendChild(el('li', 'No secret is referenced in .ia-qa/config.json.', 'muted'));
|
|
352
|
+
})();
|
|
353
|
+
|
|
354
|
+
refresh();
|
|
355
|
+
setInterval(refresh, 2000);
|
|
356
|
+
setInterval(() => api('/api/ping').catch(() => {}), 15000);
|
|
357
|
+
addEventListener('pagehide', () => navigator.sendBeacon && navigator.sendBeacon('/api/bye?t=' + TOKEN));
|
|
358
|
+
})();
|
|
359
|
+
</script>
|
|
360
|
+
</body></html>`;
|
|
361
|
+
}
|
|
362
|
+
//# sourceMappingURL=page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"page.js","sourceRoot":"","sources":["../../src/ui/page.ts"],"names":[],"mappings":";;AAcA,8CAuVC;AArWD;;;;;;;;;;;;;GAaG;AACH,SAAgB,iBAAiB,CAAC,KAAa;IAC7C,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;kBA0JS,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eA2LxB,CAAC;AAChB,CAAC"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { probeSession, performLogin, storeKind, writeSecret, readSecret, type SecureServer } from '@ia-qa/self-healing';
|
|
2
|
+
/**
|
|
3
|
+
* `ia-qa-pal ui` — launch a tour, follow it live, see what it found, manage who it runs as.
|
|
4
|
+
*
|
|
5
|
+
* The terminal is where a tour used to live, and that is where it broke for the people the
|
|
6
|
+
* package is for: a run that took fifteen minutes and said nothing, a login that stopped on a
|
|
7
|
+
* question, a report that ended on a command to copy. This console is the same four verbs —
|
|
8
|
+
* tour, status, report, login — for somebody who does not live in a terminal.
|
|
9
|
+
*
|
|
10
|
+
* **It has no authority of its own.** Every rule that makes a local console safe — loopback
|
|
11
|
+
* only, a per-process token, Host pinning, the CSP, the idle watchdog — comes from
|
|
12
|
+
* `startSecureServer`, the same function `ia-qa-heal ui` uses, so the two cannot drift apart
|
|
13
|
+
* on the part that must not. A tour is started by `startBackground` with a fixed argv, exactly
|
|
14
|
+
* as `ia-qa-pal tour --background` and the MCP start one: nothing from a request reaches a
|
|
15
|
+
* command line except a persona name, and that is held to the filename rule first.
|
|
16
|
+
*
|
|
17
|
+
* **Three things it deliberately does not do**, each a rule inherited rather than invented:
|
|
18
|
+
* - It never SHOWS a secret value, and never writes one anywhere but this machine's OS
|
|
19
|
+
* credential store. A value can be typed in once, to be stored — the same act as
|
|
20
|
+
* `ia-qa-heal secret set <NAME>` — and the answer is "stored" or why not: never the value,
|
|
21
|
+
* never its length, never logged. `.ia-qa/config.json` is never edited: an `env`
|
|
22
|
+
* reference is answered by a stored value of the same name when the shell leaves it unset
|
|
23
|
+
* (heal's `resolveSecret`), so CI, which sets the variable, is unchanged.
|
|
24
|
+
* - It never checks an `aws-ssm` secret by itself: that is a call to AWS, and a flow that
|
|
25
|
+
* leaves the machine is announced and asked for, never made on page load.
|
|
26
|
+
* - It never picks a user. With several personas and none chosen, a tour is refused and the
|
|
27
|
+
* page asks — the same door `map`, the tour and the MCP use.
|
|
28
|
+
*/
|
|
29
|
+
export interface PalUiOptions {
|
|
30
|
+
dir: string;
|
|
31
|
+
port?: number;
|
|
32
|
+
keepAlive?: boolean;
|
|
33
|
+
/** The pal CLI a background tour is spawned from. Default: this package's own. */
|
|
34
|
+
cliEntry?: string;
|
|
35
|
+
/** Injected so tests exercise the routes without a browser. */
|
|
36
|
+
login?: typeof performLogin;
|
|
37
|
+
probe?: typeof probeSession;
|
|
38
|
+
/** Injected so tests never write to the real credential store. */
|
|
39
|
+
store?: {
|
|
40
|
+
write: typeof writeSecret;
|
|
41
|
+
read: typeof readSecret;
|
|
42
|
+
kind: typeof storeKind;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
interface LoginJob {
|
|
46
|
+
persona: string;
|
|
47
|
+
status: 'waiting' | 'done' | 'failed';
|
|
48
|
+
message?: string;
|
|
49
|
+
startedAt: string;
|
|
50
|
+
confirm: () => void;
|
|
51
|
+
}
|
|
52
|
+
/** What the page polls. Never the report itself — it can be large, and the page asks for it once. */
|
|
53
|
+
export declare function consoleState(dir: string, job: LoginJob | null): Record<string, unknown>;
|
|
54
|
+
/**
|
|
55
|
+
* Secrets, by name, with where each one lives — and nothing that could carry a value.
|
|
56
|
+
*
|
|
57
|
+
* The config's references (`secrets` in `.ia-qa/config.json`) plus the names in this machine's
|
|
58
|
+
* credential store. Both are names already: this function has nothing to leak even by mistake.
|
|
59
|
+
*/
|
|
60
|
+
export declare function consoleSecrets(dir: string): Record<string, unknown>;
|
|
61
|
+
/**
|
|
62
|
+
* Does each referenced secret resolve? Yes, no, or deliberately not checked — never the value.
|
|
63
|
+
*
|
|
64
|
+
* `env` is presence in this console's own environment, which is the one a tour it starts
|
|
65
|
+
* inherits. `keychain` is a lookup in this machine's store, answered as a boolean. `aws-ssm`
|
|
66
|
+
* is **not checked**: resolving it is a call to AWS, and this package announces and asks for
|
|
67
|
+
* anything that leaves the machine instead of doing it because a page loaded.
|
|
68
|
+
*/
|
|
69
|
+
export declare function checkSecrets(dir: string): Array<{
|
|
70
|
+
name: string;
|
|
71
|
+
source: string;
|
|
72
|
+
resolves: boolean | null;
|
|
73
|
+
from?: string;
|
|
74
|
+
why?: string;
|
|
75
|
+
}>;
|
|
76
|
+
/** A name a store entry may carry: the shape of an environment variable, so one name serves both. */
|
|
77
|
+
export declare const SECRET_NAME: RegExp;
|
|
78
|
+
export type SetSecretAnswer = {
|
|
79
|
+
status: number;
|
|
80
|
+
body: {
|
|
81
|
+
stored: boolean;
|
|
82
|
+
name?: string;
|
|
83
|
+
reason?: string;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Store one value in this machine's credential store — and answer without it.
|
|
88
|
+
*
|
|
89
|
+
* Every answer is built from the NAME and a fixed reason; the value is never interpolated into
|
|
90
|
+
* one, never returned, never measured back. The write is read back before "stored" is said,
|
|
91
|
+
* the same rule `ia-qa-heal secret set` follows: a store that accepted and returns something
|
|
92
|
+
* else is a failure, not a success.
|
|
93
|
+
*/
|
|
94
|
+
export declare function setSecret(body: Record<string, unknown>, store: {
|
|
95
|
+
write: typeof writeSecret;
|
|
96
|
+
read: typeof readSecret;
|
|
97
|
+
kind: typeof storeKind;
|
|
98
|
+
}): SetSecretAnswer;
|
|
99
|
+
export declare function startPalUi(opts: PalUiOptions): Promise<SecureServer>;
|
|
100
|
+
export {};
|