morphit/apps/web/scripts/sally-walkthrough-smoke.ts

288 lines
10 KiB
TypeScript

#!/usr/bin/env tsx
/**
* sally-walkthrough-smoke.
*
* Structural smokes for the Sally walkthrough fixes from the
* Part 68 hardening pass. Each scenario locks in a specific
* UX or security protection by greping the source for the
* sentinel pattern that proves the fix is still wired. If a
* later refactor accidentally removes the protection, the
* smoke fails loudly.
*
* Sentinel-grep is a deliberate choice over runtime testing:
* - The fixes touch UI surfaces that need a real DOM /
* reactive Svelte runtime to exercise. The per-fix smoke
* would otherwise need the whole svelte-kit harness, which
* is excessive for "did this line still exist."
* - Future bug-fixes that change WHICH file holds the
* protection (e.g. extracting to a helper) are caught and
* the maintainer is forced to update this file in the same
* commit. That's the audit trail we want.
*
* Coverage:
* H1 password not cleared on user-input errors
* H2 import → settings session-flag handoff
* H4 orderbook needs-account banner
* H6 backup-keys show-seed flow exists
* H7 /support is no longer a one-card bounce
* H8 AvatarMenu has View my profile
* H9 per-order syndicate checkbox with selling-point pitch
* (the separate LeaveFeedbackForm first-trade box was
* removed in cp399 — announcing is still on by default,
* controlled on the post form + in Settings)
* L8 /post broadcasts run redactPrivateKeys() over region +
* payment-method entries (in addition to terms)
* M3 waiver asset-locked hint renders ABOVE the chip row
* M9 /explorer/account uses recursive setTimeout backoff,
* not setInterval(POLL_MS)
*
* Usage:
* cd apps/web && npx tsx scripts/sally-walkthrough-smoke.ts
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const REPO = join(import.meta.dirname, '..');
interface Scenario {
readonly name: string;
readonly file: string;
/** Substrings that must ALL appear in the file. */
readonly mustHave: readonly string[];
/** Substrings that must NOT appear (regression sentinels).
* Pre-fix patterns that the fix removed; if they reappear
* the protection regressed. */
readonly mustNotHave?: readonly string[];
}
const SCENARIOS: readonly Scenario[] = [
{
name: 'H1 — onboarding/import retains password on user-input errors',
file: 'src/routes/[lang]/onboarding/import/+page.svelte',
mustHave: ['Sally finding H1', "verdict.kind === 'wrong-role'"],
// Pre-fix the wrong-role / account-not-found branches each
// reset both password fields. Verify those resets are gone.
mustNotHave: [
"// Hygiene: clear passwords on this early-exit branch.\n\t\t\t\tpostingNewPassword = '';"
]
},
{
name: 'H2 — import flow writes session flag for settings banner',
file: 'src/routes/[lang]/onboarding/import/+page.svelte',
mustHave: ['Sally finding H2', 'morphit.import.needs_account_name']
},
{
name: 'H2 — settings reads-and-clears the import banner flag',
file: 'src/routes/[lang]/settings/+page.svelte',
mustHave: [
'morphit.import.needs_account_name',
'needsAccountNameBanner',
'settings.import_landing_banner.title'
]
},
{
name: 'H4 — orderbook shows needs-account banner when unlocked but unregistered',
file: 'src/routes/[lang]/orderbook/+page.svelte',
mustHave: [
'Sally finding H4',
'orderbook.needs_account.title',
'$isUnlocked && viewerAccount === null'
]
},
{
name: 'H4 — orderbook viewerAccount is reactive ($derived), not const',
file: 'src/routes/[lang]/orderbook/+page.svelte',
mustHave: ['const viewerAccount = $derived.by'],
mustNotHave: ['const viewerAccount: string | null = getUserBlurtAccount();']
},
{
name: 'H6 — backup-keys has show-seed flow with password gate',
file: 'src/routes/[lang]/backup-keys/+page.svelte',
mustHave: [
'Sally finding H6',
'backup_keys.show_seed.heading',
'mnemonicForBackup',
'decryptIdentity',
// FullIdentity must be wiped after extracting the
// mnemonic — guards against the 12-word recovery
// bytes lingering in component state.
'wipeFullIdentity(id)'
]
},
{
name: 'H7 — /support is a real page, not a one-card bounce to /faq',
file: 'src/routes/[lang]/support/+page.svelte',
mustHave: [
'support.heading',
'support.faq.heading',
'support.operator.heading',
'support.security.heading',
'support.self_host.heading'
],
// Pre-Part-68 the entire body was a single card with
// "still need help?" text. That one phrase is gone.
mustNotHave: ['faq.still_need_help']
},
{
name: 'H8 — AvatarMenu has View my profile entry',
file: 'src/lib/components/AvatarMenu.svelte',
mustHave: ['Sally finding H8', 'avatar_menu.view_my_profile', 'goToMyProfile', 'canViewProfile']
},
{
name: 'H9 — per-order syndicate checkbox has selling-point pitch',
file: 'src/routes/[lang]/post/+page.svelte',
mustHave: ['syndicate.opt_in_pitch']
},
{
name: 'L8 — /post redacts private keys from region + payment-method entries',
file: 'src/routes/[lang]/post/+page.svelte',
mustHave: [
'Sally finding L8',
'redactPrivateKeys(region.trim())',
'paymentMethods.map((pm) => redactPrivateKeys(pm))'
]
},
{
name: 'M3 — waiver asset-locked hint renders ABOVE the chip row',
file: 'src/routes/[lang]/post/+page.svelte',
mustHave: ['Sally finding M3', 'post_order.form.waiver_asset_hint']
},
{
name: 'M9 — /explorer/account uses backoff, not naive setInterval',
file: 'src/routes/[lang]/explorer/account/[name=account]/+page.svelte',
mustHave: ['Sally finding M9', 'POLL_MS_BASE', 'POLL_MS_MAX', 'currentPollMs'],
mustNotHave: [
"setInterval(() => {\n\t\t\tif (typeof document !== 'undefined' && document.hidden) return;"
]
},
{
name: 'M12 — /instances pins current instance to top of list',
file: 'src/routes/[lang]/instances/+page.svelte',
mustHave: ['Sally finding M12', 'if (aIsCurrent && !bIsCurrent) return -1']
},
{
name: 'L13 — XMR jitter shows explicit guidance in BOTH on/off states',
file: 'src/lib/components/AddressShareModal.svelte',
mustHave: [
'Sally finding L13',
'chat.address.xmr_jitter_send_exact_hint',
'chat.address.xmr_jitter_off_warning_heading',
'chat.address.xmr_jitter_off_warning_body'
]
},
// ─── Part 69 second-pass scenarios ─────────────────────────────
{
name: 'DL1 — /download direct-APK link no longer points at /morphit.apk',
file: 'src/routes/[lang]/download/+page.svelte',
mustHave: ['Sally finding DL1', 'https://git.agorise.net/agorise/morphit/releases'],
// Pre-Part-69 the direct-download went to /morphit.apk on
// the local origin — 404 on every instance that didn't
// manually drop the APK in static/.
mustNotHave: ["id: 'direct', url: '/morphit.apk'"]
},
{
name: 'RAN2 — /run-a-node uses Forgejo URL syntax (not GitLab /-/blob/)',
file: 'src/routes/[lang]/run-a-node/+page.svelte',
mustHave: [
'Sally finding RAN2',
'git.agorise.net/agorise/morphit/src/branch/main/docs/RUN-A-MORPHIT-NODE.md',
'git.agorise.net/agorise/morphit/src/branch/main/docs/OPERATIONS.md'
],
mustNotHave: ['git.agorise.net/agorise/morphit/-/blob/']
},
{
name: 'ATI1 — /about-this-instance warns "type don\'t click" for known-good URLs',
file: 'src/routes/[lang]/about-this-instance/+page.svelte',
mustHave: [
'Sally finding ATI1',
'about_this_instance.worried.type_warning_heading',
'about_this_instance.worried.type_warning_body',
// select-all class on the URLs so they copy cleanly
'select-all'
]
},
{
name: 'CMP1 — /compare offers re-run without retyping URL',
file: 'src/routes/[lang]/compare/+page.svelte',
mustHave: ['Sally finding CMP1', 'compare.button.rerun']
},
{
name: 'CMP2 — /compare order references are clickable links',
file: 'src/routes/[lang]/compare/+page.svelte',
mustHave: [
'Sally finding CMP2',
'`/@${o.account}/${o.permlink}`',
// Part 70 hardening: hardened cross-instance links
// through safeInstanceOrigin().
'safeInstanceOrigin(otherOrigin)',
'`${safeOther}/@${o.account}/${o.permlink}`'
],
// Pre-Part-69 they were plain mono text spans.
mustNotHave: ['<li class="font-mono text-xs break-all">']
},
{
name: "H3 — voucher path warns user they're leaving Morphit + plain-text fallback",
file: 'src/routes/[lang]/onboarding/register-name/+page.svelte',
mustHave: ['Sally finding H3 follow-up', 'daily_ceiling_voucher_external_warning']
},
{
name: 'OPS2 — /operators inline validateContactUrl documented as intentional',
file: 'src/routes/[lang]/operators/+page.svelte',
mustHave: [
'Sally finding OPS2',
// Confirm the inline helper still https-only (stricter
// than the shared safeContactUrl).
"u.protocol !== 'https:'"
]
}
];
let failed = 0;
let passed = 0;
console.log('sally-walkthrough smoke:\n');
for (const sc of SCENARIOS) {
const path = join(REPO, sc.file);
let body: string;
try {
body = readFileSync(path, 'utf8');
} catch (err) {
console.error(`${sc.name}`);
console.error(` file not readable: ${sc.file}`);
console.error(` ${err}`);
failed++;
continue;
}
const missing = sc.mustHave.filter((s) => !body.includes(s));
const present = (sc.mustNotHave ?? []).filter((s) => body.includes(s));
if (missing.length === 0 && present.length === 0) {
console.log(`${sc.name}`);
passed++;
} else {
console.error(`${sc.name}`);
for (const s of missing) {
console.error(` MUST HAVE (not found): ${JSON.stringify(s.slice(0, 100))}`);
}
for (const s of present) {
console.error(` MUST NOT HAVE (found): ${JSON.stringify(s.slice(0, 100))}`);
}
failed++;
}
}
console.log(`\n${passed} passed, ${failed} failed (${SCENARIOS.length} total)`);
if (failed > 0) {
console.error('\nsally-walkthrough smoke FAILED');
process.exit(1);
}
// Canonical success line — run-smokes.sh greps for `^✓ all` to tally
// scenarios. Without this, the runner counts this smoke as 0 scenarios
// even when it passes, which silently undercounts the smoke total. See
// J-2 finding (Part 87): sally was added Part 68/69 but used a custom
// `N passed, M failed (T total)` format that the runner could not parse.
console.log(`✓ all ${SCENARIOS.length} sally-walkthrough scenarios passed`);