← back to NationalPaperHangers
tests/e2e-paper-comments.js
142 lines
// e2e click-test: paper-thread comment submission (UX backlog #3, tick 3).
//
// Flow:
// 1. Public /papers/de-gournay-earlham renders thread + login CTA.
// 2. Login as installer (atelier-bond-nyc).
// 3. Profile fetched — currentInstaller surfaces → comment form replaces CTA.
// 4. Submit a valid note → row inserted, trigger bumps thread.comment_count.
// 5. Page renders the new comment with the installer attribution.
// 6. Submit a too-short note → flash.error shown, no row written.
// 7. Cleanup: delete the test row, reset thread.comment_count.
//
// Skip cleanly against remote BASE.
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
const { chromium } = require('playwright-core');
const BASE = process.env.BASE || 'http://localhost:9765';
const SLUG = 'de-gournay-earlham';
const EMAIL = process.env.EMAIL || 'demo+bond@example.com';
const PW = process.env.PW || 'demo1234';
const IS_LOCAL = /localhost|127\.0\.0\.1/.test(BASE);
if (!IS_LOCAL) {
console.log(`[e2e-paper-comments] SKIP — BASE=${BASE} is remote`);
process.exit(78);
}
function findChromium() {
const paths = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
process.env.CHROME_PATH,
].filter(Boolean);
for (const p of paths) { try { require('fs').accessSync(p); return p; } catch {} }
return null;
}
let _db = null;
function db() { if (!_db) _db = require('../lib/db'); return _db; }
const NOTE = `E2E test note ${Date.now()} — soak time matters when seam-matching this paper; bring a backup brush.`;
const SHORT_NOTE = 'too short';
(async () => {
const exe = findChromium();
if (!exe) { console.error('FAIL: no Chromium'); process.exit(2); }
console.log(`[e2e-papers] using browser: ${exe}`);
const browser = await chromium.launch({ executablePath: exe, headless: true });
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
let pass = 0, fail = 0;
const assert = (cond, msg) => { if (cond) { console.log(` ✓ ${msg}`); pass++; } else { console.log(` ✗ ${msg}`); fail++; } };
let createdId = null;
try {
console.log(`\n[step 1] public ${BASE}/papers/${SLUG} renders unauth`);
await page.goto(`${BASE}/papers/${SLUG}`, { waitUntil: 'domcontentloaded' });
const html1 = await page.content();
assert(html1.includes('Earlham'), 'thread name renders');
assert(/Log in as a verified installer/i.test(html1), 'unauth CTA shown');
console.log(`\n[step 2] login as ${EMAIL}`);
const loginResp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
if (loginResp && loginResp.status() === 429) {
console.log(' ⊘ login rate-limit — skipping write phases');
process.exit(78);
}
await page.fill('input[name="email"]', EMAIL);
await page.fill('input[name="password"]', PW);
await Promise.all([
page.waitForURL(/\/admin/, { timeout: 8000 }),
page.click('form[action="/login"] button[type="submit"]')
]);
assert(page.url().includes('/admin'), 'logged in');
console.log(`\n[step 3] /papers/${SLUG} now shows the comment form`);
await page.goto(`${BASE}/papers/${SLUG}`, { waitUntil: 'domcontentloaded' });
const form = await page.$('#comment-form form[action$="/comments"]');
assert(!!form, 'comment form present for logged-in installer');
console.log(`\n[step 4] submit a valid note`);
const beforeCount = (await db().one(`SELECT comment_count FROM paper_threads WHERE slug=$1`, [SLUG])).comment_count;
await page.fill('#comment-form textarea[name="body"]', NOTE);
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 8000 }),
page.click('#comment-form button[type="submit"]')
]);
const newRow = await db().one(
`SELECT c.id, c.body, c.flagged, t.comment_count
FROM paper_comments c
JOIN paper_threads t ON t.id = c.thread_id
WHERE t.slug = $1 AND c.body = $2
ORDER BY c.id DESC LIMIT 1`,
[SLUG, NOTE]
);
assert(!!newRow, 'paper_comments row created');
if (newRow) createdId = newRow.id;
assert(newRow && !newRow.flagged, 'flagged=false by default');
assert(newRow && newRow.comment_count === beforeCount + 1, `trigger bumped comment_count ${beforeCount}→${newRow && newRow.comment_count}`);
const html2 = await page.content();
assert(html2.includes(NOTE.slice(0, 60)), 'new comment renders on detail page');
console.log(`\n[step 5] submit a too-short note → rejected`);
await page.goto(`${BASE}/papers/${SLUG}`, { waitUntil: 'domcontentloaded' });
await page.fill('#comment-form textarea[name="body"]', SHORT_NOTE);
// Bypass HTML5 minlength by removing the attribute first — backend must still reject
await page.evaluate(() => {
document.querySelector('textarea[name="body"]').removeAttribute('minlength');
});
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 8000 }),
page.click('#comment-form button[type="submit"]')
]);
const html3 = await page.content();
assert(/at least 20 characters/i.test(html3), 'too-short note rejected with flash.error');
const shortRow = await db().one(
`SELECT id FROM paper_comments WHERE body = $1`, [SHORT_NOTE]
);
assert(!shortRow, 'no row written for too-short note');
} catch (err) {
console.error(`\nFAIL — uncaught: ${err.message}`);
fail++;
} finally {
if (createdId) {
try {
await db().query(`DELETE FROM paper_comments WHERE id = $1`, [createdId]);
console.log(`\n[cleanup] deleted paper_comments id=${createdId}`);
} catch (e) { console.error(`[cleanup] ${e.message}`); }
}
await browser.close();
}
console.log(`\n[result] ${pass} pass · ${fail} fail`);
process.exit(fail > 0 ? 1 : 0);
})();