Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions backend/e2e/newsletter-preview-overflow.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
const { test, expect } = require('@playwright/test');
const { createDocsCacheRoot } = require('./helpers/docs-content-root');
const { startOwnedTestServer, stopOwnedTestServer } = require('./helpers/isolated-capability-server');

// t2320u: long URLs and long inline code on the newsletter issue preview must
// wrap inside the card on every tab, and the shareable (code) preview shows
// only the reader-facing issue.

const LONG_URL = `https://courses.datatalks.club/register/production-ai-agents-generative-media/${'a'.repeat(140)}?utm_source=newsletter&utm_medium=email&utm_campaign=${'b'.repeat(80)}`;
const LONG_CODE = `PYTHONPATH=/home/orin/git/dataops-knowledge/tools/${'c'.repeat(120)}/read_course_inventory.py`;
const SCREENSHOTS = process.env.NEWSLETTER_PREVIEW_SCREENSHOT_DIR || '';

let server;
test.use({ screenshot: 'off', trace: 'off', video: 'off' });

test.beforeAll(async () => {
server = await startOwnedTestServer({
environment: { DTC_CACHE_ROOT: createDocsCacheRoot('newsletter-preview-overflow') },
});
});

test.afterAll(async () => {
await stopOwnedTestServer(server);
});

async function expectNoHorizontalOverflow(page, label) {
const metrics = await page.evaluate(() => {
const doc = document.documentElement;
const viewport = doc.clientWidth;
const offenders = [];
for (const node of document.querySelectorAll('#newsletter-preview-root *')) {
const rect = node.getBoundingClientRect();
if (rect.width === 0) continue;
// Content inside a horizontally scrollable box is allowed to extend past it.
let clipped = false;
for (let p = node.parentElement; p && p !== document.body; p = p.parentElement) {
const ox = getComputedStyle(p).overflowX;
if (ox === 'auto' || ox === 'scroll' || ox === 'hidden') { clipped = true; break; }
}
if (!clipped && rect.right > viewport + 1) {
offenders.push(`${node.tagName.toLowerCase()}.${node.className} right=${Math.round(rect.right)}`);
}
}
const cards = [...document.querySelectorAll('#newsletter-preview-root > .sponsor-doc-card')];
const cardOverflow = cards
.filter((card) => card.scrollWidth > card.clientWidth + 1)
.map((card) => `${card.dataset.previewSection || card.dataset.previewView || 'card'} ${card.scrollWidth}>${card.clientWidth}`);
return { scrollWidth: doc.scrollWidth, viewport, offenders: offenders.slice(0, 5), cardOverflow };
});
expect(metrics.scrollWidth, `${label}: page scrollWidth ${metrics.scrollWidth} > viewport ${metrics.viewport}`).toBeLessThanOrEqual(metrics.viewport);
expect(metrics.offenders, `${label}: elements past the right edge`).toEqual([]);
expect(metrics.cardOverflow, `${label}: card content wider than card`).toEqual([]);
}

test('newsletter preview wraps long URLs and inline code on every tab; shareable preview shows only the issue', async ({ page, request }) => {
test.setTimeout(90_000);

const issueRes = await request.post(`${server.baseURL}/api/newsletter-issues`, {
data: { kind: 'weekly', publicationDate: '2026-11-16', status: 'planned' },
});
expect(issueRes.ok(), await issueRes.text()).toBeTruthy();
const issue = await issueRes.json();

const longLine = `Authenticated course inventory ran: \`${LONG_CODE}\` read 24 courses, see ${LONG_URL} and [the register page](${LONG_URL}).`;
const issueMd = [
'Subject: Weekly overflow regression',
'',
'Preview: Long lines must wrap.',
'',
'# Weekly overflow regression',
'',
longLine,
'',
`- ${longLine}`,
'',
].join('\n');
const briefMd = `# Brief\n\n- ${longLine}\n`;
const sourcesMd = `# Sources for the weekly\n\n- INTERNAL-SOURCES-MARKER ${longLine}\n`;

const putRes = await request.put(`${server.baseURL}/api/newsletter-issues/${issue.id}/markdown`, {
data: { issue: issueMd, brief: briefMd, sources: sourcesMd },
});
expect(putRes.ok(), await putRes.text()).toBeTruthy();
const saved = await putRes.json();
expect(saved.accessCode).toBeTruthy();

for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 900 });

// Internal (staff) page: all four tabs, each contained.
await page.setExtraHTTPHeaders({});
await page.goto(`${server.baseURL}/newsletter-issues/${issue.id}/preview`);
await expect(page.locator('[data-preview-staff-bar]')).toBeVisible();
await expect(page.locator('[data-preview-tab]')).toHaveCount(4);
for (const tab of ['issue', 'brief', 'sources', 'raw']) {
await page.locator(`[data-preview-tab="${tab}"]`).click();
if (tab === 'raw') {
await expect(page.locator('pre.sponsor-doc-export-pre')).toHaveCount(3);
} else {
await expect(page.locator(`[data-preview-canvas="${tab}"] code`).first()).toBeVisible();
}
await expectNoHorizontalOverflow(page, `staff ${tab} @${width}`);
if (SCREENSHOTS) await page.screenshot({ path: `${SCREENSHOTS}/staff-${tab}-${width}.png`, fullPage: true });
}

// Shareable link: anonymous viewer with the access code sees only the issue.
const external = await page.context().browser().newContext({
viewport: { width, height: 900 },
extraHTTPHeaders: { 'x-newsletter-preview-anonymous': 'true' },
});
const extPage = await external.newPage();
await extPage.goto(`${server.baseURL}/newsletter-issues/${issue.id}/preview?code=${encodeURIComponent(saved.accessCode)}`);
await expect(extPage.locator('[data-preview-canvas="issue"]')).toBeVisible();
await expect(extPage.locator('[data-preview-canvas="issue"] code').first()).toBeVisible();
await expect(extPage.locator('[data-preview-staff-bar]')).toHaveCount(0);
await expect(extPage.locator('[data-preview-tab]')).toHaveCount(0);
await expect(extPage.locator('body')).not.toContainText('INTERNAL-SOURCES-MARKER');
await expect(extPage.locator('body')).not.toContainText('Sources (sources.md)');
await expect(extPage.locator('body')).not.toContainText('Raw Markdown');
await expectNoHorizontalOverflow(extPage, `shareable @${width}`);
if (SCREENSHOTS) await extPage.screenshot({ path: `${SCREENSHOTS}/shareable-${width}.png`, fullPage: true });
await external.close();
}
});
4 changes: 4 additions & 0 deletions backend/src/newsletter/markdownPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,8 +467,12 @@ function buildPreviewResponsePayload(
};
}

// The shareable (code-gated) preview is reader-facing only: brief.md and
// sources.md are internal working notes and never leave the staff view.
return {
...basePayload,
markdown: { ...basePayload.markdown, sources: '', brief: '' },
rendered: { ...basePayload.rendered, sourcesHtml: '', briefHtml: '' },
viewerRole: 'external' as const,
};
}
Expand Down
6 changes: 6 additions & 0 deletions backend/tests/newsletter-markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,12 @@ describe('Newsletter Markdown Storage & HTML Preview (/api/newsletter-issues/:id
assert.strictEqual(extPayload.accessCode, undefined);
assert.strictEqual(extPayload.shareUrl, undefined);
assert.strictEqual(extPayload.rendered.subject, 'Next Query 2026: How data teams build for analytics and AI');
// Shareable preview is reader-facing only: no internal brief/sources.
assert.strictEqual(extPayload.markdown.issue, issueMd);
assert.strictEqual(extPayload.markdown.sources, '');
assert.strictEqual(extPayload.markdown.brief, '');
assert.strictEqual(extPayload.rendered.sourcesHtml, '');
assert.strictEqual(extPayload.rendered.briefHtml, '');

// Rotate code -> old code fails, new code works
const rotateRes = await staffReq('POST', `/api/newsletter-issues/${issue.id}/markdown/rotate-code`, {});
Expand Down
77 changes: 77 additions & 0 deletions frontend/src/styles/newsletter-preview.css
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,80 @@
border-bottom: 1px solid var(--dk-border-default);
padding-bottom: var(--dk-space-3);
}

/* Long-line containment (t2320u): every grid level is pinned to the card width
so an unbreakable URL or inline code span can never stretch a track past the
card. Prose and inline code wrap; code blocks scroll inside the card. */
.newsletter-preview-shell,
.newsletter-preview-shell > .sponsor-doc-card,
.newsletter-email-canvas,
.newsletter-envelope {
grid-template-columns: minmax(0, 1fr);
min-width: 0;
max-width: 100%;
}

.newsletter-preview-shell > .sponsor-doc-card > *,
.newsletter-email-canvas > *,
.newsletter-envelope > * {
min-width: 0;
}

.newsletter-email-canvas,
.newsletter-envelope,
.newsletter-preview-shell .sponsor-doc-field,
.newsletter-preview-shell .sponsor-doc-title-row,
.newsletter-preview-shell .sponsor-doc-meta {
overflow-wrap: anywhere;
word-break: break-word;
}

.newsletter-email-canvas a,
.newsletter-email-canvas code,
.newsletter-envelope a,
.newsletter-envelope code {
overflow-wrap: anywhere;
word-break: break-word;
}

.newsletter-email-canvas :not(pre) > code {
white-space: normal;
}

.newsletter-email-canvas pre {
margin: 0;
max-width: 100%;
overflow-x: auto;
white-space: pre;
padding: var(--dk-space-3);
background-color: var(--dk-bg-muted);
border: 1px solid var(--dk-border-default);
border-radius: var(--dk-radius-md);
}

.newsletter-email-canvas pre code {
white-space: pre;
overflow-wrap: normal;
word-break: normal;
}

.newsletter-email-canvas img,
.newsletter-email-canvas table {
max-width: 100%;
}

.newsletter-email-canvas table {
display: block;
overflow-x: auto;
}

.newsletter-preview-shell .sponsor-doc-export-pre {
max-width: 100%;
overflow-x: auto;
}

@media (max-width: 480px) {
.newsletter-email-canvas {
padding: var(--dk-space-4) var(--dk-space-3);
}
}
20 changes: 13 additions & 7 deletions frontend/src/surfaces/operations/newsletter-preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -501,13 +501,19 @@ export function createNewsletterPreviewPage({
const previewCard = el("div", "sponsor-doc-card");
previewCard.dataset.previewSection = "rendered";

// Brief, Sources and Raw are internal working material: only staff get the
// tab bar. The shareable (code-gated) preview shows the reader-facing issue.
const isStaff = previewData.viewerRole === "staff";
if (!isStaff) activeTab = "issue";
const tabsBar = el("div", "newsletter-preview-tabs");
const tabDefs = [
{ id: "issue", label: "Email Preview (issue.md)" },
{ id: "brief", label: "Brief (brief.md)" },
{ id: "sources", label: "Sources (sources.md)" },
{ id: "raw", label: "Raw Markdown" },
];
const tabDefs = isStaff
? [
{ id: "issue", label: "Email Preview (issue.md)" },
{ id: "brief", label: "Brief (brief.md)" },
{ id: "sources", label: "Sources (sources.md)" },
{ id: "raw", label: "Raw Markdown" },
]
: [];
for (const tabDef of tabDefs) {
const btn = el(
"button",
Expand All @@ -524,7 +530,7 @@ export function createNewsletterPreviewPage({
});
tabsBar.append(btn);
}
previewCard.append(tabsBar);
if (isStaff) previewCard.append(tabsBar);

const rendered = previewData.rendered || {};
const md = previewData.markdown || {};
Expand Down
33 changes: 20 additions & 13 deletions frontend/test/newsletter-preview.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ function harness(options = {}) {
return { root, identity, live, calls, history, copied, store, controller };
}

test("newsletter preview page unlocks with ?code= query parameter, strips code from URL, and renders HTML preview tabs", async () => {
test("newsletter preview page unlocks with ?code= query parameter, strips code from URL, and renders only the reader-facing issue", async () => {
const { root, identity, history, copied, controller } = harness();
await controller.start();
await nextTicks();
Expand All @@ -168,18 +168,12 @@ test("newsletter preview page unlocks with ?code= query parameter, strips code f
await nextTicks();
assert.equal(copied.at(-1), "Free summit");

// Switch to brief tab, sources tab, and raw tab
root.querySelector('[data-preview-tab="brief"]').dispatch("click");
assert.ok(root.querySelector('[data-preview-canvas="brief"]'));

root.querySelector('[data-preview-tab="sources"]').dispatch("click");
assert.ok(root.querySelector('[data-preview-canvas="sources"]'));

root.querySelector('[data-preview-tab="raw"]').dispatch("click");
const copyRawIssue = root.querySelector('[data-preview-action="copy-raw-issue"]');
copyRawIssue.dispatch("click");
await nextTicks();
assert.match(copied.at(-1), /Subject: Next Query 2026/);
// Shareable (code) preview is reader-facing only: no Brief/Sources/Raw tabs.
for (const tab of ["issue", "brief", "sources", "raw"]) {
assert.equal(root.querySelector(`[data-preview-tab="${tab}"]`), null);
}
assert.equal(root.querySelector('[data-preview-canvas="sources"]'), null);
assert.equal(root.querySelector('[data-preview-action="copy-raw-issue"]'), null);

// Lock returns to gate
const lockBtn = identity.querySelector('[data-preview-action="lock"]');
Expand Down Expand Up @@ -222,6 +216,19 @@ test("staff viewer sees shareable preview link, rotates code, and edits Markdown

assert.ok(root.querySelector("[data-preview-staff-bar]"));

// Switch to brief tab, sources tab, and raw tab
root.querySelector('[data-preview-tab="brief"]').dispatch("click");
assert.ok(root.querySelector('[data-preview-canvas="brief"]'));

root.querySelector('[data-preview-tab="sources"]').dispatch("click");
assert.ok(root.querySelector('[data-preview-canvas="sources"]'));

root.querySelector('[data-preview-tab="raw"]').dispatch("click");
const copyRawIssue = root.querySelector('[data-preview-action="copy-raw-issue"]');
copyRawIssue.dispatch("click");
await nextTicks();
assert.match(copied.at(-1), /Subject: Next Query 2026/);

const copyShareBtn = root.querySelector('[data-preview-action="copy-share-url"]');
copyShareBtn.dispatch("click");
await nextTicks();
Expand Down