morphit/apps/web/scripts/chat-immersive-layout-smoke.ts

135 lines
4.7 KiB
TypeScript

#!/usr/bin/env tsx
/**
* chat-immersive-layout-smoke (cp402 [9]).
*
* Pins the mobile chat layout fix. The chat CONVERSATION route is an
* immersive full-viewport view: ConversationView fills the space below
* the sticky header with its composer pinned + always visible, and the
* marketing footer is suppressed. Before the fix the chat used a FIXED
* `h-[100svh]` container which — stacked below the sticky header (taller
* on mobile) and above the footer — overflowed the viewport and pushed
* the Send button below the fold.
*
* Invariants guarded (source-assertion — the layout math needs a real
* viewport to see, so a smoke pins the wiring against regression):
*
* • The [lang] layout detects the chat conversation route by PATHNAME
* SHAPE (three segments, `chat` in the middle) — so non-chat routes
* are untouched.
* • On that route ONLY: the root gets a definite height, <main> becomes
* a min-h-0 flex column, and the footer is suppressed. Non-chat
* routes keep min-h-[100dvh] + footer.
* • ConversationView + the loading shell FILL the flex column
* (flex-1 min-h-0) instead of a fixed `h-[100svh]` (regression
* sentinel: the fixed height must not come back).
* • The composer lays the textarea + Send on ONE row (Send visible
* without the textarea/button stack pushing it down).
*
* Usage:
* cd apps/web && npx tsx scripts/chat-immersive-layout-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;
readonly mustHave: readonly string[];
readonly mustNotHave?: readonly string[];
}
const LAYOUT = 'src/routes/[lang]/+layout.svelte';
const CONV = 'src/lib/components/ConversationView.svelte';
const COMPOSER = 'src/lib/components/ChatComposer.svelte';
const CHAT_PAGE = 'src/routes/[lang]/chat/[peer=account]/+page.svelte';
const SCENARIOS: readonly Scenario[] = [
{
name: '1 — layout detects the chat conversation route by pathname shape (3 segments, chat middle)',
file: LAYOUT,
mustHave: [
'const isImmersiveChat = $derived.by(() => {',
"const parts = ($page.url?.pathname ?? '').split('/').filter(Boolean);",
"return parts.length === 3 && parts[1] === 'chat';"
]
},
{
name: '2 — root height is definite ONLY on chat; non-chat keeps min-h-[100dvh]',
file: LAYOUT,
mustHave: ["{isImmersiveChat ? 'h-[100svh]' : 'min-h-[100dvh]'}"]
},
{
name: '3 — <main> becomes a min-h-0 flex column ONLY on chat',
file: LAYOUT,
mustHave: ["{isImmersiveChat ? 'flex min-h-0 flex-col' : ''}"]
},
{
name: '4 — footer is suppressed on the immersive chat route',
file: LAYOUT,
mustHave: ['{#if !isImmersiveChat}', '<footer']
},
{
name: '5 — ConversationView FILLS the flex column (flex-1 min-h-0), not a fixed viewport height',
file: CONV,
mustHave: ['<div class="chat-conversation flex min-h-0 flex-1 flex-col">'],
mustNotHave: ['chat-conversation flex h-[100svh]']
},
{
name: '6 — composer lays the textarea + Send on ONE row',
file: COMPOSER,
mustHave: ['<div class="flex items-center gap-2">', 'class="flex-1"']
},
{
name: '7 — chat-route loading shell FILLS instead of a fixed viewport height',
file: CHAT_PAGE,
mustHave: ['flex min-h-0 flex-1 flex-col items-center justify-center'],
mustNotHave: ['h-[100svh]']
}
];
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(` - ${JSON.stringify(m)}`);
}
if (regressed.length > 0) {
console.log(` regressed sentinel(s) (pre-fix pattern reappeared):`);
for (const m of regressed) console.log(` - ${JSON.stringify(m)}`);
}
}
console.log('chat-immersive-layout smoke:\n');
for (const s of SCENARIOS) check(s);
console.log(`\n${scenarios} scenarios, ${failures} failed`);
if (failures > 0) {
console.error('chat-immersive-layout-smoke FAILED');
process.exit(1);
}
// Canonical success line — run-smokes.sh greps for `^✓ all` to tally.
console.log(`✓ all ${SCENARIOS.length} chat-immersive-layout scenarios passed`);