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
53 changes: 53 additions & 0 deletions src/tests/frontend-new/specs/timeslider_deeplink.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {expect, Page, test} from "@playwright/test";
import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper";

// Ported from the "jumps to a revision given in the url" case of the legacy
// timeslider_revisions.js (which no CI workflow ran). Re-targeted at the in-pad
// history model (#7659): a #rev/N hash on the pad URL boots straight into
// history mode at that revision (pad_mode.bootstrapFromHash), and the legacy
// #N shortlink form is still accepted for old bookmarks.
test.describe('timeslider deep link', function () {
test.describe.configure({mode: 'serial'});

test.beforeEach(async ({context}) => {
await context.clearCookies();
});

const expectHistoryAtRev0 = async (page: Page) => {
await expect(page.locator('body.history-mode')).toBeVisible({timeout: 15000});
await expect(page.locator('#history-controls')).toBeVisible();
// bootstrapFromHash canonicalizes any accepted hash form to #rev/0.
await expect.poll(() => new URL(page.url()).hash, {timeout: 15000}).toBe('#rev/0');
// The slider lands on revision 0 once pad_mode syncs from the embedded
// BroadcastSlider — the signal that we're actually viewing that revision.
await expect.poll(
async () => await page.locator('#history-slider-input').evaluate(
(el) => Number((el as HTMLInputElement).value)),
{timeout: 15000}).toBe(0);
};

test('#rev/N hash boots into history mode at that revision', async function ({page}) {
const padId = await goToNewPad(page);
await clearPadContent(page);
await writeToPad(page, 'One ');
await page.waitForTimeout(400);
await writeToPad(page, 'Two ');
await page.waitForTimeout(800);

// Deep-link to revision 0 of the same pad.
await page.goto(`http://localhost:9001/p/${padId}#rev/0`);
await expectHistoryAtRev0(page);
});

test('legacy #N shortlink hash still enters history mode', async function ({page}) {
const padId = await goToNewPad(page);
await clearPadContent(page);
await writeToPad(page, 'One ');
await page.waitForTimeout(400);
await writeToPad(page, 'Two ');
await page.waitForTimeout(800);

await page.goto(`http://localhost:9001/p/${padId}#0`);
await expectHistoryAtRev0(page);
});
});
88 changes: 88 additions & 0 deletions src/tests/frontend-new/specs/timeslider_export_links.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {expect, Page, test} from "@playwright/test";
import {clearPadContent, goToPad, writeToPad} from "../helper/padHelper";

// Ported from the legacy mocha suite (timeslider_numeric_padID.js and the
// "checks the export url" case in timeslider_revisions.js), neither of which
// ran in CI. Re-targeted at the in-pad history UI (#7659): the export links
// live in the outer #exportColumn and pad_mode.ts rewrites their hrefs to
// /p/<pad>/<rev>/export/<type> for the revision currently being viewed.
test.describe('timeslider export links', function () {
test.describe.configure({mode: 'serial'});

test.beforeEach(async ({context}) => {
await context.clearCookies();
});

// Suppress the one-time pad-deletion-token modal (same trick goToNewPad uses)
// so it can't steal focus mid-test on a creator session.
const suppressDeletionTokenModal = async (page: Page) => {
await page.addInitScript(() => {
let stored: unknown;
Object.defineProperty(window, 'clientVars', {
configurable: true,
get() { return stored; },
set(v) {
if (v != null && typeof v === 'object') {
(v as {padDeletionToken?: string | null}).padDeletionToken = null;
}
stored = v;
},
});
});
};

const enterHistoryMode = async (page: Page) => {
await page.click('.buttonicon-history');
await page.waitForSelector('#history-controls:not([hidden])', {state: 'visible'});
await page.waitForSelector('#history-frame');
};

const goToRevision = async (page: Page, rev: number) => {
await page.locator('#history-slider-input').evaluate((el, value) => {
(el as HTMLInputElement).value = String(value);
el.dispatchEvent(new Event('input', {bubbles: true}));
}, rev);
await expect(page.locator('#history-banner-rev')).toHaveText(`Version ${rev}`, {timeout: 15000});
};

const exportHref = (page: Page, id: string) =>
page.locator(`#${id}`).getAttribute('href');

test('export hrefs target the viewed revision, including a numeric pad id', async function ({page}) {
// A numeric pad id is the specific case the legacy test guarded — the
// href rewriter must not confuse it with the revision segment. Use a
// high-entropy numeric id (timestamp + random) so reruns against a
// persistent DB can't collide on the same pad.
const padId = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
await suppressDeletionTokenModal(page);
await goToPad(page, padId); // navigates and waits for the editor to be ready
await clearPadContent(page);

await writeToPad(page, 'One ');
await page.waitForTimeout(400);
await writeToPad(page, 'Two ');
await page.waitForTimeout(800);

await enterHistoryMode(page);

// Wait for pad_mode to sync the slider max from the embedded BroadcastSlider.
await expect.poll(
async () => await page.locator('#history-slider-input').evaluate(
(el) => Number((el as HTMLInputElement).max)),
{timeout: 15000}).toBeGreaterThan(0);
const maxRev = await page.locator('#history-slider-input').evaluate(
(el) => Number((el as HTMLInputElement).max));
expect(maxRev).toBeGreaterThan(0);

// On entry the slider is at the latest revision; hrefs point there.
await expect.poll(() => exportHref(page, 'exporthtmla'), {timeout: 15000})
.toContain(`/${padId}/${maxRev}/export/html`);
expect(await exportHref(page, 'exportplaina')).toContain(`/${padId}/${maxRev}/export/txt`);

// Scrub to revision 0 — the export targets must follow.
await goToRevision(page, 0);
await expect.poll(() => exportHref(page, 'exporthtmla'), {timeout: 15000})
.toContain(`/${padId}/0/export/html`);
expect(await exportHref(page, 'exportplaina')).toContain(`/${padId}/0/export/txt`);
});
});
81 changes: 81 additions & 0 deletions src/tests/frontend-new/specs/timeslider_revision_labels.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {expect, Page, test} from "@playwright/test";
import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper";

// Ported from the legacy mocha suite (src/tests/frontend/specs/timeslider_labels.js),
// which no CI workflow ran. Re-targeted at the in-pad history UI (#7659): the
// revision label and date are shown in the outer #history-banner, populated by
// pad_mode.ts mirroring the embedded timeslider's #revision_label / #revision_date.
// This guards the banner bridge against silently breaking again (cf. #7946).
test.describe('timeslider revision labels', function () {
test.describe.configure({mode: 'serial'});
// The "Version N" label and "Saved <Month> <day>, <year>" date are localized
// (timeslider.version / timeslider.saved). Pin the locale so the assertions
// are deterministic and the date string stays Date-parseable.
test.use({locale: 'en-US'});

test.beforeEach(async ({context}) => {
await context.clearCookies();
});

const enterHistoryMode = async (page: Page) => {
await page.click('.buttonicon-history');
await page.waitForSelector('#history-controls:not([hidden])', {state: 'visible'});
await page.waitForSelector('#history-frame');
};

// Drive the outer slider (a remote control for the embedded BroadcastSlider)
// to a specific revision and wait for the banner to reflect it.
const goToRevision = async (page: Page, rev: number) => {
await page.locator('#history-slider-input').evaluate((el, value) => {
(el as HTMLInputElement).value = String(value);
el.dispatchEvent(new Event('input', {bubbles: true}));
}, rev);
await expect(page.locator('#history-banner-rev')).toHaveText(`Version ${rev}`, {timeout: 15000});
};

// "Saved June 12, 2026" -> a parseable Date (the banner mirrors the
// timeslider.saved l10n string "Saved {{month}} {{day}}, {{year}}").
const parsedBannerDate = async (page: Page) => await page.locator('#history-banner-date').evaluate(
(el) => new Date((el.textContent || '').replace(/^Saved\s+/i, '')).getTime());

test('shows Version label and a valid date that update while scrubbing', async function ({page}) {
await goToNewPad(page);
await clearPadContent(page);

// Produce a few revisions.
await writeToPad(page, 'Alpha ');
await page.waitForTimeout(400);
await writeToPad(page, 'Beta ');
await page.waitForTimeout(400);
await writeToPad(page, 'Gamma ');
await page.waitForTimeout(800);

await enterHistoryMode(page);

// On entry the slider sits at the latest revision; the banner must show a
// non-empty "Version N" label and a non-NaN date. Wait for pad_mode to sync
// the slider max from the embedded BroadcastSlider before reading it.
await expect.poll(
async () => await page.locator('#history-slider-input').evaluate(
(el) => Number((el as HTMLInputElement).max)),
{timeout: 15000}).toBeGreaterThan(0);
const maxRev = await page.locator('#history-slider-input').evaluate(
(el) => Number((el as HTMLInputElement).max));
expect(maxRev).toBeGreaterThan(0);

await expect(page.locator('#history-banner-rev')).toHaveText(`Version ${maxRev}`);
const dateLast = await parsedBannerDate(page);
expect(Number.isNaN(dateLast)).toBe(false);
// The mirrored timer must also be a real, non-NaN datetime.
const timerLast = await page.locator('#history-timer').textContent();
expect(Number.isNaN(new Date(timerLast || '').getTime())).toBe(false);

// Scrub back to revision 0 — label and date must update.
await goToRevision(page, 0);
await expect(page.locator('#history-banner-rev')).toHaveText('Version 0');
const dateFirst = await parsedBannerDate(page);
expect(Number.isNaN(dateFirst)).toBe(false);
// The latest revision is never older than revision 0.
expect(dateLast).toBeGreaterThanOrEqual(dateFirst);
});
});
65 changes: 0 additions & 65 deletions src/tests/frontend/specs/timeslider_labels.js

This file was deleted.

31 changes: 0 additions & 31 deletions src/tests/frontend/specs/timeslider_numeric_padID.js

This file was deleted.

Loading
Loading