A plan to take the existing suite from "every test type is represented" to "every module is covered, thoroughly, with valid / invalid / edge inputs" — so upgrades (deps, Node, providers, refactors) are safe.
Status: APPROVED 2026-06-29; implementing on feature/test-coverage. Owner decisions:
(1) enforce coverage thresholds in CI (engine src ~90%, lib ~85%); (2) build all 5
phases, then one review at the end (no per-phase pause); (3) Chromium-first — turn on
Firefox/WebKit/mobile in phase 5.
Scope (confirmed with owner)
Build out the core code layers + performance + cross-browser. Skipping production monitoring (Sentry/Datadog) by choice. Mapping the owner's 20-item list:
| Owner's layer | Where it lands here |
|---|---|
| 1 Static | already wired (eslint, prettier); add a check it actually gates |
| 2 Unit · 3 Component · 4 Interaction · 5 Integration · 6 Hooks | Vitest (Node + jsdom) — the bulk of new work |
| 7 API mocking | add MSW to the SPA suite; standardize loading/success/error/empty/slow/unauthorized |
| 8 E2E · 9 Visual · 10 Accessibility | Playwright — expand flows, add cross-browser |
| 11 Snapshot · 16 Regression · 17 Smoke | extend existing |
| 12 Storybook | not adopted (out of scope; component+interaction tests cover the "every state" need) |
| 13 Performance | Lighthouse CI + bundle-size budget (free, local + CI) |
| 14 Cross-browser | Playwright Firefox + WebKit projects (BrowserStack/Sauce are paid → skipped) |
| 15 Responsive/mobile | Playwright mobile viewport project (free, folds into cross-browser) |
| 18 Acceptance | expressed as the E2E user-flow specs |
| 20 Production monitoring | not adopted (owner's choice) |
Current state (baseline, 2026-06-29)
Existing test files and their describe/it counts:
- Node unit (
tests/unit/): cleanup 7, contentSafety 17, dpl 35, emphasis 10, gatedLists 9, keywordRepeater 8, listManifest 28, promptSalt 6, randomEmphasis.plain 4 - Node integration (
tests/integration/): enginePipeline 21, manageFs 14 - Node regression 13, snapshot 5
- E2E (
tests/e2e/): home 4, visual 4, accessibility 3 - SPA (
targets/web/tests/): customStore 2, Field 8, gallery 11, promptEngine.integration 10, providers 11, settings 6, Settings 16, share 5, TokenPicker 4, validateDpl 12
Strong where it exists; the gaps below are whole modules with zero direct tests.
Gap inventory (what's untested today)
Engine (src/) — Node suite
helpers/randomEmphasis.js— only thePlainbranch is tested. SD / NovelAI / Midjourney branches,keywordEmphasis:falseno-op, de-emphasis roll, max-levels cap untested.helpers/randomEditing.js— untested (edit-in / swap / edit-out; non-SD no-op; disabled no-op).helpers/randomAlternating.js— untested (alternation run, SD[...]wrap, MDJ no-op, NAI no brackets).helpers/aliases.js— trivial constants, fold into a tiny guard test.core/listStore.js— covered only indirectly. Depletion,listEntriesUsedOnce, reload-on-empty, alias resolution (keyword/artist,false→ random), artist & NSFW gating untested directly.core/stages/list.js— covered only indirectly. Emphasis path, NovelAI()→{}rewrite, artist detection,{#name}pass-through, nested token re-pull untested directly.core/stages/block.js— covered only indirectly. Dial arg parsing,{#any}family, implied +.groupgroups, dedup/stacking, auto-append fx/artists, NSFW gating, danbooru replacer, 10-pass cap untested directly.core/engine.js—generate()default-prompt fallback,generateManycount clamp (0/NaN/neg → 1),promptModulesordering + unknown-stage skip,\rstripping.blockManifest.js— untested (isReservedAny,dynGroupDirsv1 exclusion,dynGroupMembers).promptFilesAndSuggestions.js— untested (classification,pickerListNamesadult on/off,promptSuggestionshapes,gatePool,configure()-not-called throw).core/nodeLoader.js— loader contract over a temp data dir (lists, groups, dpl, markers, meta).settings.js— a shape/type guard test (the master defaults the engine assumes).
SPA (targets/web/frontend/lib/) — jsdom suite
keywords.js— untested (keywordKeyaccents/Unicode,cleanTaglora/attention/weights/BREAK/AND/pipe,parseKeywordsdedupe/maxLen/max/sort,normalizeKeywordList).manageTree.js— untested (buildManageModelcategories/groups/NSFW-hide,computeGhosts,injectGhosts,filterModel).rewrite.js— untested (browser-direct path, proxy fallback, non-OK error).output.js— untested (ingestImagesave/fallback,isOutputFile,openImageInNewTabdata→blob,fileActiondelete/reveal/open,updateImageMeta).online.js— untested (ONLINEflag,lockedHint,openFullVersion).sessionKeys.js— untested (get/setSessionKey,effectiveKeyprecedence).providerMeta.js— untested (metaForknown/unknown).dpl/dplLanguage.js,dpl/dplInserts.js— untested (CodeMirror language + insert-bar model).magick.js,manageApi.js,runtimeLoader.js,wrapperStore.js,gallery.js(partly),providers/index.jsshim.- Hooks:
useProvider.js,i18n/I18nProvider.jsx,settings.useSettings(persist/load).
SPA components — jsdom suite
Only Field, Settings, TokenPicker are covered. Untested (~22): App, ApiKeyField, CodeEditor, DplEditor, DplInsertBar, DplStatus, Gallery, Home, InlineImageControls, LivePreview, Manage, ManageBlockEditor, ManageFolderEditor, ManageListEditor, NsfwToggle, PromptResult, ProviderBox, ProviderGear, ProviderPicker, ProvidersMenu, SettingsDrawer, SingleView, WrapperFab.
Providers (targets/shared/) — contract suite
Only local-webui + midjourney covered. Untested adapters: comfyui, openai, gemini,
grok, replicate, fal, bfl, ideogram, leonardo, stability code/generate.js; the rewrite
adapters (openai/gemini/grok code/rewrite.js); _shared/transport/* (hostedProxy,
localDirect, submitPoll); _shared/dialects.js (engineModeFor), _shared/rewriteSystem.js,
_shared/fieldInfo.js. (The former Netlify functions were removed; server-side dispatch now lives in targets/web/backend/dispatch.js.)
Tooling changes
- MSW (
msw, dev dep intargets/web/). Addtargets/web/tests/msw/server.js+ handlers; wireserver.listen/resetHandlers/closeintotargets/web/tests/setup.js. Migrate the ad-hocvi.stubGlobal("fetch", …)provider tests onto it and add the standard network matrix (loading, 200, 4xx unauthorized, 5xx, empty body, slow/timeout). - Playwright cross-browser — add
firefoxandwebkitprojects + aMobile Chrome(Pixel 7) viewport project toplaywright.config.js. E2E + a11y run on all; visual stays chromium-only (pixel baselines are per-engine). One-timenpx playwright install firefox webkit. CI matrix note added tonotes/reference/deployment.md. - Performance — add
@lhci/cli;lighthouserc.jsonwith budgets (perf ≥ 0.9, a11y ≥ 0.95, no render-blocking regressions) run againstvite preview. Add a bundle-size budget test (tests/perf/bundleSize.test.jsorsize-limit) asserting the builtdist/main chunk gzip stays under an agreed ceiling. New scripts:test:perf,test:lhci. - Coverage config fix — root
vitest.config.jscoverage.includelists a non-existentsrc/diffSettings.js; replace with the real now-tested modules (helpers/random*.js,blockManifest.js,promptFilesAndSuggestions.js,core/listStore.js,core/stages/*.js). Add modest coverage thresholds so a future drop fails CI. - Scripts — extend root
package.json:test:e2e:all(all browsers),test:perf; keeptest(lint+smoke+unit+web) as the fast headless gate,test:alladds e2e+perf. - CI (
.github/workflows/ci.yml) — add the SPA coverage run, the cross-browser e2e job (Linux), and a Lighthouse + bundle-budget job. Documented indeployment.md.
Per-module case matrices (the thoroughness the owner asked for)
Each new test file drives the module across valid, invalid, boundary, and adversarial inputs — not one value. Representative matrices for the highest-value modules:
helpers/randomEmphasis.js — for each mode (SD/NAI/MDJ/Plain): emphasis on/off,
de-emphasis roll forced both ways (stub deEmphasisChance 0 and 1), level cap honored
(emphasisMaxLevels 1 vs 3 with emphasisLevelChance 0/1), empty keyword, multi-word
keyword, wasUsed flag, custom plainEmphasisWords ladder + cap beyond ladder length.
(lodash-RNG landmine: assert structural invariants / bracket counts, not exact random picks.)
helpers/randomEditing.js — SD: each of edit-in [k:n] / swap [k:k:n] / edit-out
[k::n] shapes (force the _.random(0,2) branch via single-value min=max settings),
n within [min,max]; non-SD mode → no-op; keywordEditing:false → no-op; wasUsed.
helpers/randomAlternating.js — SD wraps run in [a|a]; NAI run without brackets;
MDJ → no-op (returns input, wasUsed:true); keywordAlternating:false no-op; level cap.
core/listStore.js — pull from single/multi-entry list; depletion empties then reloads;
listEntriesUsedOnce:false keeps entries; alias keyword→keywordsFilename,
keywordsFilename:"false"→random non-artist list; artist alias + includeArtist:false→"";
gated list + includeAdult:false→""; missing list→""; reset() clears state.
core/stages/block.js (over fakeLoader) — {#name} resolves; {#a/b} path;
{#any} picks one; implied group picks a member; .group file picks a member; dedup drops
2nd import; stacking exempt; {#name i25% f80%} dial parse (absent→50, 0→1, >100→100,
non-numeric ignored); auto-add fx/artists once (idempotent via imageSettings flags); NSFW
gating on/off; danbooru , Person→{d/person} only for d//danbooru keyword file;
10-pass cap leaves no infinite loop; unknown {#missing}→"".
keywords.js — keywordKey: "Café"→"cafe", non-Latin unchanged, whitespace collapse;
cleanTag via parseKeywords: <lora:x:0.8> dropped, (w:1.2)/((w))/[w]/{w} keep inner,
from:to:step colons→space, BREAK/AND removed, | split, stray quotes; dedupe by key
("café"+"cafe"→1); maxLen drops run-ons; max caps count; sort alphabetizes by key;
empty/whitespace/null input → [].
manageTree.js — buildManageModel: top-level = categories, force-prefix/group/NSFW
markers surface, NSFW entries hidden when includeAdult:false and shown when true, empty
folders kept, .js sidecar of same-name .dpl hidden; computeGhosts set-difference +
NSFW hide; injectGhosts creates missing folder nodes + recounts; filterModel keeps a
folder when name or descendant matches, returns null on no match.
listManifest.resolveListLines (extend) — already 28 tests; add the {name} vs
{name-sfw} vs {name-nsfw} × adult on/off truth table, group variant propagation, the
"stray <base> beside <base>-nsfw is ignored" safety rule, cycle guard at MAX_GROUP_DEPTH,
keyword wildcard excludes artist/danbooru + itself.
share.js (extend) — round-trip encode→decode; keys stripped; unicode/emoji survive
base64url; malformed hash→null; no hash→null; extra & params tolerated.
output.js — ingestImage returns served path on {ok,path}, falls back to src on
non-ok / network throw (MSW); isOutputFile true/false; openImageInNewTab data→blob path
(stub URL.createObjectURL/window.open), non-data direct open; fileAction/updateImageMeta
success + failure.
Provider adapters (per adapter, MSW-backed) — request shape (URL, method, headers incl.
auth, body fields) and response mapping to { images: [...] }; error on non-OK with a
descriptive message; empty/missing-image response; default endpoint when URL unset. Rewrite
adapters: system prompt selection (fix vs keyword), { text } extraction, error. Transport
submitPoll: submit→poll→complete, poll timeout, failure status.
Components (RTL + user-event) — each rendered in the IntlProvider wrapper:
render with default props, the key interaction (click/type/select/toggle), the disabled/locked
state (online build), and an empty/error state. E.g. NsfwToggle confirm-on-enable flow;
ProviderPicker/ProviderBox selection + BYOK key entry (session vs saved); PromptResult
copy/regenerate; DplInsertBar token insertion; Gallery/SingleView empty + populated;
Home type→generate→result wiring with the engine mocked.
New files (inventory)
- Node unit:
tests/unit/{randomEmphasis,randomEditing,randomAlternating,aliases,listStore, listStage,blockStage,blockManifest,promptFilesAndSuggestions,engine,settings}.test.js - Node loader/integration:
tests/integration/{nodeLoader,enginePipeline.extended}.test.js - SPA lib:
targets/web/tests/lib/{keywords,manageTree,rewrite,output,online,sessionKeys,providerMeta, dplLanguage,dplInserts,wrapperStore,useProvider,manageApi}.test.js(x) - SPA components:
targets/web/tests/components/<Name>.test.jsx(one per untested component) - Providers:
targets/web/tests/providers/<id>.test.js+targets/web/tests/providers/{transport,rewrite, netlifyFunctions}.test.js;targets/web/tests/msw/{server,handlers}.js - Perf:
tests/perf/bundleSize.test.js,lighthouserc.json - E2E: extend
tests/e2e/(manage flow, provider switch, share-link load, settings persist); add browser projects inplaywright.config.js - Regression/snapshot: add as gaps surface
Phasing (so it lands reviewable, on feature/test-coverage off dev)
- Engine core (highest upgrade-risk): random* helpers, listStore, the three stages, manifests, promptFilesAndSuggestions, engine, nodeLoader. + coverage-config fix.
- SPA lib + MSW: keywords, manageTree, output, rewrite, online, sessionKeys, providerMeta, dpl language/inserts, hooks; stand up MSW and migrate provider tests onto it.
- Provider adapters + transport + netlify functions (the upgrade-fragile network edges).
- Components: the ~22 untested, RTL + user-event, valid/invalid/disabled/error states.
- Cross-browser + performance: Playwright firefox/webkit/mobile projects; Lighthouse CI
- bundle budget; CI wiring; refresh
notes/plans/testing.md+deployment.md.
- bundle budget; CI wiring; refresh
Each phase is its own commit(s) with npm test green; e2e/perf validated before the CI wiring
commit. Estimated ~250–350 new it() cases. lodash-RNG landmine respected throughout
(invariants / single-entry lists / seeded DPL only).
Verification
- After each module:
npm run lint+npm run test:unit/test:webgreen. - After each phase: full
npm test; e2e/perf phases alsonpm run test:e2e:all+test:perf. - Final:
npm run test:allgreen on all browsers; coverage thresholds met; CI green ondev. - A subagent review pass on the engine-core test phase (highest stakes) before merge.