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
93 changes: 93 additions & 0 deletions e2e/compose-layout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { test, expect, type Page } from './fixture';

// The composer and the editor after Substack's composer: one row of small
// line-icon tools that keep their words (accessible name) and hover text
// (title), a compact kind / pane switch, compact save draft and publish, and
// the syntax paragraph folded behind "syntax help" (remembered per device).
async function login(page: Page) {
await page.goto('/studio/login');
await page.locator('[name=password]').fill('test-password');
await page.getByRole('button', { name: 'log in', exact: true }).click();
await expect(page.locator('#composer-text')).toBeVisible();
}
const api = (page: Page, method: string, path: string, body?: unknown) =>
page.evaluate(
async ({ method, path, body }) => {
const response = await fetch(`/api${path}`, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
return { status: response.status, json: await response.json().catch(() => null) };
},
{ method, path, body },
);
/** Every control in `row` sits inside the viewport and is a 40px target. */
async function fits(page: Page, selector: string) {
const width = page.viewportSize()!.width;
const row = page.locator(selector);
await expect(row).toBeVisible();
for (const control of await row.locator('button').all()) {
const box = (await control.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(width);
expect(box.height).toBeGreaterThanOrEqual(40);
expect(box.width).toBeGreaterThanOrEqual(40);
}
expect(await row.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
}

test('the composer: icon tools keep their names and titles, compact actions, folded syntax help', async ({ page }) => {
await login(page);
const tools = page.locator('.composer-tools');
for (const [name, title] of [
['attach image', 'Upload an image and insert it at the cursor'],
['link from clipboard', /^Read a URL from the clipboard/],
['scan text', /^Scan printed text/],
['[[ link an item', /^Link to an item/],
['[TK] instruction', /^Write an instruction for the model/],
['Full Editor →', 'Full Editor →'],
] as const) {
const button = tools.getByRole('button', { name, exact: true });
await expect(button).toBeVisible();
await expect(button).toHaveAttribute('title', title);
}
await expect(tools.getByRole('button', { name: 'attach image', exact: true }).locator('svg')).toHaveCount(1);
await expect(tools.getByRole('button', { name: '[[ link an item', exact: true })).toHaveText('[[');
await fits(page, '.composer-tools');
// Compact segmented kind switch, compact buttons.
await expect(page.getByRole('radiogroup', { name: 'kind' })).toHaveClass(/compact/);
for (const id of ['#save-draft-btn', '#publish-btn']) expect((await page.locator(id).boundingBox())!.height).toBeLessThanOrEqual(36);
// Syntax help is folded by default, text intact, and the choice is remembered.
const help = page.locator('details.syntax-help');
await expect(help).not.toHaveAttribute('open');
await expect(help.getByText(/^Markdown supported\./)).toBeHidden();
await help.locator('summary').click();
await expect(help.getByText(/^Markdown supported\./)).toBeVisible();
// <details> fires toggle asynchronously; reload only once the choice is stored.
const stored = () => page.evaluate(() => localStorage.getItem('studio.syntaxHelpOpen'));
await expect.poll(stored).toBe('1');
await page.reload();
await expect(page.locator('details.syntax-help')).toHaveAttribute('open');
await page.locator('details.syntax-help summary').click();
await expect.poll(stored).toBe('0');
await page.reload();
await expect(page.locator('details.syntax-help')).not.toHaveAttribute('open');
// A thread adds ![[; the row still fits.
await page.getByRole('radio', { name: 'thread' }).click();
await expect(tools.getByRole('button', { name: '![[ quote an item', exact: true })).toBeVisible();
await fits(page, '.composer-tools');
});

test('the editor: one row of tools that fits, its own items named as before', async ({ page }) => {
await login(page);
const { json } = await api(page, 'POST', '/items', { content_md: 'Layout draft.', kind: 'thread' });
try {
await page.goto(`/studio/edit/${json.id}`);
await expect(page.locator('#md-input')).toBeVisible();
await fits(page, '.ed-tools');
const tools = page.locator('.ed-tools');
await expect(tools.getByRole('button', { name: 'make this a fragment', exact: true })).toHaveAttribute('title', 'make this a fragment');
await expect(tools.getByRole('button', { name: 'mark selection as generated', exact: true })).toHaveAttribute('title', /^Wrap the selected text/);
await expect(page.locator('details.syntax-help')).toBeVisible();
for (const id of ['#save-draft-btn', '#publish-btn']) expect((await page.locator(id).boundingBox())!.height).toBeLessThanOrEqual(36);
} finally {
await api(page, 'DELETE', `/items/${json.id}`);
}
});
168 changes: 168 additions & 0 deletions e2e/pwa-zoom.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { test, expect, type Page } from './fixture';

// "The page zooms in randomly and has to be resized" (iOS Safari / the
// installed PWA). Two causes, each pinned here at the phone viewport:
// - a form field under 16px zooms the page when it takes focus and the page
// does not zoom back, so every visible field on every screen and sheet is
// asserted at ≥ 16px;
// - content wider than the viewport makes iOS lay the page out wider and
// show it zoomed out, so reading a post with a long URL, a wide <pre>, a
// wide table, a wide image and a wide embed must not scroll sideways.
// User zoom itself stays enabled (no maximum-scale / user-scalable=no).

test.beforeEach(({}, info) => test.skip(info.project.name !== 'mobile', 'phone viewport only'));

async function login(page: Page) {
await page.goto('/studio/login');
await page.locator('[name=password]').fill('test-password');
await page.getByRole('button', { name: 'log in', exact: true }).click();
await expect(page.locator('#composer-text')).toBeVisible();
}
const api = (page: Page, method: string, path: string, body?: unknown) =>
page.evaluate(
async ({ method, path, body }) => {
const response = await fetch(`/api${path}`, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
return { status: response.status, json: await response.json().catch(() => null) };
},
{ method, path, body },
);
/** Every visible text field whose computed font-size is under 16px. */
const smallFields = (page: Page) =>
page.evaluate(() =>
[...document.querySelectorAll<HTMLElement>('input, textarea, select, [contenteditable]:not([contenteditable=false])')]
.filter((el) => {
const type = (el as HTMLInputElement).type;
if (['checkbox', 'radio', 'hidden', 'file', 'color', 'range', 'submit', 'button', 'reset', 'image'].includes(type)) return false;
const style = getComputedStyle(el);
const box = el.getBoundingClientRect();
return style.display !== 'none' && style.visibility !== 'hidden' && box.width > 0 && box.height > 0;
})
.map((el) => ({ field: `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${el.className ? '.' + String(el.className).split(' ').join('.') : ''}`, size: getComputedStyle(el).fontSize }))
.filter((field) => parseFloat(field.size) < 16),
);
async function noSmallFields(page: Page, where: string, needsField = true) {
// Where a screen has fields, at least one must be on screen, or the check proves nothing.
if (needsField) await expect(page.locator('input:visible, textarea:visible, select:visible').first(), `${where} shows a field`).toBeVisible();
expect(await smallFields(page), where).toEqual([]);
}
const noSideScroll = (page: Page) =>
page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth);

test('the viewport keeps user zoom', async ({ page }) => {
await login(page);
const viewport = await page.locator('meta[name=viewport]').getAttribute('content');
expect(viewport).toContain('width=device-width');
expect(viewport).not.toMatch(/maximum-scale|user-scalable/);
// No double-tap zoom on buttons: the body's touch-action allows pan and pinch only.
expect(await page.evaluate(() => getComputedStyle(document.body).touchAction)).toBe('manipulation');
});

test('every field on the main screens is at least 16px', async ({ page }) => {
await login(page);
await noSmallFields(page, 'compose');
// The [[ picker: its search box and filters.
await page.locator('#composer-text').fill('[[');
await expect(page.locator('.picker')).toBeVisible();
await noSmallFields(page, 'picker');
await page.keyboard.press('Escape');
await page.locator('#composer-text').fill('');
for (const [path, where, needsField] of [
['/studio/settings', 'settings', true],
['/studio/setup', 'setup', false],
['/studio/access', 'access', false],
['/studio/hoppers', 'hoppers', true],
['/studio/hoppers/parity-hopper', 'hopper', false],
['/studio/mentions', 'mentions', false],
['/studio/signals', 'signals', false],
['/studio/reading?view=sources', 'sources', false],
] as const) {
await page.goto(path);
await expect(page.locator('main .screen > *').first()).toBeVisible();
await page.waitForLoadState('networkidle');
await noSmallFields(page, where, needsField);
}
// Settings → appearance → Custom opens the colour editor and its hex fields.
await page.goto('/studio/settings');
const custom = page.locator('.theme-opt').filter({ hasText: /custom/i }).first();
if (await custom.count()) {
await custom.click();
await expect(page.locator('.theme-colour-text input').first()).toBeVisible();
await noSmallFields(page, 'custom theme editor');
await page.reload();
}
});

test('every field in the editor and in sheets is at least 16px', async ({ page }) => {
await login(page);
const { json } = await api(page, 'POST', '/items', { content_md: 'Zoom audit draft.', kind: 'fragment' });
try {
await page.goto(`/studio/edit/${json.id}`);
await expect(page.locator('#md-input')).toBeVisible();
await noSmallFields(page, 'editor');
await page.locator('#md-input').fill('Zoom audit draft. [[');
await expect(page.locator('.picker')).toBeVisible();
await noSmallFields(page, 'editor picker');
await page.keyboard.press('Escape');
await page.locator('#md-input').fill('Zoom audit draft.');
// A prompt sheet: link from clipboard asks for a URL when the clipboard has none.
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.evaluate(() => navigator.clipboard.writeText('not a url'));
await page.locator('[data-action="link-clipboard"]').click();
const sheet = page.getByRole('dialog', { name: 'link from clipboard' });
await expect(sheet.getByRole('textbox')).toBeVisible();
await noSmallFields(page, 'prompt sheet');
await sheet.locator('[data-sheet=cancel]').click();
// The subscribe sheet on reading.
await page.goto('/studio/reading');
await page.getByRole('button', { name: 'subscribe', exact: true }).click();
await expect(page.getByRole('dialog', { name: 'subscribe' }).getByRole('textbox').first()).toBeVisible();
await noSmallFields(page, 'subscribe sheet');
} finally {
await api(page, 'DELETE', `/items/${json.id}`);
}
});

const WIDE_IMAGE = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="300"><rect width="1600" height="300" fill="#888"/></svg>')}`;
const WIDE_HTML =
'<h2>Wide fixture post</h2>' +
'<p>A long link: https://example.com/a/really/long/path/that/never/breaks/' + 'segment'.repeat(30) + '?query=' + 'x'.repeat(120) + '</p>' +
'<pre><code>' + 'const wide = "' + 'w'.repeat(400) + '";' + '</code></pre>' +
'<table><tr>' + Array.from({ length: 14 }, (_, i) => `<td>column_${i}_wide_cell_content</td>`).join('') + '</tr></table>' +
`<p><img src="${WIDE_IMAGE}" width="1600" height="300" alt="wide"></p>` +
'<iframe width="1200" height="300" title="wide embed" srcdoc="<p>embed</p>"></iframe>' +
Array.from({ length: 6 }, (_, i) => `<p>Filler paragraph ${i} so the card clamps.</p>`).join('');
/** Serve the feed with one more legacy-RSS entry at the top: the wide post. */
async function injectWidePost(page: Page) {
await page.route(/\/api\/reading(\?|$)/, async (route) => {
const response = await route.fetch();
const page0 = await response.json();
if (page0.offset === 0) {
const at = '2026-10-17T00:00:00Z';
page0.items.unshift({
key: 'imported:["parity-rss","wide-fixture"]', source: 'imported', kind: 'fragment', withdrawn: false, l0: true, contentHtml: WIDE_HTML, displayAt: at,
imported: { subscriptionId: 'parity-rss', subscriptionTitle: 'Legacy source', remoteId: 'wide-fixture', kind: 'fragment', withdrawn: false, l0: true, updated: at, observedAt: at, contentHtml: WIDE_HTML, pinnedVersionRetained: null, sourceUrl: 'https://legacy.example/' + 'very-long-path-segment/'.repeat(12), version: 1, readVersion: 1 },
});
}
await route.fulfill({ response, json: page0 });
});
}

test('reading a post with wide content never scrolls the page sideways', async ({ page }) => {
await login(page);
await injectWidePost(page);
await page.goto('/studio/reading');
const entry = page.locator('.reading-entry').filter({ hasText: 'Wide fixture post' });
await expect(entry).toBeVisible();
await expect(entry.locator('img[alt=wide]')).toBeVisible();
expect(await noSideScroll(page)).toBe(true);
// The full view shows the whole post unclamped: still no sideways scroll.
await entry.getByRole('button', { name: 'more', exact: true }).click();
await expect(page.locator('.reading-article')).toContainText('Filler paragraph 5');
await expect(page.locator('.reading-article table')).toBeVisible();
expect(await noSideScroll(page)).toBe(true);
// The table and the code scroll inside their own boxes.
for (const selector of ['.reading-article table', '.reading-article pre']) {
const box = await page.locator(selector).boundingBox();
expect(box!.x + box!.width, selector).toBeLessThanOrEqual(page.viewportSize()!.width);
}
});
Loading
Loading