271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* paired-readonly-affordance-surfaces-smoke (ADR-0022, Part 116).
|
|
*
|
|
* Sister smoke to paired-readonly-lifecycle-smoke. Where the
|
|
* lifecycle smoke validates the persistence layer (read/write/clear
|
|
* round-trips through pairedSession.ts), THIS smoke pins down the
|
|
* affordance wiring at every write-action call site: each of the
|
|
* eight surfaces Part 114 left unwired must (a) import
|
|
* WriteBlockedReadOnly OR widen the gate to $hasAnySession, and
|
|
* (b) handle the paired-readonly case explicitly so paired users
|
|
* never see the misleading "session locked, unlock to continue"
|
|
* CTA or have a write affordance silently vanish.
|
|
*
|
|
* Sentinel-grep is the right tool here, same rationale as
|
|
* sally-walkthrough-smoke: the protections touch UI surfaces that
|
|
* need the full Svelte runtime to exercise, and a future refactor
|
|
* that accidentally drops the affordance — say, by inlining the
|
|
* gate via a different derived store — must be caught at smoke
|
|
* time and force the maintainer to update this file in the same
|
|
* commit. That's the audit-trail discipline we want.
|
|
*
|
|
* Coverage (each scenario maps 1:1 to a TARBALL.md Part-115-
|
|
* inventoried gap):
|
|
*
|
|
* 1. /my/orders page-shell three-way branch
|
|
* 2. /my/orders inline feature-bid affordance
|
|
* 3. /my/orders inline feedback affordance
|
|
* 4. /my/orders inline cancel-order affordance
|
|
* 5. /post/edit/[permlink] paired-readonly affordance
|
|
* 6. profile feedback_response affordance (own profile, isOwnProfile)
|
|
* 7. order-detail owner-actions affordance
|
|
* 8. /orderbook fee-rejected recovery link widened to $hasAnySession
|
|
* 9. /run-a-node operator_register three-way branch
|
|
* 10. +layout mobile-nav sign-in link widened to $hasAnySession
|
|
*
|
|
* Also asserts (the cross-cutting variant + locale piece):
|
|
* 11. WriteBlockedReadOnly component declares the 4 new variants
|
|
* 12. WriteBlockedReadOnly component has deepLink cases for them
|
|
* 13. en.json paired_readonly block carries the 4 new body strings
|
|
*
|
|
* Locale parity for the 4 new keys is enforced by the existing
|
|
* i18n-locale-parity-smoke; no need to re-walk all 10 locales here.
|
|
*
|
|
* Usage:
|
|
* cd apps/web && npx tsx scripts/paired-readonly-affordance-surfaces-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: '1 — /my/orders page-shell three-way branch (paired falls through, locked still blocks)',
|
|
file: 'src/routes/[lang]/my/orders/+page.svelte',
|
|
mustHave: [
|
|
"import { identity, isUnlocked, isPairedReadOnly, hasAnySession } from '$stores/identity'",
|
|
"import WriteBlockedReadOnly from '$components/WriteBlockedReadOnly.svelte'",
|
|
'{:else if !$isUnlocked && !$isPairedReadOnly}'
|
|
]
|
|
},
|
|
{
|
|
name: '2 — /my/orders inline feature_order affordance for paired users',
|
|
file: 'src/routes/[lang]/my/orders/+page.svelte',
|
|
mustHave: [
|
|
'{#if $isPairedReadOnly}',
|
|
'variant="feature_order"',
|
|
'orderPermlink={o.permlink}'
|
|
]
|
|
},
|
|
{
|
|
name: '3 — /my/orders inline feedback affordance for paired users (preserves permlink hash deep link)',
|
|
file: 'src/routes/[lang]/my/orders/+page.svelte',
|
|
mustHave: ['variant="feedback"', 'peer={blurtAccount}', 'orderPermlink={o.permlink}']
|
|
},
|
|
{
|
|
name: '4 — /my/orders inline cancel_order affordance for paired users',
|
|
file: 'src/routes/[lang]/my/orders/+page.svelte',
|
|
mustHave: ['variant="cancel_order"']
|
|
},
|
|
{
|
|
name: '5 — /post/edit/[permlink] paired-readonly affordance with permlink-preserving deep link',
|
|
file: 'src/routes/[lang]/post/edit/[permlink]/+page.svelte',
|
|
mustHave: [
|
|
"import WriteBlockedReadOnly from '$components/WriteBlockedReadOnly.svelte'",
|
|
"import { identity, isUnlocked, isPairedReadOnly } from '$stores/identity'",
|
|
'{#if $isPairedReadOnly}',
|
|
'<WriteBlockedReadOnly variant="post_order" orderPermlink={permlink} />'
|
|
]
|
|
},
|
|
{
|
|
name: '6 — profile feedback_response affordance for paired users on own profile',
|
|
file: 'src/routes/[lang]/[x+40][account=account]/+page.svelte',
|
|
mustHave: [
|
|
"import { isUnlocked, isPairedReadOnly } from '$stores/identity'",
|
|
'{#if isOwnProfile && fb.responses.length === 0}',
|
|
'variant="feedback_response"',
|
|
'peer={account}'
|
|
],
|
|
mustNotHave: [
|
|
// The pre-fix gate hid the reply affordance entirely for
|
|
// paired users. The fix is the three-way restructure: if
|
|
// this regresses to a single-line $isUnlocked gate that
|
|
// silently hides for paired, the smoke fails.
|
|
'isOwnProfile && $isUnlocked && fb.responses.length === 0'
|
|
]
|
|
},
|
|
{
|
|
name: '7 — order-detail owner-actions affordance for paired users (edit + cancel, permlink preserved)',
|
|
file: 'src/routes/[lang]/[x+40][account=account]/[permlink=permlink]/+page.svelte',
|
|
mustHave: [
|
|
"import WriteBlockedReadOnly from '$components/WriteBlockedReadOnly.svelte'",
|
|
"import { identity, isUnlocked, isPairedReadOnly } from '$stores/identity'",
|
|
'{#if $isPairedReadOnly}',
|
|
'variant="post_order"',
|
|
'variant="cancel_order"',
|
|
'orderPermlink={permlink}'
|
|
]
|
|
},
|
|
{
|
|
name: '8 — /orderbook fee-rejected recovery link widened to $hasAnySession (+ cp384 viewerHasOrdered gate)',
|
|
file: 'src/routes/[lang]/orderbook/+page.svelte',
|
|
mustHave: [
|
|
"import { isUnlocked, hasAnySession } from '$stores/identity'",
|
|
// cp384 #2: the link stays WIDENED to $hasAnySession (paired-readonly
|
|
// users still see it) but is now additionally gated on viewerHasOrdered
|
|
// — the "Posted an order but don't see it?" hint only makes sense once
|
|
// the viewer has actually posted an order (Ken's request). The gate is
|
|
// still BASED on $hasAnySession (NOT re-narrowed to $isUnlocked).
|
|
'{#if $hasAnySession && viewerAccount !== null && viewerHasOrdered}'
|
|
],
|
|
mustNotHave: [
|
|
// Pre-fix sentinel: the old gate was $isUnlocked &&
|
|
// viewerAccount !== null in the same block that contains
|
|
// the /my/orders#fee-status link. We assert the exact
|
|
// pre-fix shape no longer appears on the fee-status link.
|
|
'$isUnlocked && viewerAccount !== null}\n\t\t<p class="mb-4 text-xs text-ink-500'
|
|
]
|
|
},
|
|
{
|
|
name: '9 — /run-a-node operator_register three-way branch (signed-out / paired / unlocked)',
|
|
file: 'src/routes/[lang]/run-a-node/+page.svelte',
|
|
mustHave: [
|
|
"import WriteBlockedReadOnly from '$components/WriteBlockedReadOnly.svelte'",
|
|
"import { liveIdentity, isPairedReadOnly, hasAnySession } from '$stores/identity'",
|
|
'{#if !$hasAnySession}',
|
|
'{:else if $isPairedReadOnly}',
|
|
'<WriteBlockedReadOnly variant="operator_register" />'
|
|
],
|
|
mustNotHave: [
|
|
// Pre-fix sentinel: a !$liveIdentity gate ahead of the
|
|
// form would, for paired-readonly users, route them into
|
|
// the "please sign in" branch (since paired sessions have
|
|
// $liveIdentity === null).
|
|
'{#if !$liveIdentity}'
|
|
]
|
|
},
|
|
{
|
|
// cp205 intentionally REMOVED the duplicate mobile-nav sign-in
|
|
// link: the header <AvatarMenu/> (no md:hidden, so visible on
|
|
// mobile too) already renders the "Sign in / Register" CTA when
|
|
// signed out, and a mobile-nav text link showed that label
|
|
// twice. The paired-readonly sign-in affordance now lives in
|
|
// AvatarMenu. This pins the decision so a regression can't
|
|
// silently re-introduce the duplicate link.
|
|
name: '10 — +layout mobile-nav has NO duplicate sign-in link (AvatarMenu is the single CTA)',
|
|
file: 'src/routes/[lang]/+layout.svelte',
|
|
mustHave: [
|
|
'No sign-in link here on purpose',
|
|
'<AvatarMenu />'
|
|
]
|
|
},
|
|
{
|
|
name: '11 — WriteBlockedReadOnly declares the four new variants in its WriteVariant union',
|
|
file: 'src/lib/components/WriteBlockedReadOnly.svelte',
|
|
mustHave: [
|
|
"| 'feedback_response'",
|
|
"| 'operator_register'",
|
|
"| 'feature_order'",
|
|
"| 'cancel_order'"
|
|
]
|
|
},
|
|
{
|
|
name: '12 — WriteBlockedReadOnly has deepLink cases for the four new variants',
|
|
file: 'src/lib/components/WriteBlockedReadOnly.svelte',
|
|
mustHave: [
|
|
"variant === 'feedback_response'",
|
|
"variant === 'operator_register'",
|
|
"variant === 'feature_order'",
|
|
"variant === 'cancel_order'",
|
|
// And the permlink-preserving deep-link patterns.
|
|
'#feature=',
|
|
'#cancel=',
|
|
'/post/edit/',
|
|
'/my/orders#feedback='
|
|
]
|
|
},
|
|
{
|
|
name: '13 — en.json paired_readonly block carries the four new body strings',
|
|
file: 'src/lib/i18n/locales/en.json',
|
|
mustHave: [
|
|
'"write_blocked_feedback_response_body"',
|
|
'"write_blocked_operator_register_body"',
|
|
'"write_blocked_feature_order_body"',
|
|
'"write_blocked_cancel_order_body"'
|
|
]
|
|
}
|
|
];
|
|
|
|
let failures = 0;
|
|
let scenarios = 0;
|
|
|
|
function check(s: Scenario): void {
|
|
scenarios++;
|
|
const path = join(REPO, s.file);
|
|
let body: string;
|
|
try {
|
|
body = readFileSync(path, 'utf8');
|
|
} catch (err) {
|
|
failures++;
|
|
console.log(` ✗ ${s.name}`);
|
|
console.log(` could not read ${s.file}: ${err instanceof Error ? err.message : err}`);
|
|
return;
|
|
}
|
|
const missing = s.mustHave.filter((m) => !body.includes(m));
|
|
const regressed = (s.mustNotHave ?? []).filter((m) => body.includes(m));
|
|
if (missing.length === 0 && regressed.length === 0) {
|
|
console.log(` ✓ ${s.name}`);
|
|
return;
|
|
}
|
|
failures++;
|
|
console.log(` ✗ ${s.name}`);
|
|
if (missing.length > 0) {
|
|
console.log(` missing sentinel(s):`);
|
|
for (const m of missing) console.log(` - ${m}`);
|
|
}
|
|
if (regressed.length > 0) {
|
|
console.log(` regressed sentinel(s) (pre-fix pattern reappeared):`);
|
|
for (const m of regressed) console.log(` - ${m}`);
|
|
}
|
|
}
|
|
|
|
console.log('paired-readonly-affordance-surfaces smoke:\n');
|
|
for (const s of SCENARIOS) check(s);
|
|
|
|
console.log(`\n${scenarios} scenarios, ${failures} failed`);
|
|
if (failures > 0) {
|
|
console.error('paired-readonly-affordance-surfaces-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-walkthrough was
|
|
// added Part 68/69 but used a custom `N passed, M failed` format
|
|
// that the runner could not parse and was undercounted by 22 for
|
|
// ~20 Parts before being caught.
|
|
console.log(`✓ all ${SCENARIOS.length} paired-readonly-affordance-surfaces scenarios passed`);
|