Tests réels : le registre des tests hors CI par application (tenant de test, Cloudflare Access et Workers, Power BI Desktop / SSAS / Docker en local…

Now shown inside Registres (tab Tests réels, /registres tests); /tests keeps working (this mod while loaded, else Registres' alias).
The registry of real (non-CI) tests per application: live runs on a test tenant, Cloudflare Access / Workers checks, Power BI Desktop / SSAS / Docker runs on this PC, UI automation, manual checks. So the team remembers what was tested, how, in which test environment and with which test account, and never loses it again.
Now also a tab of Registres: /registres tests (or /tests when this mod is not loaded) shows the same registry in the Registres pane, drawn by a copy of hooks/view.mjs (the pane's pure drawing; hooks/register.tsx only reads the files and hands them over). Same masking rules there. This mod keeps working on its own while it is loaded.
/tests opens the pane (/tests close closes it). French labels, top to bottom, every block folded to a one-line summary:
required_ids per kind), and secrets seen in clear in the history (« mot de passe vu dans la session « … » le … : à ranger dans le Gestionnaire d'identification »), with how to store them.secret_ref: { store: "wincred", target: "<generic credential address>" } (Windows Credential Manager), or password-manager, env (a variable name), other. The CLI refuses --password, --token, --secret, --key, --cookie and any value that reads like one (exit 2).~/.claude/tests-reels/ (outside every repo). This repository ships code and invented fixtures only.| File | Written by | What |
|---|---|---|
~/.claude/tests-reels/registry.json | cli.mjs add … | apps → environments → test accounts (references) → procedures → manual checks |
~/.claude/tests-reels/runs.jsonl | cli.mjs run-log … | one line per real test run (append-only, safe for parallel agents) |
~/.claude/tests-reels/recovered.json | scanner/recover.mjs | what the history says (rewritten on each scan) and recover-cache.json (per-file cache) |
~/.claude/tests-reels/config.json | you | deep-merged over tests-reels.config.json: source paths, aliases, labels |
TESTS_REELS_HOME or --data-dir moves the folder (tests use a temporary one).
cli.mjs run-logSo that rules can require it, every agent that runs a real test appends one line:
node mods/tests-reels/cli.mjs run-log --app "<appli>" --env "<env>" --procedure "<nom>" --result pass|fail|partial \
--evidence reports/<preuve>.md [--note "<une ligne>"] [--session "<titre ou id>"] [--by "<agent>"]
Declare things once with add:
node mods/tests-reels/cli.mjs add app --name "<appli>" --repo "<dépôt>"
node mods/tests-reels/cli.mjs add env --app "<appli>" --name "TEST tenant" --kind tenant --id tenant=<guid> --id workspace=<guid> --url <url> --owner Julian
node mods/tests-reels/cli.mjs add account --app "<appli>" --env "TEST tenant" --login <compte de test> --secret-store wincred --secret-target <adresse>
node mods/tests-reels/cli.mjs add procedure --app "<appli>" --name "Lecture live" --env "TEST tenant" --kind live --step "…" --command "…" --expected "…" --evidence reports/x.md
node mods/tests-reels/cli.mjs add check --app "<appli>" --what "…" --how "…"
node mods/tests-reels/cli.mjs show [--app "<appli>"] # counts and masked logins
node mods/tests-reels/cli.mjs recover [--force] # the recovery scan
Store a secret in the Windows Credential Manager: Control Panel › Credential Manager › Windows Credentials › Add a generic credential (the address is the --secret-target).
node mods/tests-reels/scanner/recover.mjs [--force] (Node 22+, standard library), or the pane button. Sources come from recovery.sources (set your paths in ~/.claude/tests-reels/config.json; the shipped repo_roots is a placeholder):
~/.claude/projects/**/*.jsonl (main sessions and subagents; app = the session's repo, a worktree counts as its repo; app_aliases reassigns by text),manual-checks.md lines date · project · what · how (struck lines keep their tail as status, DONE = fait),handoff/*.md and reports/* under repo_roots,extra_roots (e.g. an audit bundle): .jsonl read as transcripts, documents line by line.It keeps, per app and per category (categories: Fabric / Power BI service, Entra tenant, Cloudflare, Power BI Desktop / SSAS, Docker, UI automation): sessions with their title, commands that match command_pattern (with errors from is_error tool results), GUIDs next to a context word (id_context), URLs on url_hosts, logins next to a test word (account_context, minus ignore_accounts), workspace names, short passages with a real-test marker (real_markers). A file whose size and date did not change is read from the cache. It prints counts only.
Non vérifié: the transcript format (not documented; the fields used are cwd, sessionId, timestamp, message.content, custom-title, ai-title, tool_use, tool_result.is_error); secret detection is heuristic (strong formats such as JWT, GitHub / GitLab / Anthropic tokens, private keys, plus password=…-style pairs whose value looks random), so a secret in another shape can be missed.
node --test mods/tests-reels/tests/logic.test.mjs
claude plugin test mods/tests-reels
claude plugin validate mods/tests-reelshooks/register.tsx 130 lines1// tests-reels mod (« Tests réels »): read-only registry of real (non-CI) tests per application. /tests [refresh|close]
2// opens the pane. Data: ~/.claude/tests-reels (registry.json written by cli.mjs, runs.jsonl appended by agents after a
3// real test, recovered.json written by scanner/recover.mjs). The pane only reads them; the « Lancer la récupération »
4// button runs the scanner, which writes only into that folder. No secret value is ever stored or shown: accounts show
5// a masked login and where the secret lives. The command returns no text, so nothing reaches the model; nothing here
6// blocks, rewrites or delays a tool call or a prompt. The drawing is hooks/view.mjs (pure, also drawn by the registres
7// mod's « Tests réels » tab).
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import { buildView, deepMerge, fill, parseRuns } from '../lib/registry.mjs'
12import { drawTestsReels } from './view.mjs'
13
14const PANE = 'tests-reels'
15const openAtom = atom({ plugin: 'tests-reels', key: 'open' } as const, null as unknown as string[])
16const revealAtom = atom({ plugin: 'tests-reels', key: 'reveal' } as const, [] as string[])
17const statusAtom = atom({ plugin: 'tests-reels', key: 'status' } as const, '')
18
19const S: { busy: boolean; error: string; view: any; at: number } = { busy: false, error: '', view: undefined, at: 0 }
20
21const home = async ($: EngineInterface) => {
22 let v: string | undefined
23 try {
24 v = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
25 } catch {}
26 return (v ?? '').replace(/\\/g, '/')
27}
28const readText = async ($: EngineInterface, path: string): Promise<string | undefined> => {
29 try {
30 return (await $.fs.read(path, { as: 'text' })) as string
31 } catch {
32 return undefined
33 }
34}
35const readJson = async ($: EngineInterface, path: string): Promise<any> => {
36 const t = await readText($, path)
37 try {
38 return t ? JSON.parse(t) : undefined
39 } catch {
40 return undefined
41 }
42}
43
44async function context($: EngineInterface) {
45 const root = $.plugin.root.replace(/\\/g, '/')
46 const h = await home($)
47 const base = (await readJson($, `${root}/tests-reels.config.json`)) || {}
48 const dataDir = String(base.data_dir || '~/.claude/tests-reels').replace(/^~(?=[\\/]|$)/, h).replace(/\\/g, '/')
49 const localText = await readText($, `${dataDir}/config.json`)
50 const local = await readJson($, `${dataDir}/config.json`)
51 const cfg = deepMerge(base, local || {})
52 cfg._local_error = localText && !local ? `${dataDir}/config.json illisible (JSON invalide)` : ''
53 return { root, dataDir, cfg }
54}
55
56async function load($: EngineInterface) {
57 const { cfg, dataDir, root } = await context($)
58 if (!S.view) {
59 const registry = (await readJson($, `${dataDir}/registry.json`)) || { apps: [] }
60 const runs = parseRuns((await readText($, `${dataDir}/runs.jsonl`)) || '')
61 const recovered = (await readJson($, `${dataDir}/recovered.json`)) || null
62 S.view = buildView(registry, runs, recovered, cfg)
63 }
64 return { cfg, dataDir, root, view: S.view, now: await $.clock.now() }
65}
66
67const reload = ($: EngineInterface) => {
68 S.view = undefined
69 $.ui.invalidate('ui.render')
70}
71
72/** Runs the recovery scanner in the background (only on a button press); writes only into the data folder. */
73async function recover($: EngineInterface) {
74 if (S.busy) return
75 S.busy = true
76 $.ui.invalidate('ui.render')
77 try {
78 const { root, cfg } = await context($)
79 const r = await $.process.run([cfg.node || 'node', `${root}/scanner/recover.mjs`], { timeoutMs: Math.min((cfg.scan_timeout_s ?? 600) * 1000, 600000) })
80 if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().split('\n').pop() || `scanner exit ${r.exitCode}`)
81 const out = JSON.parse(String(r.stdout || '{}').trim().split('\n').pop() || '{}')
82 S.error = ''
83 await update($, statusAtom, () => fill(cfg.labels?.recover_done ?? '', out.counts ?? {}))
84 } catch (err) {
85 S.error = String((err as Error)?.message ?? err).slice(0, 300)
86 } finally {
87 S.busy = false
88 }
89 reload($)
90}
91
92export const register: Register = on => {
93 on('session.start', async ($, e, next) => {
94 await $.command.register({
95 name: 'tests',
96 description: 'Tests réels : registre des tests hors CI par appli (environnements, comptes de test masqués, procédures, derniers essais, À ranger), lecture seule',
97 argumentHint: '[refresh] | close',
98 })
99 return next(e)
100 })
101
102 on('command.run', { command: 'tests' }, async ($, e) => {
103 const args = String(e.args ?? '').trim().toLowerCase()
104 if (args === 'close') {
105 await $.ui.close({ id: PANE })
106 return {}
107 }
108 S.view = undefined
109 const { cfg } = await context($)
110 await $.ui.open({ id: PANE, title: cfg.labels?.pane_title ?? 'Tests réels' })
111 return {} // a pane, no transcript text: nothing for the model to read
112 })
113
114 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
115 const { cfg, view, now } = await load($)
116 const openList = ((await read($, openAtom)) as string[] | null) ?? (cfg.open ?? ['todo'])
117 const reveal = (await read($, revealAtom)) as string[]
118 const status = (await read($, statusAtom)) as string
119 return drawTestsReels($.ui.resolve(e), { cfg, view, now, openList, reveal, status, busy: S.busy, error: S.error }, {
120 toggle: (id: string) => update($, openAtom, cur => {
121 const list = (cur as string[] | null) ?? (cfg.open ?? ['todo'])
122 return list.includes(id) ? list.filter(x => x !== id) : [...list, id]
123 }),
124 reveal: (rk: string) => update($, revealAtom, l => ((l as string[]).includes(rk) ? (l as string[]).filter(x => x !== rk) : [...(l as string[]), rk])),
125 reload: () => reload($),
126 recover: () => { $.clock.after(0, () => recover($)) },
127 }) as any
128 })
129}
130lib/registry.mjs 245 lines1// Registry logic of the tests-reels mod (« Tests réels »). Pure functions, no I/O: the pane, the CLI and the tests
2// share them. Local data only (~/.claude/tests-reels): registry.json (curated), runs.jsonl (runs appended by agents),
3// recovered.json (what the recovery scan found). Nothing here stores or returns a secret value.
4
5export function deepMerge(a, b) {
6 if (Array.isArray(b)) return b.slice()
7 if (!b || typeof b !== 'object') return b === undefined ? a : b
8 const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
9 for (const [k, v] of Object.entries(b)) out[k] = deepMerge(out[k], v)
10 return out
11}
12
13/** `{name}` placeholders filled from `vars`; unknown ones kept. */
14export function fill(s, vars = {}) {
15 return String(s ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined ? m : String(vars[k])))
16}
17
18/** `~`, `%VAR%` and `$VAR` expanded; forward slashes. */
19export function expand(p, env = {}) {
20 let s = String(p || '')
21 s = s.replace(/^~(?=[\\/]|$)/, env.HOME || env.USERPROFILE || '')
22 s = s.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (m, k) => (env[k] ?? m))
23 s = s.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (m, k) => (env[k] ?? m))
24 return s.replace(/\\/g, '/')
25}
26
27export const unset = v => v == null || v === '' || /^<.*>$/.test(String(v))
28
29export function fmtAge(then, now) {
30 if (!then) return '—'
31 const t = typeof then === 'number' ? then : Date.parse(then)
32 if (!Number.isFinite(t)) return '—'
33 const m = Math.max(0, (now - t) / 60000)
34 if (m < 60) return `${Math.round(m)} min`
35 if (m < 48 * 60) return `${Math.round(m / 60)} h`
36 return `${Math.round(m / 1440)} j`
37}
38
39/** A login shown masked: `te•••@co•••` (e-mail) or `te•••`. */
40export function mask(login, keep = 2) {
41 const s = String(login || '')
42 if (!s) return ''
43 const part = x => (x.length <= keep ? '•'.repeat(Math.max(1, x.length)) : x.slice(0, keep) + '•••')
44 const at = s.indexOf('@')
45 return at > 0 ? `${part(s.slice(0, at))}@${part(s.slice(at + 1))}` : part(s)
46}
47
48/** An id shown short: first 8 characters then … */
49export const shortId = (v, n = 8) => (String(v || '').length > n + 1 ? String(v).slice(0, n) + '…' : String(v || ''))
50
51export const norm = s => String(s || '').trim().toLowerCase().replace(/[\s_-]+/g, '-')
52
53/** Lines of runs.jsonl parsed; bad lines skipped. */
54export function parseRuns(text) {
55 const out = []
56 for (const line of String(text || '').split(/\r?\n/)) {
57 if (!line.trim()) continue
58 try {
59 const r = JSON.parse(line)
60 if (r && r.app) out.push(r)
61 } catch {}
62 }
63 return out
64}
65
66const RESULTS = ['pass', 'fail', 'partial', 'ran']
67export const resultOf = r => (RESULTS.includes(r?.result) ? r.result : 'ran')
68const dateOf = r => String(r?.date || r?.last || '')
69const byDateDesc = (a, b) => dateOf(b).localeCompare(dateOf(a))
70
71/** One app in the view: curated entry merged with its runs and what recovery found. */
72function appView(name, cur, runs, rec, cfg) {
73 const envs = (cur?.environments ?? []).map(e => ({ ...e, ids: e.ids ?? {}, urls: e.urls ?? [] }))
74 const accounts = (cur?.accounts ?? []).map(a => ({ ...a }))
75 const appRuns = runs.filter(r => norm(r.app) === norm(name)).sort(byDateDesc)
76 const procs = (cur?.procedures ?? []).map(p => {
77 const own = [...(p.runs ?? []), ...appRuns.filter(r => norm(r.procedure) === norm(p.name))].sort(byDateDesc)
78 const last = own[0]
79 return { ...p, runs: own, last, status: last ? resultOf(last) : 'never' }
80 })
81 const known = new Set(procs.map(p => norm(p.name)))
82 const orphanRuns = appRuns.filter(r => !known.has(norm(r.procedure)))
83 for (const r of orphanRuns) {
84 let p = procs.find(x => x.undeclared && norm(x.name) === norm(r.procedure || '—'))
85 if (!p) procs.push((p = { name: r.procedure || '—', env: r.env, undeclared: true, runs: [], status: 'never' }))
86 p.runs.push(r)
87 p.last = p.runs[0]
88 p.status = resultOf(p.last)
89 }
90 const recovered = rec ?? null
91 const recCommands = (recovered?.commands ?? []).slice().sort(byDateDesc)
92 const lastRuns = [...appRuns].slice(0, cfg.max_runs ?? 8)
93 const lastReal = appRuns[0] || null
94 const todo = []
95 for (const a of accounts) {
96 if (!a.secret_ref || unset(a.secret_ref.target)) todo.push({ kind: 'secret_unknown', app: name, what: a.display || a.login || a.id || '?', env: a.env })
97 }
98 for (const e of envs) {
99 const need = cfg.required_ids?.[e.kind] ?? []
100 const missing = need.filter(k => unset(e.ids?.[k]) && !(k === 'url' && e.urls?.length))
101 if (missing.length) todo.push({ kind: 'id_missing', app: name, what: `${e.name} : ${missing.join(', ')}`, env: e.name })
102 }
103 for (const s of recovered?.secrets ?? []) todo.push({ kind: 'secret_seen', app: name, what: s.kind, title: s.title, date: s.date, count: s.count ?? 1 })
104 const neverReal = !lastReal && procs.every(p => p.status === 'never') && !recCommands.length
105 return {
106 name,
107 repo: cur?.repo,
108 notes: cur?.notes,
109 curated: !!cur,
110 environments: envs,
111 accounts,
112 procedures: procs,
113 checks: [...(cur?.manual_checks ?? []), ...(recovered?.manual ?? [])].sort(byDateDesc),
114 runs: lastRuns,
115 lastRun: lastReal,
116 recovered,
117 recCommands,
118 neverReal,
119 todo,
120 }
121}
122
123/** Summary line of an app, shown when its block is folded. */
124export function appSummary(a, L = {}) {
125 const parts = []
126 parts.push(fill(L.sum_envs ?? '{n} env.', { n: a.environments.length }))
127 parts.push(fill(L.sum_accounts ?? '{n} compte(s)', { n: a.accounts.length }))
128 parts.push(fill(L.sum_procs ?? '{n} procédure(s)', { n: a.procedures.length }))
129 if (a.lastRun) parts.push(fill(L.sum_last ?? 'dernier essai {date} {result}', { date: dateOf(a.lastRun).slice(0, 10), result: L[`result_${resultOf(a.lastRun)}`] ?? resultOf(a.lastRun) }))
130 else if (a.neverReal) parts.push(L.never_real ?? 'jamais testé en réel')
131 const r = a.recovered
132 if (r) parts.push(fill(L.sum_recovered ?? 'historique : {s} session(s), {c} commande(s)', { s: (r.sessions ?? []).length, c: (r.commands ?? []).length }))
133 if (a.todo.length) parts.push(fill(L.sum_todo ?? '{n} à ranger', { n: a.todo.length }))
134 return parts.join(' · ')
135}
136
137/**
138 * The whole view: apps sorted (curated first, then by evidence), totals and the « À ranger » list.
139 * registry: { apps: [...] } · runs: parsed runs.jsonl · recovered: recovered.json ({ apps: { name: {...} } }).
140 */
141export function buildView(registry, runs, recovered, cfg = {}) {
142 const cur = new Map()
143 for (const a of registry?.apps ?? []) if (a?.name) cur.set(norm(a.name), a)
144 const rec = new Map()
145 for (const [name, r] of Object.entries(recovered?.apps ?? {})) rec.set(norm(name), { name, ...r })
146 const names = new Map()
147 for (const a of registry?.apps ?? []) if (a?.name) names.set(norm(a.name), a.name)
148 for (const r of runs ?? []) if (r?.app && !names.has(norm(r.app))) names.set(norm(r.app), r.app)
149 const minHits = cfg.recovery?.min_hits_to_list ?? 1
150 for (const [k, r] of rec) {
151 const hits = (r.sessions ?? []).length + (r.commands ?? []).length + (r.manual ?? []).length + (r.ids ?? []).length
152 if (!names.has(k) && hits >= minHits) names.set(k, r.name)
153 }
154 const apps = [...names].map(([k, name]) => appView(name, cur.get(k), runs ?? [], rec.get(k), cfg))
155 const weight = a => (a.curated ? 1e9 : 0) + (a.lastRun ? 1e6 : 0) + (a.recovered ? (a.recovered.sessions?.length ?? 0) * 10 + (a.recovered.commands?.length ?? 0) : 0)
156 apps.sort((a, b) => weight(b) - weight(a) || a.name.localeCompare(b.name))
157 const todo = apps.flatMap(a => a.todo)
158 // secrets seen in sessions of an app that is not listed (no test evidence) still go to « À ranger »
159 for (const [k, r] of rec) {
160 if (names.has(k)) continue
161 for (const s of r.secrets ?? []) todo.push({ kind: 'secret_seen', app: r.name, what: s.kind, title: s.title, date: s.date, count: s.count ?? 1 })
162 }
163 const rank = { secret_unknown: 0, id_missing: 1, secret_seen: 2 }
164 todo.sort((a, b) => rank[a.kind] - rank[b.kind] || String(b.date || '').localeCompare(String(a.date || '')))
165 const totals = {
166 apps: apps.length,
167 environments: apps.reduce((n, a) => n + a.environments.length, 0),
168 accounts: apps.reduce((n, a) => n + a.accounts.length, 0),
169 procedures: apps.reduce((n, a) => n + a.procedures.length, 0),
170 runs: (runs ?? []).length,
171 never: apps.filter(a => a.neverReal).length,
172 todo: todo.length,
173 secrets_seen: todo.filter(t => t.kind === 'secret_seen').reduce((n, t) => n + (t.count ?? 1), 0),
174 }
175 return { apps, todo, totals, recoveredAt: recovered?.generated ?? null, registryAt: registry?.updated ?? null, recoveryCounts: recovered?.counts ?? null }
176}
177
178/** One « À ranger » line in French. */
179export function todoLine(t, L = {}) {
180 if (t.kind === 'secret_seen') return fill(L.todo_secret_seen ?? '{app} · {what} vu dans la session « {title} » le {date} ({count}×) : à ranger dans le Gestionnaire d\'identification', { ...t, date: String(t.date || '').slice(0, 10) })
181 if (t.kind === 'secret_unknown') return fill(L.todo_secret_unknown ?? '{app} · {what} : secret non enregistré, à ranger dans le Gestionnaire d\'identification Windows', t)
182 return fill(L.todo_id_missing ?? '{app} · id manquant : {what}', t)
183}
184
185// ---- writes (used by cli.mjs): pure transforms of the registry object -------------------------------------------
186
187const ENV_KINDS = ['tenant', 'cloud', 'local', 'docker']
188const PROC_KINDS = ['live', 'local', 'docker', 'ui', 'manual']
189
190export function ensureApp(reg, name, extra = {}) {
191 if (!name) throw new Error('--app manquant')
192 reg.apps ??= []
193 let a = reg.apps.find(x => norm(x.name) === norm(name))
194 if (!a) reg.apps.push((a = { name, environments: [], accounts: [], procedures: [], manual_checks: [] }))
195 for (const [k, v] of Object.entries(extra)) if (v !== undefined) a[k] = v
196 a.environments ??= []
197 a.accounts ??= []
198 a.procedures ??= []
199 a.manual_checks ??= []
200 return a
201}
202
203const upsert = (list, item, key = 'name') => {
204 const i = list.findIndex(x => norm(x[key]) === norm(item[key]))
205 if (i >= 0) list[i] = { ...list[i], ...Object.fromEntries(Object.entries(item).filter(([, v]) => v !== undefined)) }
206 else list.push(item)
207 return list[i >= 0 ? i : list.length - 1]
208}
209
210export function addEnv(reg, o, today) {
211 if (!o.name) throw new Error('--name manquant')
212 if (o.kind && !ENV_KINDS.includes(o.kind)) throw new Error(`--kind : ${ENV_KINDS.join(' | ')}`)
213 const a = ensureApp(reg, o.app)
214 return upsert(a.environments, { name: o.name, kind: o.kind, urls: o.urls, ids: o.ids, owner: o.owner, status: o.status ?? 'actif', notes: o.notes, created: o.created ?? today })
215}
216
217export function addAccount(reg, o) {
218 if (!o.login && !o.display) throw new Error('--login ou --display manquant')
219 const a = ensureApp(reg, o.app)
220 const secret_ref = o.secret_store || o.secret_target ? { store: o.secret_store || 'wincred', target: o.secret_target || '' } : undefined
221 return upsert(a.accounts, { login: o.login, display: o.display, env: o.env, purpose: o.purpose, dedicated: o.dedicated ?? true, secret_ref }, o.login ? 'login' : 'display')
222}
223
224export function addProcedure(reg, o) {
225 if (!o.name) throw new Error('--name manquant')
226 if (o.kind && !PROC_KINDS.includes(o.kind)) throw new Error(`--kind : ${PROC_KINDS.join(' | ')}`)
227 const a = ensureApp(reg, o.app)
228 return upsert(a.procedures, { name: o.name, env: o.env, account: o.account, kind: o.kind, steps: o.steps, commands: o.commands, expected: o.expected, evidence: o.evidence })
229}
230
231export function addCheck(reg, o, today) {
232 if (!o.what) throw new Error('--what manquant')
233 const a = ensureApp(reg, o.app)
234 const c = { date: o.date ?? today, what: o.what, how: o.how, status: o.status ?? 'à faire' }
235 a.manual_checks.push(c)
236 return c
237}
238
239export function runRecord(o, nowIso) {
240 if (!o.app) throw new Error('--app manquant')
241 if (!o.procedure) throw new Error('--procedure manquant')
242 if (!['pass', 'fail', 'partial'].includes(o.result)) throw new Error('--result : pass | fail | partial')
243 return { date: o.date ?? nowIso, app: o.app, env: o.env, procedure: o.procedure, result: o.result, evidence: o.evidence ?? [], note: o.note, session: o.session, by: o.by }
244}
245hooks/view.mjs 121 lines1// tests-reels mod - the « Tests réels » pane's drawing, pure: no `$`. `el` is the element table from $.ui.resolve(e),
2// `M` the facts the hook read, `act` the callbacks it hands in. register.tsx draws /tests with it, and the registres
3// mod draws its « Tests réels » tab with a byte-for-byte copy (lib/core/targets.json): change it here only.
4// Secrets: logins are masked unless revealed one by one; secret values are never in the view (only where they live).
5import { appSummary, fill, fmtAge, mask, resultOf, shortId, todoLine } from '../lib/registry.mjs'
6
7/** h-like factory over the element table: mk(el)('Text', { dimColor: true }, 'x'). */
8export const mk = (el) => (type, props, ...kids) =>
9 el[type]({ ...(props || {}), children: kids.flat(Infinity).filter((k) => k !== null && k !== undefined && k !== false) })
10
11/**
12 * M = { cfg, view, now, openList, reveal, status, busy, error }; act = { toggle(id), reveal(key), reload(), recover() }.
13 * Returns the pane's tree.
14 */
15export function drawTestsReels(el, M, act) {
16 const h = mk(el)
17 const { cfg, view, now, openList, reveal, status } = M
18 const L = cfg.labels ?? {}
19 const C = cfg.colors ?? {}
20 const max = cfg.max_rows ?? 12
21 const isOpen = (id) => openList.includes(id)
22 const k = (s) => s.replace(/[^A-Za-z0-9_-]/g, '_')
23 const col = (r) => (C[r] ? { color: C[r] } : {})
24 const resLabel = (r) => L[`result_${r}`] ?? r
25 const more = (n) => (n > max ? h('Text', { dimColor: true }, fill(L.more, { n: n - max })) : null)
26 const t = view.totals
27 const head = h('Box', { flexDirection: 'column' },
28 h('Text', { dimColor: true }, fill(L.subtitle, { apps: t.apps, reg: view.registryAt ? fmtAge(view.registryAt, now) : L.never_scanned, rec: view.recoveredAt ? fmtAge(view.recoveredAt, now) : L.never_scanned })),
29 h('Text', {}, fill(L.totals, t)),
30 h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
31 h('Button', { key: 'refresh', plain: true, onPress: () => act.reload() }, L.refresh ?? '↻'),
32 h('Button', { key: 'recover', plain: true, onPress: () => act.recover() }, L.recover)),
33 M.busy ? h('Text', { dimColor: true }, L.recovering) : null,
34 M.error ? h('Text', { color: 'error' }, `récupération : ${M.error}`) : null,
35 cfg._local_error ? h('Text', { color: 'error' }, cfg._local_error) : null,
36 status ? h('Text', { dimColor: true }, status) : null)
37 if (!view.apps.length && !view.todo.length) {
38 return h('Box', { flexDirection: 'column', gap: 1 }, head, h('Text', {}, L.empty))
39 }
40
41 const fold = (id, title, summary, body, warn = false) => h('Box', { key: `b-${k(id)}`, flexDirection: 'column' },
42 h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
43 h('Button', { key: `t-${k(id)}`, plain: true, onPress: () => act.toggle(id) }, `${isOpen(id) ? '▾' : '▸'} ${title}`),
44 h('Text', { ...(warn ? col('warn') : { dimColor: true }), wrap: 'truncate' }, summary)),
45 isOpen(id) ? h('Box', { flexDirection: 'column', paddingLeft: 2 }, body()) : null)
46 const part = (title, rows, line) => h('Box', { flexDirection: 'column' },
47 h('Text', { bold: true }, `${title} (${rows.length})`),
48 !rows.length ? h('Text', { dimColor: true }, ` ${L.none}`) : null,
49 rows.slice(0, max).map(line),
50 more(rows.length))
51
52 const todoBlock = fold(
53 'todo',
54 L.todo_title,
55 view.todo.length ? fill(L.todo_summary, { n: view.todo.length, secrets: t.secrets_seen }) : L.todo_none,
56 () => h('Box', { flexDirection: 'column' },
57 view.todo.slice(0, max).map((x, i) => h('Text', { key: `td-${i}`, ...col('todo') }, `● ${todoLine(x, L)}`)),
58 more(view.todo.length),
59 view.todo.length ? h('Text', { dimColor: true, wrap: 'wrap' }, cfg.store_instruction) : null),
60 view.todo.length > 0,
61 )
62
63 const appBlock = (a) => {
64 const id = `app:${a.name}`
65 return fold(id, a.name, appSummary(a, L), () => {
66 const rec = a.recovered
67 return h('Box', { flexDirection: 'column', gap: 1 },
68 a.notes ? h('Text', { dimColor: true }, a.notes) : null,
69 a.neverReal ? h('Text', col('warn'), `⚠ ${L.never_real}`) : null,
70 part(L.envs, a.environments, (e, i) => h('Box', { key: `e-${i}`, flexDirection: 'column', paddingLeft: 1 },
71 h('Text', {}, `● ${e.name}${e.kind ? ` · ${L[`kind_${e.kind}`] ?? e.kind}` : ''}${e.status ? ` · ${e.status}` : ''}${e.owner ? ` · ${e.owner}` : ''}${e.created ? ` · ${e.created}` : ''}`),
72 (e.urls ?? []).map((u, j) => h('Text', { key: `eu-${i}-${j}`, dimColor: true }, ` ${u}`)),
73 Object.keys(e.ids ?? {}).length ? h('Text', { dimColor: true }, ` ${Object.entries(e.ids).map(([kk, v]) => `${kk} ${shortId(v)}`).join(' · ')}`) : null)),
74 part(L.accounts, a.accounts, (ac, i) => {
75 const rk = `${a.name}|${ac.login || ac.display}`
76 const shown = reveal.includes(rk)
77 return h('Box', { key: `a-${i}`, flexDirection: 'column', paddingLeft: 1 },
78 h('Box', { flexDirection: 'row', gap: 1 },
79 h('Text', {}, `● ${shown ? ac.login || ac.display : mask(ac.login || ac.display)}${ac.env ? ` · ${ac.env}` : ''}${ac.purpose ? ` · ${ac.purpose}` : ''}`),
80 h('Button', { key: `rv-${k(rk)}`, plain: true, onPress: () => act.reveal(rk) }, shown ? L.hide : L.show)),
81 ac.secret_ref?.target
82 ? h('Text', { dimColor: true }, ` ${fill(L.secret_at, { store: cfg.secret_stores?.[ac.secret_ref.store] ?? ac.secret_ref.store, target: ac.secret_ref.target })}`)
83 : h('Text', col('todo'), ` ${L.secret_none}`))
84 }),
85 part(L.procedures, a.procedures, (p, i) => h('Box', { key: `p-${i}`, flexDirection: 'column', paddingLeft: 1 },
86 h('Text', col(p.status), `● ${p.name}${p.env ? ` · ${p.env}` : ''}${p.kind ? ` · ${p.kind}` : ''} · ${resLabel(p.status)}${p.last?.date ? ` ${String(p.last.date).slice(0, 10)}` : ''}${p.undeclared ? ` · ${L.undeclared}` : ''}`),
87 p.steps?.length ? h('Text', { dimColor: true, wrap: 'wrap' }, ` ${fill(L.steps, { n: p.steps.length })} : ${p.steps.join(' → ')}`) : null,
88 (p.commands ?? []).map((c, j) => h('Text', { key: `pc-${i}-${j}`, dimColor: true, wrap: 'wrap' }, ` $ ${c}`)),
89 p.expected ? h('Text', { dimColor: true, wrap: 'wrap' }, ` ${fill(L.expected, { text: p.expected })}`) : null,
90 p.evidence?.length ? h('Text', { dimColor: true, wrap: 'wrap' }, ` ${fill(L.evidence, { list: p.evidence.join(', ') })}`) : null)),
91 part(L.runs, a.runs, (r, i) => h('Box', { key: `r-${i}`, flexDirection: 'column', paddingLeft: 1 },
92 h('Text', col(resultOf(r)), `● ${String(r.date).slice(0, 16).replace('T', ' ')} · ${r.procedure}${r.env ? ` · ${r.env}` : ''} · ${resLabel(resultOf(r))}${r.by ? ` · ${r.by}` : ''}`),
93 r.note ? h('Text', { dimColor: true, wrap: 'wrap' }, ` ${r.note}`) : null,
94 r.evidence?.length ? h('Text', { dimColor: true, wrap: 'wrap' }, ` ${fill(L.evidence, { list: r.evidence.join(', ') })}`) : null)),
95 part(L.checks, a.checks, (c, i) => h('Box', { key: `c-${i}`, flexDirection: 'column', paddingLeft: 1 },
96 h('Text', {}, `● ${c.date ?? ''} · ${c.what}${c.status ? ` · ${c.status}` : ''}`),
97 c.how ? h('Text', { dimColor: true, wrap: 'wrap' }, ` ${c.how}`) : null)),
98 rec
99 ? fold(`${id}:rec`, L.recovered, fill(L.sum_recovered, { s: (rec.sessions ?? []).length, c: (rec.commands ?? []).length }), () => h('Box', { flexDirection: 'column', gap: 1 },
100 part(L.rec_sessions, rec.sessions ?? [], (s, i) => h('Text', { key: `rs-${i}`, dimColor: true }, `● ${String(s.date).slice(0, 10)} · ${s.title} · ${(s.cats ?? []).join(', ')} · ${s.hits}`)),
101 part(L.rec_commands, a.recCommands ?? [], (c, i) => h('Text', { key: `rc-${i}`, ...(c.errors ? col('fail') : { dimColor: true }), wrap: 'wrap' }, `● ${String(c.date).slice(0, 10)} · ${c.count}×${c.errors ? ` · ${fill(L.errors, { n: c.errors })}` : ''} · ${c.cmd}`)),
102 part(L.rec_ids, rec.ids ?? [], (x, i) => h('Text', { key: `ri-${i}`, dimColor: true }, `● ${x.kind} ${shortId(x.value)} · ${x.count}×`)),
103 part(L.rec_urls, rec.urls ?? [], (x, i) => h('Text', { key: `ru-${i}`, dimColor: true, wrap: 'wrap' }, `● ${x.url} · ${x.count}×`)),
104 part(L.rec_accounts, rec.accounts ?? [], (x, i) => h('Text', { key: `ra-${i}`, dimColor: true }, `● ${mask(x.login)} · ${x.count}×`)),
105 part(L.rec_workspaces, rec.workspaces ?? [], (x, i) => h('Text', { key: `rw-${i}`, dimColor: true }, `● ${x.name} · ${x.count}×`)),
106 part(L.rec_mentions, rec.mentions ?? [], (x, i) => h('Text', { key: `rm-${i}`, dimColor: true, wrap: 'wrap' }, `● ${String(x.date).slice(0, 10)} · ${x.title ?? ''} : ${x.text}`))))
107 : null)
108 }, a.neverReal)
109 }
110
111 return h('Box', { flexDirection: 'column', gap: 1 },
112 head,
113 todoBlock,
114 h('Box', { flexDirection: 'column' },
115 h('Text', { bold: true }, `${L.apps_title} (${view.apps.length})`),
116 view.apps.slice(0, cfg.max_apps ?? 60).map(appBlock),
117 view.apps.length > (cfg.max_apps ?? 60) ? h('Text', { dimColor: true }, fill(L.more, { n: view.apps.length - (cfg.max_apps ?? 60) })) : null),
118 h('Box', { flexDirection: 'column' },
119 (L.notes ?? []).map((x, i) => h('Text', { key: `n-${i}`, dimColor: true, wrap: 'wrap' }, `· ${x}`))))
120}
121types/index.d.ts 18 lines1// Types of the tests-reels mod: the `$.state` values the pane reads.
2
3/** Result of a run: pass (green), fail (red), partial, ran (seen in history), never. */
4export type Result = 'pass' | 'fail' | 'partial' | 'ran' | 'never'
5
6declare module 'claude-code' {
7 interface PluginState {
8 'tests-reels': {
9 /** ids of the open blocks: todo, app:<name>, app:<name>:rec */
10 open: string[]
11 /** accounts whose login is shown in clear (app|login) */
12 reveal: string[]
13 /** last action line under the buttons */
14 status: string
15 }
16 }
17}
18