morphit/apps/web/scripts/chat-pdf-export-smoke.ts

137 lines
6 KiB
TypeScript

#!/usr/bin/env tsx
/**
* apps/web/scripts/chat-pdf-export-smoke.ts (cp404)
*
* Invariants over the LOCKED, courtroom-grade chat PDF export in
* ConversationView.svelte:
* • Built entirely client-side from decrypted in-memory messages — no
* plaintext leaves the browser, no server round-trip.
* • jsPDF is DYNAMICALLY imported (await import('jspdf')) so it is
* code-split and only fetched when a user actually exports — the
* footprint stays lean and it honours the lazy-load posture. There
* must be NO static top-level `import ... from 'jspdf'`.
* • The PDF is LOCKED: an owner password + a permission set that omits
* modification (view/print/copy only).
* • The REAL tamper-evidence: every message cites its on-chain
* transaction id (LocalMessage.trxId → "Blockchain proof"), with a
* "pending confirmation" fallback for not-yet-anchored messages.
* • Plain-language verification explainer + both parties' posting keys
* + the order it regards are included (legal-document structure).
* • Failed-decrypt messages render the encrypted placeholder.
* • The kebab exposes the export item.
* • source_trx_id is plumbed end-to-end so trxId can be populated.
* Plus i18n parity for the 15 chat.export.* keys across all 10 locales.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
const WEB = resolve(HERE, '..');
const read = (p: string) => readFileSync(resolve(WEB, p), 'utf8');
let total = 0;
let failed = 0;
const check = (name: string, cond: boolean, detail = '') => {
total++;
if (cond) console.log(` \u2713 ${name}`);
else {
failed++;
console.log(` \u2717 ${name}`);
if (detail) console.log(` ${detail}`);
}
};
const cv = read('src/lib/components/ConversationView.svelte');
check('1 exportChatToPdf function exists (async)', /async function exportChatToPdf\(/.test(cv));
check(
'2 jsPDF is dynamically imported (code-split / lazy)',
/await import\(['"]jspdf['"]\)/.test(cv)
);
check(
'3 NO static top-level jspdf import (would defeat code-splitting)',
!/^\s*import\s+.*from\s+['"]jspdf['"]/m.test(cv),
'jspdf must be dynamically imported only'
);
check(
'4 PDF is locked (owner password + restricted permissions, no modify)',
/encryption:\s*\{[\s\S]*ownerPassword[\s\S]*userPermissions:\s*\[['"]print['"],\s*['"]copy['"]\]/.test(cv)
);
check(
'5 owner password is randomly generated (crypto.getRandomValues)',
/crypto\.getRandomValues/.test(cv) && /ownerPassword/.test(cv)
);
check(
'6 every message cites its on-chain transaction id (proof)',
/m\.trxId/.test(cv) && /chat\.export\.proof_label/.test(cv)
);
check(
'7 pending fallback for not-yet-anchored messages',
/chat\.export\.pending_label/.test(cv)
);
check(
'8 legal structure: verify explainer + parties + posting keys + regarding',
/chat\.export\.verify_body/.test(cv) &&
/chat\.export\.parties_heading/.test(cv) &&
/chat\.export\.posting_key_label/.test(cv) &&
/(myPostingKey|peerPostingKey)/.test(cv) &&
/chat\.export\.regarding/.test(cv)
);
check('9 timestamps use the canonical UTC formatter', /formatDayMonthTime\(m\.createdAt\.toISOString\(\)\)/.test(cv));
check(
'10 failed-decrypt renders the encrypted placeholder',
/m\.decryptFailed \?[\s\S]*chat\.export\.encrypted/.test(cv)
);
check('11 kebab menu exposes the export item', /chat\.export\.menu_label/.test(cv) && /onclick=\{exportChatToPdf\}/.test(cv));
check('12 triggers a real download (doc.save)', /doc\.save\(/.test(cv));
// ─── source_trx_id plumbed end-to-end ─────────────────────────────
const rec = read('../../packages/indexer-client/src/index.ts');
check('13 ChatMessageRecord carries source_trx_id', /source_trx_id\?:\s*string/.test(rec));
const svc = read('src/lib/chat/chatService.ts');
check(
'14 LocalMessage.trxId populated from source_trx_id',
/trxId:\s*string \| null/.test(svc) && /source_trx_id \|\| null/.test(svc)
);
const helpers = read('../indexer/src/api/chatStreamHelpers.ts');
check('15 indexer wire row exposes source_trx_id', /source_trx_id:\s*string/.test(helpers));
// ─── jspdf present as a dependency (intentional, for the dynamic import) ─
const pkg = JSON.parse(read('package.json')) as { dependencies?: Record<string, string> };
check(
'16 jspdf is a pinned dependency',
typeof pkg.dependencies?.jspdf === 'string',
'the dynamic import needs jspdf installed'
);
// ─── i18n parity for the 15 chat.export keys ──────────────────────
const LOCALES_DIR = resolve(WEB, 'src/lib/i18n/locales');
const KEYS = [
'menu_label', 'title', 'regarding', 'exported_at', 'you', 'encrypted', 'no_messages',
'subtitle', 'parties_heading', 'posting_key_label', 'verify_heading', 'verify_body',
'proof_label', 'pending_label', 'footer'
];
const files = readdirSync(LOCALES_DIR).filter((f) => f.endsWith('.json'));
check('17 found the 10 expected locales', files.length === 10, `found ${files.length}`);
for (const lf of files) {
const loc = lf.replace('.json', '');
const d = JSON.parse(readFileSync(join(LOCALES_DIR, lf), 'utf8')) as {
chat?: { export?: Record<string, string> };
};
const e = d.chat?.export ?? {};
const missing = KEYS.filter((k) => typeof e[k] !== 'string' || e[k].length === 0);
check(`18.${loc} has all 15 chat.export keys`, missing.length === 0, `missing: ${missing.join(', ')}`);
check(`19.${loc} title keeps {peer}`, (e['title'] ?? '').includes('{peer}'), e['title'] ?? '');
check(`20.${loc} regarding keeps {summary}`, (e['regarding'] ?? '').includes('{summary}'), e['regarding'] ?? '');
check(`21.${loc} exported_at keeps {datetime}`, (e['exported_at'] ?? '').includes('{datetime}'), e['exported_at'] ?? '');
}
console.log('');
if (failed > 0) {
console.log(`\u2717 ${failed}/${total} chat-pdf-export scenarios failed`);
process.exit(1);
}
console.log(`\u2713 all ${total} chat-pdf-export scenarios passed`);