mirror of
https://github.com/luxfi/safe-wallet.git
synced 2026-10-30 15:19:21 +00:00
* feat(web): add Playwright one-shots suite, config project, and example clickthrough Introduce a per-PR "one-shots" Playwright suite for complete happy-path clickthroughs. Adds a dedicated 'one-shots' project that always records video, gates the local webServer so the suite can target a deployed preview via PLAYWRIGHT_BASE_URL, excludes one-shots from the default chromium project, adds a pw:test:oneshot script, and an example read-only dashboard clickthrough. Also fixes a Playwright >=1.49 fixture signature in test.fixture.ts. * tests: collapse one-shot dashboard example into a single clickthrough A one-shot is a single complete happy-path clickthrough that produces one cohesive video per run (later converted to a GIF in the PR), so the example is one sequential test rather than three separate ones. * tests: reuse homePage.goto and anchor balances URL regex in one-shot example * chore(web): add video_to_gif helper to convert one-shot clickthrough to GIF and MP4 * chore(ci): record one-shot clickthrough against PR preview and post GIF * fix(ci): correct video_to_gif.sh path in one-shot job The script lives at apps/web/scripts/github/, so from working-directory apps/web/e2e it must be invoked as ../scripts/github/video_to_gif.sh. The previous ./scripts path did not exist, which would have made every PR fall through to the 'no video produced' comment. Also make the preview wait-loop exit 0 explicitly after timeout. * feat(web): add local one-shot record wrapper and document the workflow Add pw:oneshot:record (scripts/record-oneshot.sh) so authors and AI agents can record + preview the one-shot clickthrough locally before opening a PR, reusing the shared video_to_gif.sh helper. Document the one-shots suite in the e2e README and AGENTS.md, and add a PR-template checklist item. CI remains the authoritative recorder/poster of the GIF. * fix(web): pin bare pw:test to chromium project so it skips one-shots Without an explicit --project, Playwright instantiates every project, including one-shots, which targets the local dev server and would fail/hang for anyone running the bare pw:test. The grep-based scripts are unaffected. * feat(web): add Playwright wallet-connect and SiWE helpers for e2e Add reusable wallet support to the e2e framework: a credentials util reading CYPRESS_WALLET_CREDENTIALS, a WalletPage page object that connects the private-key web3-onboard module via the connect UI and performs SiWE login, plus walletPage/credentials fixtures and a parser test. Enables one-shot clickthroughs to connect a wallet. * feat(web): add pay-later Safe creation one-shot demo with wallet connect Replace the read-only dashboard example with a wallet-connected demo one-shot that clicks through the new-Safe creation wizard ending in 'Pay later' (counterfactual, no on-chain tx). It exercises connectWallet and SiWE (selecting Pay later triggers the PK-module-signed sign-in). Pass CYPRESS_WALLET_CREDENTIALS to the one-shot CI job and document the wallet-connected one-shot pattern. * tests: harden pay-later one-shot with wizard-step checkpoints Wait for Next to enable on the set-name step (network resolves from the wallet) and for the owners-step form before advancing, reducing flakiness on a live preview. Clarify the SiWE helper doc. * fix(web): make one-shot wallet flow run in CI and address review - Install ffmpeg in the one-shot CI job (ubuntu-24.04 has none) so the video actually converts. - Harden connectWallet: target the visible connect-wallet-btn and retry until the onboard modal opens (the hidden header button was swallowing the open on /welcome/accounts). - video_to_gif.sh: use find -exec so no .webm yields a clear error instead of picking an arbitrary file; check ffmpeg presence. - credentials: only OWNER_4_PRIVATE_KEY is required, validate field types; add parser test cases. - SiWE waits now require a successful POST /v1/auth/verify (not any response). - Gate the GIF PR comment on the S3 upload succeeding; fallback covers upload failure too. * fix(web): open onboard reliably, dismiss cookie banner, dedupe GIF frames - connectWallet: click a single visible connect-wallet-btn and key the open-retry off the shadow-DOM 'Private key' button (the onboard-v2 host stays hidden), fixing the strict-mode click that was swallowed and left the modal closed. - Seed the correct terms version in the safePage fixture (the app gates consent on termsVersion === '1.3') and add acceptCookies() to dismiss the banner if it still shows. - video_to_gif.sh: mpdecimate + re-timed PTS so static page dwells collapse instead of producing a long static GIF. * tests: wait on URL for pay-later creation success with generous timeout The counterfactual create persists to the backend before routing to the new Safe; the persist can be slow on staging. Assert the /home?safe URL change (the definitive success signal) with a 45s timeout instead of the short default wait on the success dialog, and dismiss the dialog only if shown. * chore: share Stop hooks so Claude Code won't forget tests Add a checked-in .claude/settings.json (applies to every contributor's Claude Code) wiring two end-of-turn Stop hooks: - verify:changed (the one AGENTS.md documented but was configured nowhere), now also honouring the documented SKIP_VERIFY=1 escape (added to scripts/verify.mjs). - .claude/hooks/check-tests.mjs: advisory, never-blocking reminders that fire at most once per new change-set when web code lacks a one-shot clickthrough, or when significant source lacks a colocated unit test. Disable with SKIP_TEST_REMINDERS=1. Document both in AGENTS.md. * tests: end pay-later one-shot at creation-initiated (green, preview-safe) Counterfactual creation persists to a backend not available on ephemeral PR-preview deploys (the create button spins indefinitely), so asserting the success screen was perpetually flaky. Assert the flow up to creation being initiated (connect -> wizard -> SiWE -> Pay later -> create click -> button enters creating state). The recording still captures the full clickthrough including the create click. --------- Co-authored-by: Hanzo Dev <[email protected]>
359 lines
10 KiB
JavaScript
359 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* Parallel verify script for running all quality checks simultaneously.
|
||
*
|
||
* Usage:
|
||
* node scripts/verify.mjs [--workspace=web] [--changed] [--compact]
|
||
*
|
||
* Flags:
|
||
* --workspace=<name> Workspace to check (default: "web")
|
||
* --changed Only check files changed since the base branch
|
||
* --compact Capture output and print summary instead of streaming
|
||
*/
|
||
|
||
import { spawn, execFileSync } from 'node:child_process'
|
||
import { existsSync } from 'node:fs'
|
||
import path from 'node:path'
|
||
import { createInterface } from 'node:readline'
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Argument parsing
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const args = process.argv.slice(2)
|
||
|
||
// Allow opting out entirely (documented in AGENTS.md; used by the Stop hook).
|
||
if (process.env.SKIP_VERIFY === '1') {
|
||
console.log('verify: skipped (SKIP_VERIFY=1)')
|
||
process.exit(0)
|
||
}
|
||
|
||
function getFlag(name) {
|
||
const prefix = `--${name}=`
|
||
const entry = args.find((a) => a.startsWith(prefix))
|
||
if (entry) return entry.slice(prefix.length)
|
||
return undefined
|
||
}
|
||
|
||
const workspace = getFlag('workspace') ?? 'web'
|
||
const isCompact = args.includes('--compact')
|
||
const isChanged = args.includes('--changed')
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Changed-file detection (--changed mode)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function gitDiff(...diffArgs) {
|
||
try {
|
||
const out = execFileSync('git', ['diff', '--name-only', '--diff-filter=d', ...diffArgs], {
|
||
encoding: 'utf-8',
|
||
stdio: ['ignore', 'pipe', 'ignore'],
|
||
})
|
||
return out
|
||
.trim()
|
||
.split('\n')
|
||
.filter((l) => l.length > 0)
|
||
} catch (err) {
|
||
console.warn(`verify: git diff failed (${diffArgs.join(' ')}):`, err.message)
|
||
return []
|
||
}
|
||
}
|
||
|
||
function getMergeBase() {
|
||
const targets = ['dev', 'origin/dev', 'main']
|
||
for (const target of targets) {
|
||
try {
|
||
return execFileSync('git', ['merge-base', 'HEAD', target], {
|
||
encoding: 'utf-8',
|
||
stdio: ['ignore', 'pipe', 'ignore'],
|
||
}).trim()
|
||
} catch {
|
||
// target branch not found, try next
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
|
||
const SOURCE_EXT_RE = /\.(ts|tsx|js|jsx)$/
|
||
|
||
function getChangedFiles(ws, { quiet = false } = {}) {
|
||
const mergeBase = getMergeBase()
|
||
const committed = mergeBase ? gitDiff(`${mergeBase}...HEAD`) : []
|
||
const unstaged = gitDiff()
|
||
const staged = gitDiff('--cached')
|
||
|
||
const all = [...new Set([...committed, ...unstaged, ...staged])].filter((f) => SOURCE_EXT_RE.test(f))
|
||
|
||
if (!ws) return all
|
||
|
||
const prefix = `apps/${ws}/`
|
||
const outside = all.filter((f) => !f.startsWith(prefix))
|
||
const inside = all.filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length))
|
||
|
||
if (outside.length > 0 && !quiet) {
|
||
console.log(`ℹ ${outside.length} changed file(s) outside apps/${ws}/ — skipped`)
|
||
}
|
||
|
||
return inside
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Workspace name mapping
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const WORKSPACE_NAMES = {
|
||
web: '@safe-global/web',
|
||
mobile: '@safe-global/mobile',
|
||
}
|
||
|
||
const workspacePkg = WORKSPACE_NAMES[workspace] ?? `@safe-global/${workspace}`
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Missing test detection
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function detectMissingTests(changedFiles, workspace) {
|
||
const wsRoot = workspace ? path.join('apps', workspace) : ''
|
||
const warnings = []
|
||
|
||
const testablePatterns = [
|
||
/hooks\/use.+\.tsx?$/,
|
||
/services\/.+\.ts$/,
|
||
/components\/.+\.tsx$/,
|
||
/store\/.+Slice\.ts$/,
|
||
/utils\/.+\.ts$/,
|
||
]
|
||
|
||
const skipPatterns = [
|
||
/\.d\.ts$/,
|
||
/index\.ts$/,
|
||
/\.stories\.tsx?$/,
|
||
/\.test\.tsx?$/,
|
||
/constants\.ts$/,
|
||
/types\.ts$/,
|
||
/AUTO_GENERATED/,
|
||
]
|
||
|
||
for (const file of changedFiles) {
|
||
if (skipPatterns.some((p) => p.test(file))) continue
|
||
if (!testablePatterns.some((p) => p.test(file))) continue
|
||
|
||
const dir = path.dirname(file)
|
||
const base = path.basename(file, path.extname(file))
|
||
const ext = file.endsWith('.tsx') ? '.tsx' : '.ts'
|
||
|
||
const candidates = [
|
||
path.join(wsRoot, dir, `${base}.test${ext}`),
|
||
path.join(wsRoot, dir, '__tests__', `${base}.test${ext}`),
|
||
]
|
||
|
||
const testExists = candidates.some((c) => existsSync(c))
|
||
if (!testExists) {
|
||
warnings.push({ file, message: `expected ${base}.test${ext}` })
|
||
} else {
|
||
const testModified = changedFiles.some(
|
||
(f) => f.endsWith(`${base}.test${ext}`) && (f.includes(dir) || f.includes('__tests__')),
|
||
)
|
||
if (!testModified) {
|
||
warnings.push({ file, message: 'test exists but was not updated' })
|
||
}
|
||
}
|
||
}
|
||
|
||
return warnings
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Check definitions
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function buildChecks() {
|
||
if (isChanged) {
|
||
const changedFiles = getChangedFiles(workspace, { quiet: isCompact })
|
||
|
||
if (changedFiles.length === 0) {
|
||
console.log('No changed files detected — nothing to verify.')
|
||
process.exit(0)
|
||
}
|
||
|
||
if (changedFiles.length > 50) {
|
||
console.log(`Note: 50+ files changed (${changedFiles.length}) — running full verify.`)
|
||
return { checks: buildFullChecks(), changedFiles }
|
||
}
|
||
|
||
const lintableFiles = changedFiles.filter((f) => SOURCE_EXT_RE.test(f))
|
||
const testableFiles = changedFiles.filter((f) => !f.endsWith('.d.ts'))
|
||
|
||
const checks = [
|
||
{
|
||
label: 'types',
|
||
cmd: 'yarn',
|
||
args: ['workspace', workspacePkg, 'type-check'],
|
||
},
|
||
]
|
||
|
||
if (lintableFiles.length > 0) {
|
||
checks.push({
|
||
label: 'lint',
|
||
cmd: 'yarn',
|
||
args: ['workspace', workspacePkg, 'eslint', ...lintableFiles],
|
||
})
|
||
}
|
||
|
||
if (changedFiles.length > 0) {
|
||
checks.push({
|
||
label: 'prettier',
|
||
cmd: 'yarn',
|
||
args: ['workspace', workspacePkg, 'prettier', '--check', ...changedFiles],
|
||
})
|
||
}
|
||
|
||
if (testableFiles.length > 0) {
|
||
checks.push({
|
||
label: 'tests',
|
||
cmd: 'yarn',
|
||
args: [
|
||
'workspace',
|
||
workspacePkg,
|
||
'test',
|
||
'--findRelatedTests',
|
||
...testableFiles,
|
||
'--watchAll=false',
|
||
'--passWithNoTests',
|
||
],
|
||
})
|
||
}
|
||
|
||
return { checks, changedFiles }
|
||
}
|
||
|
||
return { checks: buildFullChecks(), changedFiles: null }
|
||
}
|
||
|
||
function buildFullChecks() {
|
||
return [
|
||
{
|
||
label: 'types',
|
||
cmd: 'yarn',
|
||
args: ['workspace', workspacePkg, 'type-check'],
|
||
},
|
||
{
|
||
label: 'lint',
|
||
cmd: 'yarn',
|
||
args: ['workspace', workspacePkg, 'lint'],
|
||
},
|
||
{
|
||
label: 'prettier',
|
||
cmd: 'yarn',
|
||
args: ['workspace', workspacePkg, 'prettier'],
|
||
},
|
||
{
|
||
label: 'tests',
|
||
cmd: 'yarn',
|
||
args: ['workspace', workspacePkg, 'test', '--watchAll=false'],
|
||
},
|
||
]
|
||
}
|
||
|
||
const { checks, changedFiles } = buildChecks()
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Runner
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function runCheck(check) {
|
||
return new Promise((resolve) => {
|
||
const stdio = isCompact ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe']
|
||
const child = spawn(check.cmd, check.args, { stdio })
|
||
|
||
const chunks = []
|
||
|
||
if (isCompact) {
|
||
// Capture all output silently
|
||
child.stdout.on('data', (d) => chunks.push(d))
|
||
child.stderr.on('data', (d) => chunks.push(d))
|
||
} else {
|
||
// Stream with prefixed lines
|
||
const prefix = `[${check.label}]`
|
||
|
||
const rlOut = createInterface({ input: child.stdout })
|
||
rlOut.on('line', (line) => {
|
||
process.stdout.write(`${prefix} ${line}\n`)
|
||
})
|
||
|
||
const rlErr = createInterface({ input: child.stderr })
|
||
rlErr.on('line', (line) => {
|
||
process.stderr.write(`${prefix} ${line}\n`)
|
||
})
|
||
}
|
||
|
||
child.on('close', (code) => {
|
||
resolve({
|
||
label: check.label,
|
||
code: code ?? 1,
|
||
output: Buffer.concat(chunks).toString(),
|
||
})
|
||
})
|
||
})
|
||
}
|
||
|
||
async function main() {
|
||
const results = await Promise.all(checks.map(runCheck))
|
||
|
||
const failed = results.filter((r) => r.code !== 0)
|
||
const allPassed = failed.length === 0
|
||
|
||
// Detect missing tests when running in --changed mode
|
||
const testWarnings = changedFiles ? detectMissingTests(changedFiles, workspace) : []
|
||
|
||
if (isCompact) {
|
||
console.log('-- verify -----')
|
||
|
||
// Print failed check output first so errors are visible
|
||
for (const r of failed) {
|
||
console.log(`\n--- ${r.label} (exit ${r.code}) ---`)
|
||
console.log(r.output.trimEnd())
|
||
}
|
||
|
||
// Print missing test warnings (one line each)
|
||
for (const w of testWarnings) {
|
||
console.log(`WARN Missing test: ${w.file}`)
|
||
}
|
||
|
||
// Summary line
|
||
const summary = results.map((r) => (r.code === 0 ? `PASS ${r.label}` : `FAIL ${r.label}`)).join(' ')
|
||
console.log(`\n${summary}`)
|
||
console.log('--------')
|
||
|
||
// Write failures to stderr so Claude Code stop hook can display them
|
||
if (!allPassed) {
|
||
const failedLabels = failed.map((r) => `${r.label} (exit ${r.code})`).join(', ')
|
||
process.stderr.write(`verify failed: ${failedLabels}\n`)
|
||
}
|
||
} else {
|
||
if (!allPassed) {
|
||
// In non-compact mode output was already streamed; just print summary
|
||
console.log('')
|
||
for (const r of failed) {
|
||
console.error(`FAIL: ${r.label} (exit ${r.code})`)
|
||
}
|
||
}
|
||
|
||
// Print missing test warnings block
|
||
if (testWarnings.length > 0) {
|
||
console.log('')
|
||
console.log('WARN Missing tests:')
|
||
for (const w of testWarnings) {
|
||
console.log(` ${w.file} -> ${w.message}`)
|
||
}
|
||
console.log(` ${testWarnings.length} changed file(s) have no corresponding tests.`)
|
||
console.log(' Run: yarn test:scaffold <file> to generate a test skeleton.')
|
||
}
|
||
}
|
||
|
||
process.exit(allPassed ? 0 : 1)
|
||
}
|
||
|
||
main()
|