Files
9edfdc94bb feat(web): one-shot clickthrough tests with CI video preview (#8043)
* 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]>
2026-06-09 17:47:54 +02:00

359 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()