|
| 1 | +// Standalone retro script — uses Playwright lib directly (no test runner). |
| 2 | +// Captures screenshots + DOM evidence for every dashboard route. |
| 3 | +import { chromium } from 'playwright' |
| 4 | +import fs from 'fs' |
| 5 | + |
| 6 | +const OUT = '/tmp/dashboard-retro' |
| 7 | +const BASE = 'http://localhost:5173' |
| 8 | + |
| 9 | +const FAKE_TEAM = '00000000-1111-2222-3333-444444444444' |
| 10 | +const FAKE_USER = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' |
| 11 | +const FAKE_RESOURCES = [ |
| 12 | + { id: '11111111-aaaa-bbbb-cccc-000000000001', token: '11111111-aaaa-bbbb-cccc-000000000001', |
| 13 | + resource_type: 'postgres', name: 'flashcards-db', env: 'production', tier: 'hobby', |
| 14 | + status: 'active', storage_bytes: 47_500_000, storage_limit_bytes: 500_000_000, |
| 15 | + storage_exceeded: false, connections_in_use: 2, connections_limit: 5, |
| 16 | + created_at: '2026-04-22T18:42:11Z', team_id: FAKE_TEAM, expires_at: null }, |
| 17 | + { id: '22222222-aaaa-bbbb-cccc-000000000002', token: '22222222-aaaa-bbbb-cccc-000000000002', |
| 18 | + resource_type: 'redis', name: 'flashcards-cache', env: 'production', tier: 'hobby', |
| 19 | + status: 'active', storage_bytes: 1_200_000, storage_limit_bytes: 25_000_000, |
| 20 | + storage_exceeded: false, connections_in_use: 1, connections_limit: 5, |
| 21 | + created_at: '2026-04-22T18:42:25Z', team_id: FAKE_TEAM, expires_at: null }, |
| 22 | +] |
| 23 | + |
| 24 | +function json(body) { |
| 25 | + return { status: 200, contentType: 'application/json', body: JSON.stringify(body) } |
| 26 | +} |
| 27 | + |
| 28 | +async function installFakes(page) { |
| 29 | + await page.route('**/auth/me', (r) => r.fulfill(json({ |
| 30 | + ok: true, user_id: FAKE_USER, team_id: FAKE_TEAM, |
| 31 | + email: 'aanya@example.com', tier: 'hobby', trial_ends_at: null, |
| 32 | + }))) |
| 33 | + await page.route('**/api/v1/resources', (r) => { |
| 34 | + if (r.request().method() === 'GET') { |
| 35 | + return r.fulfill(json({ ok: true, items: FAKE_RESOURCES, total: FAKE_RESOURCES.length })) |
| 36 | + } |
| 37 | + return r.continue() |
| 38 | + }) |
| 39 | + for (const res of FAKE_RESOURCES) { |
| 40 | + await page.route(`**/api/v1/resources/${res.token}`, (r) => |
| 41 | + r.fulfill(json({ ok: true, item: res }))) |
| 42 | + await page.route(`**/api/v1/resources/${res.token}/credentials`, (r) => |
| 43 | + r.fulfill(json({ ok: true, id: res.id, token: res.token, |
| 44 | + resource_type: res.resource_type, env: res.env, |
| 45 | + connection_url: res.resource_type === 'postgres' |
| 46 | + ? 'postgres://usr:pw@pg.instanode.dev:5432/db' |
| 47 | + : 'redis://usr:pw@redis.instanode.dev:6379/0' }))) |
| 48 | + } |
| 49 | + await page.route('**/api/v1/vault/production', (r) => r.fulfill(json({ ok: true, keys: ['RAZORPAY_KEY_SECRET','OPENAI_API_KEY'] }))) |
| 50 | + await page.route('**/api/v1/vault/staging', (r) => r.fulfill(json({ ok: true, keys: [] }))) |
| 51 | + await page.route('**/api/v1/vault/development', (r) => r.fulfill(json({ ok: true, keys: [] }))) |
| 52 | + await page.route('**/api/v1/auth/api-keys', (r) => { |
| 53 | + if (r.request().method() === 'GET') { |
| 54 | + return r.fulfill(json({ ok: true, items: [ |
| 55 | + { id: 'k1111111-1111-1111-1111-111111111111', name: 'laptop', |
| 56 | + scopes: ['read','write'], created_at: '2026-05-01T10:00:00Z', |
| 57 | + last_used_at: '2026-05-09T18:00:00Z', revoked: false } |
| 58 | + ]})) |
| 59 | + } |
| 60 | + return r.continue() |
| 61 | + }) |
| 62 | + await page.route('**/api/v1/stacks', (r) => r.fulfill(json({ |
| 63 | + ok: true, items: [ |
| 64 | + { id: 'stk_flashcards', slug: 'flashcards', name: 'flashcards', |
| 65 | + status: 'running', env: 'production', tier: 'hobby', |
| 66 | + url: 'https://flashcards.deployment.instanode.dev', |
| 67 | + last_deploy_at: new Date(Date.now() - 12*60_000).toISOString(), |
| 68 | + build_duration_s: 38, created_at: new Date(Date.now() - 86400_000).toISOString() }, |
| 69 | + { id: 'stk_worker', slug: 'worker', name: 'worker', |
| 70 | + status: 'building', env: 'production', tier: 'hobby', |
| 71 | + url: null, last_deploy_at: new Date(Date.now() - 5*60_000).toISOString(), |
| 72 | + build_duration_s: 14, created_at: new Date(Date.now() - 2*86400_000).toISOString() }, |
| 73 | + ], total: 2, |
| 74 | + }))) |
| 75 | + await page.route('**/api/v1/team/members', (r) => r.fulfill(json({ |
| 76 | + ok: true, members: [ |
| 77 | + { id: 'u1', email: 'aanya@example.com', role: 'owner', display_name: 'Aanya', created_at: new Date().toISOString() } |
| 78 | + ] |
| 79 | + }))) |
| 80 | + await page.route('**/api/v1/team/invitations', (r) => r.fulfill(json({ ok: true, invitations: [] }))) |
| 81 | + await page.route('**/api/v1/billing', (r) => r.fulfill(json({ |
| 82 | + ok: true, billing: { |
| 83 | + tier: 'hobby', payment_network: 'visa', payment_last4: '4242', |
| 84 | + payment_exp_month: 12, payment_exp_year: 2028, |
| 85 | + current_period_end: new Date(Date.now() + 9*86400_000).toISOString() |
| 86 | + } |
| 87 | + }))) |
| 88 | + await page.route('**/api/v1/billing/invoices', (r) => r.fulfill(json({ ok: true, invoices: [] }))) |
| 89 | + await page.route('**/api/v1/activity', (r) => r.fulfill(json({ ok: true, items: [] }))) |
| 90 | +} |
| 91 | + |
| 92 | +async function audit(page, route, slug) { |
| 93 | + const screenshot = `${OUT}/${slug}.png` |
| 94 | + try { await page.screenshot({ path: screenshot, fullPage: true, timeout: 10000 }) } |
| 95 | + catch (e) { console.log(` screenshot fail: ${e.message}`) } |
| 96 | + |
| 97 | + const buttons = await page.$$eval('button', (btns) => |
| 98 | + btns.filter(b => !b.hasAttribute('onclick') && b.getAttribute('type') !== 'submit') |
| 99 | + .map(b => (b.getAttribute('aria-label') || b.textContent || '').trim()) |
| 100 | + .filter(Boolean) |
| 101 | + ).catch(() => []) |
| 102 | + const anchorsNoHref = await page.$$eval('a:not([href])', (as) => |
| 103 | + as.map(a => (a.getAttribute('aria-label') || a.textContent || '').trim()).filter(Boolean) |
| 104 | + ).catch(() => []) |
| 105 | + |
| 106 | + const text = await page.locator('body').innerText().catch(() => '') |
| 107 | + const count = (re) => (text.match(re) || []).length |
| 108 | + const shortcuts = { |
| 109 | + kbk: count(/⌘K/g), |
| 110 | + kbslash: count(/⌘\//g), |
| 111 | + sparkle: count(/✦/g), |
| 112 | + askAgent: count(/ask agent/gi), |
| 113 | + } |
| 114 | + const hard = { |
| 115 | + acme: count(/acme-corp|acme\.dev|acme\.com/gi), |
| 116 | + aanya: count(/aanya|kavya@|marcus/gi), |
| 117 | + flashcards: count(/flashcards|render-queue|events-store|cache-sessions/g), |
| 118 | + fixtures: count(/d_xY9z2k7m|r_5tYn2k|m_2a8f10|q_cb091f|a31fc8de/g), |
| 119 | + renewal: count(/9 days to renewal|auto-charges|May 19/g), |
| 120 | + comingSoon: count(/coming soon|mocked|stubbed/gi), |
| 121 | + } |
| 122 | + const placeholders = (text.match(/\b(TODO|WIP|CHANGE_ME|FIXME|XXX|mocked|stubbed|FIXTURE)\b/g) || []) |
| 123 | + const h1 = (await page.locator('h1').first().textContent().catch(() => '')) || '' |
| 124 | + |
| 125 | + console.log(`\n## ${route}`) |
| 126 | + console.log(` screenshot: ${screenshot}`) |
| 127 | + console.log(` H1: ${h1.trim().slice(0, 80)}`) |
| 128 | + console.log(` shortcuts: ⌘K=${shortcuts.kbk} ⌘/=${shortcuts.kbslash} ✦=${shortcuts.sparkle} "ask agent"=${shortcuts.askAgent}`) |
| 129 | + console.log(` hardcoded: acme=${hard.acme} aanya/kavya/marcus=${hard.aanya} fixtures=${hard.flashcards} fixture-IDs=${hard.fixtures} renewal=${hard.renewal} comingSoon=${hard.comingSoon}`) |
| 130 | + console.log(` placeholders: ${placeholders.join(', ') || '—'}`) |
| 131 | + console.log(` buttons-no-onclick: ${buttons.length}`) |
| 132 | + if (buttons.length) console.log(` sample: ${buttons.slice(0, 12).map(s => s.replace(/\s+/g,' ').slice(0,40)).join(' | ')}`) |
| 133 | + console.log(` anchors-no-href: ${anchorsNoHref.length}`) |
| 134 | + if (anchorsNoHref.length) console.log(` sample: ${anchorsNoHref.slice(0, 8).map(s => s.replace(/\s+/g,' ').slice(0,40)).join(' | ')}`) |
| 135 | + |
| 136 | + return { route, screenshot, h1: h1.trim(), shortcuts, hardcoded: hard, placeholders, buttonsNoHandler: buttons, anchorsNoHref } |
| 137 | +} |
| 138 | + |
| 139 | +async function main() { |
| 140 | + fs.mkdirSync(OUT, { recursive: true }) |
| 141 | + const browser = await chromium.launch() |
| 142 | + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }) |
| 143 | + await ctx.addInitScript(() => { localStorage.setItem('instanode.token', 'ink_FAKE_RETRO') }) |
| 144 | + const page = await ctx.newPage() |
| 145 | + await installFakes(page) |
| 146 | + |
| 147 | + const routes = [ |
| 148 | + ['/', 'marketing'], |
| 149 | + ['/pricing', 'pricing'], |
| 150 | + ['/for-agents', 'for-agents'], |
| 151 | + ['/docs', 'docs'], |
| 152 | + ['/blog', 'blog'], |
| 153 | + ['/status', 'status'], |
| 154 | + ['/use-cases', 'use-cases'], |
| 155 | + ['/login', 'login'], |
| 156 | + ['/claim?t=eyJhbGciOiJIUzI1NiJ9.fake.fake', 'claim'], |
| 157 | + // Re-arm the token (claim flow may clear it), then enter /app. |
| 158 | + ['__reset__', null], |
| 159 | + ['/app', 'app-overview'], |
| 160 | + ['/app/resources', 'app-resources'], |
| 161 | + [`/app/resources/${FAKE_RESOURCES[0].id}`, 'app-resource-detail'], |
| 162 | + ['/app/deployments', 'app-deployments'], |
| 163 | + ['/app/deployments/stk_flashcards', 'app-deployment-detail'], |
| 164 | + ['/app/billing', 'app-billing'], |
| 165 | + ['/app/team', 'app-team'], |
| 166 | + ['/app/vault', 'app-vault'], |
| 167 | + ['/app/settings', 'app-settings'], |
| 168 | + ['/app/stacks', 'app-stacks'], |
| 169 | + ['/app/agent', 'app-agent'], |
| 170 | + ['/app/contracts', 'app-contracts'], |
| 171 | + ] |
| 172 | + const results = [] |
| 173 | + for (const [route, slug] of routes) { |
| 174 | + if (route === '__reset__') continue |
| 175 | + try { |
| 176 | + const isApp = route.startsWith('/app') |
| 177 | + await page.goto(BASE + route, { waitUntil: 'domcontentloaded', timeout: 15000 }) |
| 178 | + if (isApp) { |
| 179 | + // After AuthGate runs once with no token (synchronously), re-seed and reload. |
| 180 | + const onLogin = await page.locator('h1').first().textContent().catch(() => '') |
| 181 | + if ((onLogin || '').includes('Sign in')) { |
| 182 | + await page.evaluate(() => localStorage.setItem('instanode.token', 'ink_FAKE_RETRO')) |
| 183 | + await page.goto(BASE + route, { waitUntil: 'domcontentloaded', timeout: 15000 }) |
| 184 | + } |
| 185 | + } |
| 186 | + await page.waitForTimeout(900) |
| 187 | + results.push(await audit(page, route, slug)) |
| 188 | + } catch (e) { |
| 189 | + console.log(`FAIL ${route}: ${e.message}`) |
| 190 | + } |
| 191 | + } |
| 192 | + fs.writeFileSync(`${OUT}/audit.json`, JSON.stringify(results, null, 2)) |
| 193 | + await ctx.close() |
| 194 | + await browser.close() |
| 195 | + console.log(`\nDONE — ${results.length} routes audited`) |
| 196 | +} |
| 197 | + |
| 198 | +main().catch((e) => { console.error(e); process.exit(1) }) |
0 commit comments