SLOPSHOPPER

tests-reels

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…

panecommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tests-reels
│ ┃ Tests réels ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ↻ recover ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tests │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tests réels
↻ recover
README

tests-reels (« Tests réels »)

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:

  1. Header: apps, environments, accounts, procedures, runs, apps « jamais testé en réel »; buttons « ↻ relire » and « Lancer la récupération ».
  2. À ranger: test accounts whose secret is not stored anywhere known, environments with a missing id (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.
  3. Applications, one foldable block each: environments (kind tenant / cloud / local / docker, URLs, ids shortened, owner, created, status), test accounts (login masked until « voir », and where the secret lives), procedures (steps, commands, expected result, evidence, last run green / red / never), last runs, manual checks, and « Trouvé dans l'historique » (sessions, commands with their error count, ids, URLs, workspaces, passages).

Security rules

  • No secret value is ever stored, printed or committed. An account keeps only its login (a dedicated test account), its display name and a reference: 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).
  • Tenant ids, workspace ids and URLs of private apps live only in the local data folder ~/.claude/tests-reels/ (outside every repo). This repository ships code and invented fixtures only.
  • The recovery scan never copies a secret value: it records its kind, the session title and the date, and counts it. Every passage and command it keeps is redacted first; URLs lose their query string and fragment.

Data (local only)

FileWritten byWhat
~/.claude/tests-reels/registry.jsoncli.mjs add …apps → environments → test accounts (references) → procedures → manual checks
~/.claude/tests-reels/runs.jsonlcli.mjs run-log …one line per real test run (append-only, safe for parallel agents)
~/.claude/tests-reels/recovered.jsonscanner/recover.mjswhat the history says (rewritten on each scan) and recover-cache.json (per-file cache)
~/.claude/tests-reels/config.jsonyoudeep-merged over tests-reels.config.json: source paths, aliases, labels

TESTS_REELS_HOME or --data-dir moves the folder (tests use a temporary one).

After any real test: cli.mjs run-log

So 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).

Recovery scan

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):

  • transcripts ~/.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),
  • each repo's 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.

Tests

node --test mods/tests-reels/tests/logic.test.mjs
claude plugin test mods/tests-reels
claude plugin validate mods/tests-reels
Source 4 files
hooks/register.tsx 130 lines
1// 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}
130
lib/registry.mjs 245 lines
1// 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}
245
hooks/view.mjs 121 lines
1// 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}
121
types/index.d.ts 18 lines
1// 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