thevoidforge 21.0.11 → 21.0.13
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/dist/.claude/commands/ai.md +69 -0
- package/dist/.claude/commands/architect.md +121 -0
- package/dist/.claude/commands/assemble.md +201 -0
- package/dist/.claude/commands/assess.md +75 -0
- package/dist/.claude/commands/blueprint.md +135 -0
- package/dist/.claude/commands/build.md +116 -0
- package/dist/.claude/commands/campaign.md +201 -0
- package/dist/.claude/commands/cultivation.md +166 -0
- package/dist/.claude/commands/current.md +128 -0
- package/dist/.claude/commands/dangerroom.md +74 -0
- package/dist/.claude/commands/debrief.md +178 -0
- package/dist/.claude/commands/deploy.md +99 -0
- package/dist/.claude/commands/devops.md +143 -0
- package/dist/.claude/commands/gauntlet.md +140 -0
- package/dist/.claude/commands/git.md +104 -0
- package/dist/.claude/commands/grow.md +146 -0
- package/dist/.claude/commands/imagine.md +126 -0
- package/dist/.claude/commands/portfolio.md +50 -0
- package/dist/.claude/commands/prd.md +113 -0
- package/dist/.claude/commands/qa.md +107 -0
- package/dist/.claude/commands/review.md +151 -0
- package/dist/.claude/commands/security.md +100 -0
- package/dist/.claude/commands/test.md +96 -0
- package/dist/.claude/commands/thumper.md +116 -0
- package/dist/.claude/commands/treasury.md +100 -0
- package/dist/.claude/commands/ux.md +118 -0
- package/dist/.claude/commands/vault.md +189 -0
- package/dist/.claude/commands/void.md +108 -0
- package/dist/CHANGELOG.md +1918 -0
- package/dist/CLAUDE.md +250 -0
- package/dist/HOLOCRON.md +856 -0
- package/dist/VERSION.md +123 -0
- package/dist/docs/NAMING_REGISTRY.md +478 -0
- package/dist/docs/methods/AI_INTELLIGENCE.md +276 -0
- package/dist/docs/methods/ASSEMBLER.md +142 -0
- package/dist/docs/methods/BACKEND_ENGINEER.md +165 -0
- package/dist/docs/methods/BUILD_JOURNAL.md +185 -0
- package/dist/docs/methods/BUILD_PROTOCOL.md +426 -0
- package/dist/docs/methods/CAMPAIGN.md +568 -0
- package/dist/docs/methods/CONTEXT_MANAGEMENT.md +189 -0
- package/dist/docs/methods/DEEP_CURRENT.md +184 -0
- package/dist/docs/methods/DEVOPS_ENGINEER.md +295 -0
- package/dist/docs/methods/FIELD_MEDIC.md +261 -0
- package/dist/docs/methods/FORGE_ARTIST.md +108 -0
- package/dist/docs/methods/FORGE_KEEPER.md +268 -0
- package/dist/docs/methods/GAUNTLET.md +344 -0
- package/dist/docs/methods/GROWTH_STRATEGIST.md +466 -0
- package/dist/docs/methods/HEARTBEAT.md +168 -0
- package/dist/docs/methods/MCP_INTEGRATION.md +139 -0
- package/dist/docs/methods/MUSTER.md +148 -0
- package/dist/docs/methods/PRD_GENERATOR.md +186 -0
- package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +250 -0
- package/dist/docs/methods/QA_ENGINEER.md +337 -0
- package/dist/docs/methods/RELEASE_MANAGER.md +145 -0
- package/dist/docs/methods/SECURITY_AUDITOR.md +320 -0
- package/dist/docs/methods/SUB_AGENTS.md +335 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +171 -0
- package/dist/docs/methods/TESTING.md +359 -0
- package/dist/docs/methods/THUMPER.md +175 -0
- package/dist/docs/methods/TIME_VAULT.md +120 -0
- package/dist/docs/methods/TREASURY.md +184 -0
- package/dist/docs/methods/TROUBLESHOOTING.md +265 -0
- package/dist/docs/patterns/README.md +52 -0
- package/dist/docs/patterns/ad-billing-adapter.ts +537 -0
- package/dist/docs/patterns/ad-platform-adapter.ts +421 -0
- package/dist/docs/patterns/ai-classifier.ts +195 -0
- package/dist/docs/patterns/ai-eval.ts +272 -0
- package/dist/docs/patterns/ai-orchestrator.ts +341 -0
- package/dist/docs/patterns/ai-router.ts +194 -0
- package/dist/docs/patterns/ai-tool-schema.ts +237 -0
- package/dist/docs/patterns/api-route.ts +241 -0
- package/dist/docs/patterns/backtest-engine.ts +499 -0
- package/dist/docs/patterns/browser-review.ts +292 -0
- package/dist/docs/patterns/combobox.tsx +300 -0
- package/dist/docs/patterns/component.tsx +262 -0
- package/dist/docs/patterns/daemon-process.ts +338 -0
- package/dist/docs/patterns/data-pipeline.ts +297 -0
- package/dist/docs/patterns/database-migration.ts +466 -0
- package/dist/docs/patterns/e2e-test.ts +629 -0
- package/dist/docs/patterns/error-handling.ts +312 -0
- package/dist/docs/patterns/execution-safety.ts +601 -0
- package/dist/docs/patterns/financial-transaction.ts +342 -0
- package/dist/docs/patterns/funding-plan.ts +462 -0
- package/dist/docs/patterns/game-entity.ts +137 -0
- package/dist/docs/patterns/game-loop.ts +113 -0
- package/dist/docs/patterns/game-state.ts +143 -0
- package/dist/docs/patterns/job-queue.ts +225 -0
- package/dist/docs/patterns/kongo-integration.ts +164 -0
- package/dist/docs/patterns/middleware.ts +363 -0
- package/dist/docs/patterns/mobile-screen.tsx +139 -0
- package/dist/docs/patterns/mobile-service.ts +167 -0
- package/dist/docs/patterns/multi-tenant.ts +382 -0
- package/dist/docs/patterns/oauth-token-lifecycle.ts +223 -0
- package/dist/docs/patterns/outbound-rate-limiter.ts +260 -0
- package/dist/docs/patterns/prompt-template.ts +195 -0
- package/dist/docs/patterns/revenue-source-adapter.ts +311 -0
- package/dist/docs/patterns/service.ts +224 -0
- package/dist/docs/patterns/sse-endpoint.ts +118 -0
- package/dist/docs/patterns/stablecoin-adapter.ts +511 -0
- package/dist/docs/patterns/third-party-script.ts +68 -0
- package/dist/scripts/thumper/gom-jabbar.sh +241 -0
- package/dist/scripts/thumper/relay.sh +610 -0
- package/dist/scripts/thumper/scan.sh +359 -0
- package/dist/scripts/thumper/thumper.sh +190 -0
- package/dist/scripts/thumper/water-rings.sh +76 -0
- package/dist/wizard/ui/index.html +1 -1
- package/package.json +1 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Browser-Based Agent Review
|
|
3
|
+
*
|
|
4
|
+
* Purpose: Give review agents (Batman, Galadriel, Kenobi, Thanos) browser eyes
|
|
5
|
+
* during ad-hoc review passes. NOT E2E testing (see e2e-test.ts for that).
|
|
6
|
+
*
|
|
7
|
+
* E2E tests vs browser review — separate concerns:
|
|
8
|
+
* - E2E tests: deterministic, CI, assert pass/fail. Owned by test suites.
|
|
9
|
+
* - Browser review: exploratory, during /qa /ux /security /gauntlet. Evidence for triage.
|
|
10
|
+
*
|
|
11
|
+
* Three capabilities:
|
|
12
|
+
* 1. Console capture — catch uncaught exceptions and JS errors passively.
|
|
13
|
+
* 2. Behavioral walkthrough — click buttons, fill forms, verify responses.
|
|
14
|
+
* 3. Screenshot evidence — capture viewport state for triage reports.
|
|
15
|
+
*
|
|
16
|
+
* Screenshots: good for triage (blank page? modal open?) and evidence (attach to findings).
|
|
17
|
+
* NOT good for design review. Riker's dissent: "A screenshot tells you what rendered, not
|
|
18
|
+
* whether it looks right. Design review requires a designer's eye, not a pixel grid."
|
|
19
|
+
*
|
|
20
|
+
* Agents: Batman (QA walkthrough), Galadriel (responsive), Kenobi (security),
|
|
21
|
+
* Hawkeye (Gauntlet smoke), Riker (dissent on screenshot-as-design-review)
|
|
22
|
+
*
|
|
23
|
+
* === Framework Adaptations (baseURL + startup) ===
|
|
24
|
+
* Next.js: `next dev -p 3199`, baseURL = 'http://127.0.0.1:3199'
|
|
25
|
+
* Express: `PORT=3199 npx tsx src/server.ts`, same baseURL
|
|
26
|
+
* Django: `python manage.py runserver 3199 --settings=project.settings.test`
|
|
27
|
+
* Rails: `RAILS_ENV=test bin/rails server -p 3199`
|
|
28
|
+
* All: start the dev server BEFORE calling launchReviewBrowser(). The review
|
|
29
|
+
* browser connects to a running server — it does NOT manage the lifecycle.
|
|
30
|
+
*
|
|
31
|
+
* Dependencies: playwright (already available if using e2e-test.ts)
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
|
35
|
+
// Type-only import for axe — actual usage is dynamic import (not all projects have it)
|
|
36
|
+
type AxeResults = { violations: Array<{ id: string; impact?: string; description: string; nodes: unknown[] }> };
|
|
37
|
+
|
|
38
|
+
// ── Types ───────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface ConsoleError {
|
|
41
|
+
url: string;
|
|
42
|
+
type: 'uncaught-exception' | 'console-error';
|
|
43
|
+
message: string;
|
|
44
|
+
stack: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PageStateReport {
|
|
48
|
+
route: string;
|
|
49
|
+
title: string;
|
|
50
|
+
headings: string[];
|
|
51
|
+
screenshotPath: string;
|
|
52
|
+
consoleErrors: ConsoleError[];
|
|
53
|
+
a11yViolations: Array<{ id: string; impact: string | undefined; description: string; nodeCount: number }>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ViewportCapture {
|
|
57
|
+
viewport: { width: number; height: number; label: string };
|
|
58
|
+
screenshotPath: string;
|
|
59
|
+
consoleErrors: ConsoleError[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ResponsiveReport { route: string; captures: ViewportCapture[] }
|
|
63
|
+
|
|
64
|
+
export interface InteractionReport {
|
|
65
|
+
route: string;
|
|
66
|
+
buttons: Array<{ text: string; responded: boolean; error: string | null }>;
|
|
67
|
+
links: Array<{ text: string; href: string | null }>;
|
|
68
|
+
formFields: Array<{ label: string; type: string; acceptedInput: boolean; validationMessage: string | null }>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CookieReport {
|
|
72
|
+
cookies: Array<{ name: string; domain: string; secure: boolean; httpOnly: boolean; sameSite: string; expires: number }>;
|
|
73
|
+
findings: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CORSReport {
|
|
77
|
+
url: string;
|
|
78
|
+
allowOrigin: string | null;
|
|
79
|
+
allowCredentials: string | null;
|
|
80
|
+
allowMethods: string | null;
|
|
81
|
+
allowHeaders: string | null;
|
|
82
|
+
findings: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CSPViolation { blockedURI: string; violatedDirective: string; originalPolicy: string }
|
|
86
|
+
|
|
87
|
+
// ── Review Browser Launcher ─────────────────────────
|
|
88
|
+
// Network-isolated Chromium. Fixed viewport (1440x900, 1x DPI, light mode).
|
|
89
|
+
|
|
90
|
+
export async function launchReviewBrowser(baseURL: string): Promise<{
|
|
91
|
+
browser: Browser; context: BrowserContext; page: Page;
|
|
92
|
+
}> {
|
|
93
|
+
const browser = await chromium.launch({
|
|
94
|
+
args: [
|
|
95
|
+
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1', // Block external DNS
|
|
96
|
+
'--disable-extensions',
|
|
97
|
+
],
|
|
98
|
+
});
|
|
99
|
+
const context = await browser.newContext({
|
|
100
|
+
viewport: { width: 1440, height: 900 },
|
|
101
|
+
deviceScaleFactor: 1,
|
|
102
|
+
colorScheme: 'light',
|
|
103
|
+
baseURL,
|
|
104
|
+
});
|
|
105
|
+
const page = await context.newPage();
|
|
106
|
+
return { browser, context, page };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Console Error Capture ───────────────────────────
|
|
110
|
+
// pageerror = uncaught exception (always a finding).
|
|
111
|
+
// console.error = filtered for noise (React dev, HMR, extensions).
|
|
112
|
+
|
|
113
|
+
const CONSOLE_NOISE: RegExp[] = [
|
|
114
|
+
/Download the React DevTools/i, /Warning: ReactDOM/i,
|
|
115
|
+
/Warning: Each child in a list/i, /\[HMR\]/i, /\[vite\]/i,
|
|
116
|
+
/webpack.*hot/i, /chrome-extension:\/\//i, /moz-extension:\/\//i,
|
|
117
|
+
/Failed to load resource.*favicon/i,
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
export function attachConsoleCapture(page: Page): {
|
|
121
|
+
errors: ConsoleError[]; getErrors: () => ConsoleError[];
|
|
122
|
+
} {
|
|
123
|
+
const errors: ConsoleError[] = [];
|
|
124
|
+
page.on('pageerror', (error) => {
|
|
125
|
+
errors.push({ url: page.url(), type: 'uncaught-exception', message: error.message, stack: error.stack ?? null });
|
|
126
|
+
});
|
|
127
|
+
page.on('console', (msg) => {
|
|
128
|
+
if (msg.type() !== 'error') return;
|
|
129
|
+
const text = msg.text();
|
|
130
|
+
if (CONSOLE_NOISE.some((p) => p.test(text))) return;
|
|
131
|
+
errors.push({ url: page.url(), type: 'console-error', message: text, stack: null });
|
|
132
|
+
});
|
|
133
|
+
return { errors, getErrors: () => [...errors] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Page State Capture ──────────────────────────────
|
|
137
|
+
// Navigate, wait for ready, screenshot, headings, a11y. The workhorse.
|
|
138
|
+
|
|
139
|
+
export async function capturePageState(
|
|
140
|
+
page: Page, route: string,
|
|
141
|
+
options?: { readySelector?: string; screenshotDir?: string; runA11y?: boolean }
|
|
142
|
+
): Promise<PageStateReport> {
|
|
143
|
+
const readySelector = options?.readySelector ?? '[data-ready]';
|
|
144
|
+
const screenshotDir = options?.screenshotDir ?? 'review-captures';
|
|
145
|
+
const runA11y = options?.runA11y ?? true;
|
|
146
|
+
|
|
147
|
+
await page.goto(route);
|
|
148
|
+
// Wait for ready selector; fall back to networkidle (lenient for review, not E2E)
|
|
149
|
+
try { await page.waitForSelector(readySelector, { timeout: 3000 }); }
|
|
150
|
+
catch { await page.waitForLoadState('networkidle'); }
|
|
151
|
+
|
|
152
|
+
const slug = route.replace(/\//g, '-').replace(/^-/, '') || 'index';
|
|
153
|
+
const screenshotPath = `${screenshotDir}/${slug}.jpg`;
|
|
154
|
+
await page.screenshot({ path: screenshotPath, type: 'jpeg', quality: 80 });
|
|
155
|
+
|
|
156
|
+
const title = await page.title();
|
|
157
|
+
const headings = await page.locator('h1, h2, h3').allTextContents();
|
|
158
|
+
|
|
159
|
+
// a11y scan — dynamic import so pattern doesn't hard-depend on axe
|
|
160
|
+
let a11yViolations: PageStateReport['a11yViolations'] = [];
|
|
161
|
+
if (runA11y) {
|
|
162
|
+
try {
|
|
163
|
+
const { default: Builder } = await import('@axe-core/playwright');
|
|
164
|
+
const results: AxeResults = await new (Builder as unknown as new (opts: { page: Page }) => { analyze: () => Promise<AxeResults> })({ page }).analyze();
|
|
165
|
+
a11yViolations = results.violations.map((v) => ({
|
|
166
|
+
id: v.id, impact: v.impact, description: v.description, nodeCount: v.nodes.length,
|
|
167
|
+
}));
|
|
168
|
+
} catch { /* axe not installed — skip */ }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { route, title, headings, screenshotPath, consoleErrors: [], a11yViolations };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Responsive Capture ──────────────────────────────
|
|
175
|
+
// Three viewports: mobile (375x812), tablet (768x1024), desktop (1440x900).
|
|
176
|
+
|
|
177
|
+
const VIEWPORTS = [
|
|
178
|
+
{ width: 375, height: 812, label: 'mobile' },
|
|
179
|
+
{ width: 768, height: 1024, label: 'tablet' },
|
|
180
|
+
{ width: 1440, height: 900, label: 'desktop' },
|
|
181
|
+
] as const;
|
|
182
|
+
|
|
183
|
+
export async function captureResponsiveSet(
|
|
184
|
+
page: Page, route: string, options?: { screenshotDir?: string }
|
|
185
|
+
): Promise<ResponsiveReport> {
|
|
186
|
+
const screenshotDir = options?.screenshotDir ?? 'review-captures';
|
|
187
|
+
const captures: ViewportCapture[] = [];
|
|
188
|
+
for (const vp of VIEWPORTS) {
|
|
189
|
+
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
190
|
+
const capture = attachConsoleCapture(page);
|
|
191
|
+
await page.goto(route);
|
|
192
|
+
await page.waitForLoadState('networkidle');
|
|
193
|
+
const slug = route.replace(/\//g, '-').replace(/^-/, '') || 'index';
|
|
194
|
+
const path = `${screenshotDir}/${slug}-${vp.label}.jpg`;
|
|
195
|
+
await page.screenshot({ path, type: 'jpeg', quality: 80 });
|
|
196
|
+
captures.push({ viewport: { width: vp.width, height: vp.height, label: vp.label }, screenshotPath: path, consoleErrors: capture.getErrors() });
|
|
197
|
+
}
|
|
198
|
+
await page.setViewportSize({ width: 1440, height: 900 }); // Restore default
|
|
199
|
+
return { route, captures };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Behavioral Walkthrough ──────────────────────────
|
|
203
|
+
// Click everything, fill every form, see what breaks. Exploratory, not deterministic.
|
|
204
|
+
|
|
205
|
+
export async function walkInteractiveElements(page: Page): Promise<InteractionReport> {
|
|
206
|
+
const route = page.url();
|
|
207
|
+
const buttons: InteractionReport['buttons'] = [];
|
|
208
|
+
const btnLocators = page.locator('button:visible, [role="button"]:visible, [role="tab"]:visible');
|
|
209
|
+
for (let i = 0; i < await btnLocators.count(); i++) {
|
|
210
|
+
const btn = btnLocators.nth(i);
|
|
211
|
+
const text = ((await btn.textContent()) ?? '[no text]').trim();
|
|
212
|
+
const before = await page.content();
|
|
213
|
+
try {
|
|
214
|
+
await btn.click({ timeout: 2000 });
|
|
215
|
+
await page.waitForTimeout(500); // Brief wait for async response
|
|
216
|
+
buttons.push({ text, responded: (await page.content()) !== before, error: null });
|
|
217
|
+
} catch (err) { buttons.push({ text, responded: false, error: String(err) }); }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const links: InteractionReport['links'] = [];
|
|
221
|
+
const linkLocators = page.locator('a:visible');
|
|
222
|
+
for (let i = 0; i < await linkLocators.count(); i++) {
|
|
223
|
+
const link = linkLocators.nth(i);
|
|
224
|
+
links.push({ text: ((await link.textContent()) ?? '').trim(), href: await link.getAttribute('href') });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const formFields: InteractionReport['formFields'] = [];
|
|
228
|
+
const inputs = page.locator('input:visible, textarea:visible, select:visible');
|
|
229
|
+
for (let i = 0; i < await inputs.count(); i++) {
|
|
230
|
+
const input = inputs.nth(i);
|
|
231
|
+
const label = (await input.getAttribute('aria-label')) ?? (await input.getAttribute('placeholder'))
|
|
232
|
+
?? (await input.getAttribute('name')) ?? '[unlabeled]';
|
|
233
|
+
const type = (await input.getAttribute('type')) ?? 'text';
|
|
234
|
+
try {
|
|
235
|
+
await input.fill('test-review-input');
|
|
236
|
+
await input.blur();
|
|
237
|
+
const msg = await input.evaluate((el) => (el as HTMLInputElement).validationMessage || null);
|
|
238
|
+
formFields.push({ label, type, acceptedInput: true, validationMessage: msg });
|
|
239
|
+
} catch (err) { formFields.push({ label, type, acceptedInput: false, validationMessage: String(err) }); }
|
|
240
|
+
}
|
|
241
|
+
return { route, buttons, links, formFields };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Security Inspection ─────────────────────────────
|
|
245
|
+
// Kenobi's toolkit: cookies, CORS, CSP. Runtime security inspection.
|
|
246
|
+
|
|
247
|
+
export async function inspectCookies(context: BrowserContext): Promise<CookieReport> {
|
|
248
|
+
const raw = await context.cookies();
|
|
249
|
+
const findings: string[] = [];
|
|
250
|
+
const cookies = raw.map((c) => {
|
|
251
|
+
if (!c.secure) findings.push(`Cookie "${c.name}" missing Secure flag`);
|
|
252
|
+
if (!c.httpOnly && /sess|token|auth/i.test(c.name)) findings.push(`Session cookie "${c.name}" missing HttpOnly`);
|
|
253
|
+
if (c.sameSite === 'None' && !c.secure) findings.push(`Cookie "${c.name}" SameSite=None without Secure`);
|
|
254
|
+
if (c.expires === -1 && /sess|token|auth/i.test(c.name)) findings.push(`Session cookie "${c.name}" no expiry`);
|
|
255
|
+
return { name: c.name, domain: c.domain, secure: c.secure, httpOnly: c.httpOnly, sameSite: c.sameSite, expires: c.expires };
|
|
256
|
+
});
|
|
257
|
+
return { cookies, findings };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function captureCORSHeaders(page: Page, apiPattern: string): Promise<CORSReport> {
|
|
261
|
+
const report: CORSReport = { url: apiPattern, allowOrigin: null, allowCredentials: null, allowMethods: null, allowHeaders: null, findings: [] };
|
|
262
|
+
const respPromise = page.waitForResponse((r) => r.url().includes(apiPattern), { timeout: 5000 }).catch(() => null);
|
|
263
|
+
await page.evaluate(async (p) => { try { await fetch(p, { method: 'OPTIONS', mode: 'cors' }); } catch { /* expected */ } }, apiPattern);
|
|
264
|
+
const resp = await respPromise;
|
|
265
|
+
if (resp) {
|
|
266
|
+
const h = resp.headers();
|
|
267
|
+
report.allowOrigin = h['access-control-allow-origin'] ?? null;
|
|
268
|
+
report.allowCredentials = h['access-control-allow-credentials'] ?? null;
|
|
269
|
+
report.allowMethods = h['access-control-allow-methods'] ?? null;
|
|
270
|
+
report.allowHeaders = h['access-control-allow-headers'] ?? null;
|
|
271
|
+
}
|
|
272
|
+
if (report.allowOrigin === '*') report.findings.push('CORS allows all origins (*)');
|
|
273
|
+
if (report.allowOrigin === '*' && report.allowCredentials === 'true') report.findings.push('CRITICAL: Wildcard origin + credentials');
|
|
274
|
+
if (report.allowMethods?.includes('*')) report.findings.push('CORS allows all methods');
|
|
275
|
+
return report;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function captureCSPViolations(page: Page): Promise<CSPViolation[]> {
|
|
279
|
+
// Inject listener BEFORE navigating — call this, then goto(), then read violations
|
|
280
|
+
await page.addInitScript(() => {
|
|
281
|
+
const v: Array<{ blockedURI: string; violatedDirective: string; originalPolicy: string }> = [];
|
|
282
|
+
document.addEventListener('securitypolicyviolation', (e) => {
|
|
283
|
+
v.push({ blockedURI: e.blockedURI, violatedDirective: e.violatedDirective, originalPolicy: e.originalPolicy });
|
|
284
|
+
});
|
|
285
|
+
Object.defineProperty(window, '__cspViolations', { get: () => v, configurable: true });
|
|
286
|
+
});
|
|
287
|
+
// Read accumulated violations (call after navigation)
|
|
288
|
+
return page.evaluate(() => {
|
|
289
|
+
const w = window as unknown as Record<string, unknown>;
|
|
290
|
+
return (w.__cspViolations as CSPViolation[] | undefined) ?? [];
|
|
291
|
+
});
|
|
292
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Accessible Combobox with Value Source Management
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Two distinct value sources: display value (closed) and search query (open)
|
|
6
|
+
* - Never let dropdown close events switch the value source mid-keystroke
|
|
7
|
+
* - ARIA combobox role with listbox, proper focus management
|
|
8
|
+
* - Keyboard nav: arrow keys, Enter to select, Escape to close
|
|
9
|
+
*
|
|
10
|
+
* The trap: A controlled combobox shows `displayValue` when closed and
|
|
11
|
+
* `searchQuery` when open. If the search function closes the dropdown
|
|
12
|
+
* (e.g., on short queries or no results), the input switches from
|
|
13
|
+
* `searchQuery` back to `displayValue` — wiping the user's keystrokes.
|
|
14
|
+
*
|
|
15
|
+
* Agents: Legolas (code), Samwise (a11y), Bilbo (copy)
|
|
16
|
+
*
|
|
17
|
+
* Framework adaptations:
|
|
18
|
+
* React: This file (hooks, controlled input)
|
|
19
|
+
* Vue: v-model with computed get/set for value source switching
|
|
20
|
+
* Svelte: Reactive declarations with $: for value derivation
|
|
21
|
+
* Django + HTMX: Server-filtered results via hx-get, debounced hx-trigger
|
|
22
|
+
*
|
|
23
|
+
* Field report #259: Combobox value source switching caused keystroke loss
|
|
24
|
+
* when dropdown closed during typing.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
28
|
+
|
|
29
|
+
// ── Types ────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
interface ComboboxOption {
|
|
32
|
+
id: string;
|
|
33
|
+
label: string; // Display text
|
|
34
|
+
searchText?: string; // Optional: separate text for search matching
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ComboboxProps {
|
|
38
|
+
options: ComboboxOption[];
|
|
39
|
+
value: string | null; // Selected option ID
|
|
40
|
+
onChange: (id: string | null) => void;
|
|
41
|
+
onSearch?: (query: string) => void; // For async/server-side search
|
|
42
|
+
placeholder?: string;
|
|
43
|
+
label: string; // Required — a11y
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
minSearchLength?: number; // Min chars before showing results (default: 1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── The Value Source Rule ────────────────────────────────
|
|
49
|
+
//
|
|
50
|
+
// WRONG: Derive input value from isOpen
|
|
51
|
+
// value={isOpen ? searchQuery : displayValue}
|
|
52
|
+
// // Closing the dropdown wipes searchQuery with displayValue
|
|
53
|
+
//
|
|
54
|
+
// RIGHT: Track value source explicitly, only switch on user actions
|
|
55
|
+
// - User types → source = 'search'
|
|
56
|
+
// - User selects option → source = 'display', close dropdown
|
|
57
|
+
// - User clicks away (blur) → source = 'display', close dropdown
|
|
58
|
+
// - User presses Escape → source = 'display', close dropdown
|
|
59
|
+
// - Dropdown closes from search logic → DO NOT switch source
|
|
60
|
+
//
|
|
61
|
+
// The key insight: the dropdown's open/closed state should NOT
|
|
62
|
+
// control the input's value source. Only explicit user intent should.
|
|
63
|
+
|
|
64
|
+
type ValueSource = 'search' | 'display';
|
|
65
|
+
|
|
66
|
+
export function Combobox({
|
|
67
|
+
options,
|
|
68
|
+
value,
|
|
69
|
+
onChange,
|
|
70
|
+
onSearch,
|
|
71
|
+
placeholder = 'Search...',
|
|
72
|
+
label,
|
|
73
|
+
disabled = false,
|
|
74
|
+
minSearchLength = 1,
|
|
75
|
+
}: ComboboxProps) {
|
|
76
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
77
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
78
|
+
const [valueSource, setValueSource] = useState<ValueSource>('display');
|
|
79
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
80
|
+
|
|
81
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
82
|
+
const listboxRef = useRef<HTMLUListElement>(null);
|
|
83
|
+
const listboxId = useRef(`combobox-listbox-${Math.random().toString(36).slice(2)}`);
|
|
84
|
+
|
|
85
|
+
// Derive display value from selected option
|
|
86
|
+
const selectedOption = options.find(o => o.id === value);
|
|
87
|
+
const displayValue = selectedOption?.label ?? '';
|
|
88
|
+
|
|
89
|
+
// The input shows search query OR display value based on explicit source
|
|
90
|
+
const inputValue = valueSource === 'search' ? searchQuery : displayValue;
|
|
91
|
+
|
|
92
|
+
// Filter options locally (skip if onSearch handles it server-side)
|
|
93
|
+
const filteredOptions = onSearch
|
|
94
|
+
? options // Server-side search — show whatever options are provided
|
|
95
|
+
: options.filter(o => {
|
|
96
|
+
if (searchQuery.length < minSearchLength) return false;
|
|
97
|
+
const text = o.searchText ?? o.label;
|
|
98
|
+
return text.toLowerCase().includes(searchQuery.toLowerCase());
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── User types → switch to search source, open dropdown ──
|
|
102
|
+
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
103
|
+
const query = e.target.value;
|
|
104
|
+
setSearchQuery(query);
|
|
105
|
+
setValueSource('search'); // Explicit: user is searching
|
|
106
|
+
setActiveIndex(-1);
|
|
107
|
+
|
|
108
|
+
if (query.length >= minSearchLength) {
|
|
109
|
+
setIsOpen(true);
|
|
110
|
+
onSearch?.(query);
|
|
111
|
+
}
|
|
112
|
+
// NOTE: Do NOT close the dropdown here even if query is short.
|
|
113
|
+
// Closing here would switch value source and wipe keystrokes.
|
|
114
|
+
// Let the empty results state handle "no results" display.
|
|
115
|
+
}, [minSearchLength, onSearch]);
|
|
116
|
+
|
|
117
|
+
// ── User selects → switch to display source, close ──
|
|
118
|
+
const handleSelect = useCallback((optionId: string) => {
|
|
119
|
+
onChange(optionId);
|
|
120
|
+
setValueSource('display'); // Explicit: user made a selection
|
|
121
|
+
setIsOpen(false);
|
|
122
|
+
setSearchQuery('');
|
|
123
|
+
setActiveIndex(-1);
|
|
124
|
+
inputRef.current?.focus();
|
|
125
|
+
}, [onChange]);
|
|
126
|
+
|
|
127
|
+
// ── User blurs → switch to display source, close ──
|
|
128
|
+
const handleBlur = useCallback((e: React.FocusEvent) => {
|
|
129
|
+
// Don't close if focus moved to the listbox
|
|
130
|
+
if (listboxRef.current?.contains(e.relatedTarget as Node)) return;
|
|
131
|
+
setValueSource('display');
|
|
132
|
+
setIsOpen(false);
|
|
133
|
+
setSearchQuery('');
|
|
134
|
+
setActiveIndex(-1);
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
// ── Keyboard navigation ──
|
|
138
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
139
|
+
switch (e.key) {
|
|
140
|
+
case 'ArrowDown':
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
if (!isOpen) {
|
|
143
|
+
setIsOpen(true);
|
|
144
|
+
setActiveIndex(0);
|
|
145
|
+
} else {
|
|
146
|
+
setActiveIndex(i => Math.min(i + 1, filteredOptions.length - 1));
|
|
147
|
+
}
|
|
148
|
+
break;
|
|
149
|
+
case 'ArrowUp':
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
setActiveIndex(i => Math.max(i - 1, 0));
|
|
152
|
+
break;
|
|
153
|
+
case 'Enter':
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
if (isOpen && activeIndex >= 0 && filteredOptions[activeIndex]) {
|
|
156
|
+
handleSelect(filteredOptions[activeIndex].id);
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
case 'Escape':
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
setValueSource('display'); // Explicit: user cancelled
|
|
162
|
+
setIsOpen(false);
|
|
163
|
+
setSearchQuery('');
|
|
164
|
+
setActiveIndex(-1);
|
|
165
|
+
break;
|
|
166
|
+
case 'Home':
|
|
167
|
+
if (isOpen) { e.preventDefault(); setActiveIndex(0); }
|
|
168
|
+
break;
|
|
169
|
+
case 'End':
|
|
170
|
+
if (isOpen) { e.preventDefault(); setActiveIndex(filteredOptions.length - 1); }
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}, [isOpen, activeIndex, filteredOptions, handleSelect]);
|
|
174
|
+
|
|
175
|
+
// Scroll active option into view
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (activeIndex >= 0 && listboxRef.current) {
|
|
178
|
+
const activeEl = listboxRef.current.children[activeIndex] as HTMLElement;
|
|
179
|
+
activeEl?.scrollIntoView({ block: 'nearest' });
|
|
180
|
+
}
|
|
181
|
+
}, [activeIndex]);
|
|
182
|
+
|
|
183
|
+
const activeDescendant = activeIndex >= 0
|
|
184
|
+
? `${listboxId.current}-option-${activeIndex}`
|
|
185
|
+
: undefined;
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div className="relative" onBlur={handleBlur}>
|
|
189
|
+
<label id={`${listboxId.current}-label`} className="block text-sm font-medium">
|
|
190
|
+
{label}
|
|
191
|
+
</label>
|
|
192
|
+
<input
|
|
193
|
+
ref={inputRef}
|
|
194
|
+
role="combobox"
|
|
195
|
+
aria-expanded={isOpen}
|
|
196
|
+
aria-controls={listboxId.current}
|
|
197
|
+
aria-labelledby={`${listboxId.current}-label`}
|
|
198
|
+
aria-activedescendant={activeDescendant}
|
|
199
|
+
aria-autocomplete="list"
|
|
200
|
+
value={inputValue}
|
|
201
|
+
onChange={handleInputChange}
|
|
202
|
+
onKeyDown={handleKeyDown}
|
|
203
|
+
onFocus={() => {
|
|
204
|
+
if (displayValue) {
|
|
205
|
+
// Pre-fill search with current value so user can refine
|
|
206
|
+
setSearchQuery(displayValue);
|
|
207
|
+
setValueSource('search');
|
|
208
|
+
setIsOpen(true);
|
|
209
|
+
}
|
|
210
|
+
}}
|
|
211
|
+
placeholder={placeholder}
|
|
212
|
+
disabled={disabled}
|
|
213
|
+
className="w-full border rounded px-3 py-2 focus-visible:ring-2 focus-visible:ring-blue-500"
|
|
214
|
+
/>
|
|
215
|
+
{isOpen && (
|
|
216
|
+
<ul
|
|
217
|
+
ref={listboxRef}
|
|
218
|
+
id={listboxId.current}
|
|
219
|
+
role="listbox"
|
|
220
|
+
aria-labelledby={`${listboxId.current}-label`}
|
|
221
|
+
className="absolute z-10 w-full mt-1 bg-white border rounded shadow-lg max-h-60 overflow-auto"
|
|
222
|
+
>
|
|
223
|
+
{filteredOptions.length === 0 ? (
|
|
224
|
+
<li className="px-3 py-2 text-gray-500" role="option" aria-selected={false}>
|
|
225
|
+
No results found
|
|
226
|
+
</li>
|
|
227
|
+
) : (
|
|
228
|
+
filteredOptions.map((option, index) => (
|
|
229
|
+
<li
|
|
230
|
+
key={option.id}
|
|
231
|
+
id={`${listboxId.current}-option-${index}`}
|
|
232
|
+
role="option"
|
|
233
|
+
aria-selected={option.id === value}
|
|
234
|
+
className={`px-3 py-2 cursor-pointer ${
|
|
235
|
+
index === activeIndex ? 'bg-blue-100' : ''
|
|
236
|
+
} ${option.id === value ? 'font-semibold' : ''}`}
|
|
237
|
+
onMouseDown={(e) => {
|
|
238
|
+
e.preventDefault(); // Prevent blur before select
|
|
239
|
+
handleSelect(option.id);
|
|
240
|
+
}}
|
|
241
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
242
|
+
>
|
|
243
|
+
{option.label}
|
|
244
|
+
</li>
|
|
245
|
+
))
|
|
246
|
+
)}
|
|
247
|
+
</ul>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Server-Side Search Example (async) ──────────────────
|
|
254
|
+
//
|
|
255
|
+
// function CityCombobox() {
|
|
256
|
+
// const [cities, setCities] = useState<ComboboxOption[]>([]);
|
|
257
|
+
// const [selected, setSelected] = useState<string | null>(null);
|
|
258
|
+
//
|
|
259
|
+
// const handleSearch = useCallback(async (query: string) => {
|
|
260
|
+
// const results = await fetch(`/api/cities?q=${encodeURIComponent(query)}`);
|
|
261
|
+
// const data = await results.json();
|
|
262
|
+
// setCities(data.map((c: any) => ({ id: c.id, label: c.displayName })));
|
|
263
|
+
// }, []);
|
|
264
|
+
//
|
|
265
|
+
// return (
|
|
266
|
+
// <Combobox
|
|
267
|
+
// options={cities}
|
|
268
|
+
// value={selected}
|
|
269
|
+
// onChange={setSelected}
|
|
270
|
+
// onSearch={handleSearch}
|
|
271
|
+
// label="City"
|
|
272
|
+
// placeholder="Search cities..."
|
|
273
|
+
// minSearchLength={2}
|
|
274
|
+
// />
|
|
275
|
+
// );
|
|
276
|
+
// }
|
|
277
|
+
//
|
|
278
|
+
// ── Django + HTMX Adaptation ────────────────────────────
|
|
279
|
+
//
|
|
280
|
+
// Server-filtered combobox without client-side JS framework:
|
|
281
|
+
//
|
|
282
|
+
// <input
|
|
283
|
+
// type="text"
|
|
284
|
+
// name="city"
|
|
285
|
+
// hx-get="/api/cities/"
|
|
286
|
+
// hx-trigger="input changed delay:300ms"
|
|
287
|
+
// hx-target="#city-results"
|
|
288
|
+
// hx-swap="innerHTML"
|
|
289
|
+
// role="combobox"
|
|
290
|
+
// aria-expanded="false"
|
|
291
|
+
// aria-controls="city-results"
|
|
292
|
+
// autocomplete="off"
|
|
293
|
+
// />
|
|
294
|
+
// <ul id="city-results" role="listbox"></ul>
|
|
295
|
+
//
|
|
296
|
+
// Server returns <li role="option"> fragments. Client-side JS
|
|
297
|
+
// (minimal, not a framework) handles: aria-expanded toggle,
|
|
298
|
+
// aria-activedescendant on arrow keys, selection on Enter.
|
|
299
|
+
// The value source trap still applies — use a hidden input for
|
|
300
|
+
// the selected ID and keep the visible input for display/search.
|