diff --git a/src/tests/frontend-new/specs/timeslider_deeplink.spec.ts b/src/tests/frontend-new/specs/timeslider_deeplink.spec.ts new file mode 100644 index 00000000000..d4441ef9d11 --- /dev/null +++ b/src/tests/frontend-new/specs/timeslider_deeplink.spec.ts @@ -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); + }); +}); diff --git a/src/tests/frontend-new/specs/timeslider_export_links.spec.ts b/src/tests/frontend-new/specs/timeslider_export_links.spec.ts new file mode 100644 index 00000000000..e9ef7adbac7 --- /dev/null +++ b/src/tests/frontend-new/specs/timeslider_export_links.spec.ts @@ -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///export/ 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`); + }); +}); diff --git a/src/tests/frontend-new/specs/timeslider_revision_labels.spec.ts b/src/tests/frontend-new/specs/timeslider_revision_labels.spec.ts new file mode 100644 index 00000000000..4cfcb70a482 --- /dev/null +++ b/src/tests/frontend-new/specs/timeslider_revision_labels.spec.ts @@ -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 , " 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); + }); +}); diff --git a/src/tests/frontend/specs/timeslider_labels.js b/src/tests/frontend/specs/timeslider_labels.js deleted file mode 100644 index c43552c6b08..00000000000 --- a/src/tests/frontend/specs/timeslider_labels.js +++ /dev/null @@ -1,65 +0,0 @@ -'use strict'; - -describe('timeslider', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - /** - * @todo test authorsList - */ - it("Shows a date/time in the timeslider and make sure it doesn't include NaN", async function () { - this.timeout(12000); - // make some changes to produce 3 revisions - const revs = 3; - - for (let i = 0; i < revs; i++) { - await helper.edit('a\n'); - } - - await helper.gotoTimeslider(revs); - await helper.waitForPromise(() => helper.contentWindow().location.hash === `#${revs}`); - - // the datetime of last edit - const timerTimeLast = new Date(helper.timesliderTimerTime()).getTime(); - - // the day of this revision, e.g. August 12, 2020 (stripped the string "Saved") - const dateLast = new Date(helper.revisionDateElem().substr(6)).getTime(); - - // the label/revision, ie Version 3 - const labelLast = helper.revisionLabelElem().text(); - - // the datetime should be a date - expect(Number.isNaN(timerTimeLast)).to.eql(false); - - // the Date object of the day should not be NaN - expect(Number.isNaN(dateLast)).to.eql(false); - - // the label should be Version `Number` - expect(labelLast).to.be(`Version ${revs}`); - - // Click somewhere left on the timeslider to go to revision 0 - helper.sliderClick(1); - - // the datetime of last edit - const timerTime = new Date(helper.timesliderTimerTime()).getTime(); - - // the day of this revision, e.g. August 12, 2020 - const date = new Date(helper.revisionDateElem().substr(6)).getTime(); - - // the label/revision, e.g. Version 0 - const label = helper.revisionLabelElem().text(); - - // the datetime should be a date - expect(Number.isNaN(timerTime)).to.eql(false); - // the last revision should be newer or have the same time - expect(timerTimeLast).to.not.be.lessThan(timerTime); - - // the Date object of the day should not be NaN - expect(Number.isNaN(date)).to.eql(false); - - // the label should be Version 0 - expect(label).to.be('Version 0'); - }); -}); diff --git a/src/tests/frontend/specs/timeslider_numeric_padID.js b/src/tests/frontend/specs/timeslider_numeric_padID.js deleted file mode 100644 index a1ba6728f30..00000000000 --- a/src/tests/frontend/specs/timeslider_numeric_padID.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -describe('timeslider', function () { - const padId = 735773577357 + (Math.round(Math.random() * 1000)); - - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad({id: padId}); - }); - - it('Makes sure the export URIs are as expected when the padID is numeric', async function () { - await helper.edit('a\n'); - - await helper.gotoTimeslider(1); - - // ensure we are on revision 1 - await helper.waitForPromise(() => helper.contentWindow().location.hash === '#1'); - - // expect URI to be similar to - // http://192.168.1.48:9001/p/2/1/export/html - // http://192.168.1.48:9001/p/735773577399/1/export/html - const rev1ExportLink = helper.contentWindow().$('#exporthtmla').attr('href'); - expect(rev1ExportLink).to.contain('/1/export/html'); - - // Click somewhere left on the timeslider to go to revision 0 - helper.sliderClick(30); - - const rev0ExportLink = helper.contentWindow().$('#exporthtmla').attr('href'); - expect(rev0ExportLink).to.contain('/0/export/html'); - }); -}); diff --git a/src/tests/frontend/specs/timeslider_revisions.js b/src/tests/frontend/specs/timeslider_revisions.js deleted file mode 100644 index c2de15c42e7..00000000000 --- a/src/tests/frontend/specs/timeslider_revisions.js +++ /dev/null @@ -1,150 +0,0 @@ -'use strict'; - -describe('timeslider', function () { - // create a new pad before each test run - beforeEach(async function () { - await helper.aNewPad(); - }); - - it('loads adds a hundred revisions', async function () { - this.timeout(100000); - const chrome$ = helper.padChrome$; - - // Create a bunch of revisions. - for (let i = 0; i < 99; i++) await helper.edit('a'); - chrome$('.buttonicon-savedRevision').trigger('click'); - await helper.waitForPromise(() => helper.padChrome$('.saved-revision').length > 0); - // Give some time to send the SAVE_REVISION message to the server before navigating away. - await new Promise((resolve) => setTimeout(resolve, 100)); - - // go to timeslider - $('#iframe-container iframe') - .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider`); - - await new Promise((resolve) => setTimeout(resolve, 6000)); - - const timeslider$ = $('#iframe-container iframe')[0].contentWindow.$; - const $sliderBar = timeslider$('#ui-slider-bar'); - - const latestContents = timeslider$('#innerdocbody').text(); - - // Click somewhere on the timeslider - let e = new jQuery.Event('mousedown'); - // sets y co-ordinate of the pad slider modal. - const base = (timeslider$('#ui-slider-bar').offset().top - 24); - e.clientX = e.pageX = 150; - e.clientY = e.pageY = base + 5; - $sliderBar.trigger(e); - - e = new jQuery.Event('mousedown'); - e.clientX = e.pageX = 150; - e.clientY = e.pageY = base; - $sliderBar.trigger(e); - - e = new jQuery.Event('mousedown'); - e.clientX = e.pageX = 150; - e.clientY = e.pageY = base - 5; - $sliderBar.trigger(e); - - $sliderBar.trigger('mouseup'); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // make sure the text has changed - expect(timeslider$('#innerdocbody').text()).not.to.eql(latestContents); - const starIsVisible = timeslider$('.star').is(':visible'); - expect(starIsVisible).to.eql(true); - }); - - - // Disabled as jquery trigger no longer works properly - xit('changes the url when clicking on the timeslider', async function () { - // Create some revisions. - for (let i = 0; i < 20; i++) await helper.edit('a'); - - // go to timeslider - $('#iframe-container iframe') - .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider`); - - await new Promise((resolve) => setTimeout(resolve, 6000)); - - const timeslider$ = $('#iframe-container iframe')[0].contentWindow.$; - const $sliderBar = timeslider$('#ui-slider-bar'); - - const oldUrl = $('#iframe-container iframe')[0].contentWindow.location.hash; - - // Click somewhere on the timeslider - const e = new jQuery.Event('mousedown'); - e.clientX = e.pageX = 150; - e.clientY = e.pageY = 60; - $sliderBar.trigger(e); - - await helper.waitForPromise( - () => $('#iframe-container iframe')[0].contentWindow.location.hash !== oldUrl, 6000); - }); - - it('jumps to a revision given in the url', async function () { - const inner$ = helper.padInner$; - this.timeout(40000); - - // wait for the text to be loaded - await helper.waitForPromise(() => inner$('body').text().length !== 0, 10000); - - const newLines = inner$('body div').length; - const oldLength = inner$('body').text().length + newLines / 2; - expect(oldLength).to.not.eql(0); - inner$('div').first().sendkeys('a'); - let timeslider$; - - // wait for our additional revision to be added - await helper.waitForPromise(() => { - // newLines takes the new lines into account which are strippen when using - // inner$('body').text(), one
is used for one line in ACE. - const lenOkay = inner$('body').text().length + newLines / 2 !== oldLength; - // this waits for the color to be added to our , which means that the revision - // was accepted by the server. - const colorOkay = inner$('span').first().attr('class').indexOf('author-') === 0; - return lenOkay && colorOkay; - }, 10000); - - // go to timeslider with a specific revision set - $('#iframe-container iframe') - .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider#0`); - - // wait for the timeslider to be loaded - await helper.waitForPromise(() => { - try { - timeslider$ = $('#iframe-container iframe')[0].contentWindow.$; - } catch (e) { - // Empty catch block <3 - } - return timeslider$ && timeslider$('#innerdocbody').text().length === oldLength; - }, 10000); - }); - - it('checks the export url', async function () { - const inner$ = helper.padInner$; - this.timeout(11000); - inner$('div').first().sendkeys('a'); - - await new Promise((resolve) => setTimeout(resolve, 2500)); - - // go to timeslider - $('#iframe-container iframe') - .attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider#0`); - let timeslider$; - let exportLink; - - await helper.waitForPromise(() => { - try { - timeslider$ = $('#iframe-container iframe')[0].contentWindow.$; - } catch (e) { - // Empty catch block <3 - } - if (!timeslider$) return false; - exportLink = timeslider$('#exportplaina').attr('href'); - if (!exportLink) return false; - return exportLink.substr(exportLink.length - 12) === '0/export/txt'; - }, 6000); - }); -});