diff --git a/.gitignore b/.gitignore index f58e1354..1ecb1520 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ templates/eject/rust/src/satellite/satellite_extension.did /blob-report/ /playwright/.cache/ /playwright/.auth/ + +.snapshots \ No newline at end of file diff --git a/e2e/create-website.spec.ts b/e2e/create-website.spec.ts deleted file mode 100644 index f411a845..00000000 --- a/e2e/create-website.spec.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {testWithII} from '@dfinity/internet-identity-playwright'; -import {expect} from '@playwright/test'; -import {initTestSuite} from './utils/init.utils'; - -const getConsolePage = initTestSuite(); - -testWithII('should create a satellite for a website', async () => { - const consolePage = getConsolePage(); - - await consolePage.createSatellite({kind: 'website'}); -}); - -testWithII('should visit newly create satellite', async () => { - const consolePage = getConsolePage(); - - const satellitePage = await consolePage.visitSatellite(); - - await expect(satellitePage).toHaveScreenshot({fullPage: true}); -}); diff --git a/e2e/page-objects/_page.ts b/e2e/page-objects/_page.ts new file mode 100644 index 00000000..5522e0e5 --- /dev/null +++ b/e2e/page-objects/_page.ts @@ -0,0 +1,3 @@ +export abstract class TestPage { + abstract close(): Promise; +} diff --git a/e2e/page-objects/cli.page.ts b/e2e/page-objects/cli.page.ts new file mode 100644 index 00000000..13cb4957 --- /dev/null +++ b/e2e/page-objects/cli.page.ts @@ -0,0 +1,183 @@ +import {assertNonNullish, notEmptyString} from '@dfinity/utils'; +import type {PrincipalText} from '@dfinity/zod-schemas'; +import {execute, spawn} from '@junobuild/cli-tools'; +import {readdirSync, statSync} from 'node:fs'; +import {readFile, writeFile} from 'node:fs/promises'; +import {join} from 'node:path'; +import {TestPage} from './_page'; + +const DEV = (process.env.NODE_ENV ?? 'production') === 'development'; + +const JUNO_CONFIG = join(process.cwd(), 'juno.config.ts'); + +const JUNO_TEST_ARGS = ['--mode', 'development', '--headless']; + +const {command: JUNO_CMD, args: JUNO_CDM_ARGS} = DEV + ? {command: 'node', args: ['dist/index.js']} + : {command: 'juno', args: []}; + +const buildArgs = (args: string[]): string[] => [...JUNO_CDM_ARGS, ...args, ...JUNO_TEST_ARGS]; + +export interface CliPageParams { + satelliteId: PrincipalText; +} + +export class CliPage extends TestPage { + readonly #satelliteId: PrincipalText; + + private constructor({satelliteId}: CliPageParams) { + super(); + + this.#satelliteId = satelliteId; + } + + static async initWithEmulatorLogin(params: CliPageParams): Promise { + const cliPage = new CliPage(params); + + await cliPage.initConfig(); + + await cliPage.loginWithEmulator(); + + await cliPage.applyConfig(); + + return cliPage; + } + + protected async initConfig(): Promise { + let content = await readFile(JUNO_CONFIG, 'utf-8'); + content = content.replace('', this.#satelliteId); + await writeFile(JUNO_CONFIG, content, 'utf-8'); + } + + private async revertConfig(): Promise { + let content = await readFile(JUNO_CONFIG, 'utf-8'); + content = content.replace(this.#satelliteId, ''); + await writeFile(JUNO_CONFIG, content, 'utf-8'); + } + + protected async loginWithEmulator(): Promise { + await execute({ + command: JUNO_CMD, + args: buildArgs(['login', '--emulator']) + }); + } + + async applyConfig(): Promise { + await execute({ + command: JUNO_CMD, + args: buildArgs(['config', 'apply', '--force']) + }); + } + + private async logout(): Promise { + await execute({ + command: JUNO_CMD, + args: buildArgs(['logout']) + }); + } + + async clearHosting(): Promise { + await execute({ + command: JUNO_CMD, + args: buildArgs(['hosting', 'clear']) + }); + } + + async createSnapshot({ + target + }: { + target: 'satellite' | 'orbiter' | 'mission-control'; + }): Promise { + await execute({ + command: JUNO_CMD, + args: buildArgs(['snapshot', 'create', '--target', target]) + }); + } + + async restoreSnapshot({ + target + }: { + target: 'satellite' | 'orbiter' | 'mission-control'; + }): Promise { + await execute({ + command: JUNO_CMD, + args: buildArgs(['snapshot', 'restore', '--target', target]) + }); + } + + async deleteSnapshot({ + target + }: { + target: 'satellite' | 'orbiter' | 'mission-control'; + }): Promise { + await execute({ + command: JUNO_CMD, + args: buildArgs(['snapshot', 'delete', '--target', target]) + }); + } + + async downloadSnapshot({ + target + }: { + target: 'satellite' | 'orbiter' | 'mission-control'; + }): Promise<{snapshotFolder: string}> { + await execute({ + command: JUNO_CMD, + args: buildArgs(['snapshot', 'download', '--target', target]) + }); + + // Retrieve where the snapshot was created + const snapshotsFolder = join(process.cwd(), '.snapshots'); + const [snapshotFolder] = readdirSync(snapshotsFolder, {withFileTypes: true}) + .filter((d) => d.isDirectory()) + .map(({name}) => { + const path = join(snapshotsFolder, name); + const {birthtimeMs: time} = statSync(path); + return {path, time}; + }) + .sort((a, b) => b.time - a.time); + + assertNonNullish(snapshotFolder); + + return {snapshotFolder: snapshotFolder.path}; + } + + async uploadSnapshot({ + target, + folder + }: { + target: 'satellite' | 'orbiter' | 'mission-control'; + folder: string; + }): Promise { + await execute({ + command: JUNO_CMD, + args: buildArgs(['snapshot', 'upload', '--target', target, '--dir', folder]) + }); + } + + async listSnapshot({ + target + }: { + target: 'satellite' | 'orbiter' | 'mission-control'; + }): Promise<{snapshotId: string | undefined}> { + let output = ''; + + await spawn({ + command: JUNO_CMD, + args: buildArgs(['snapshot', 'list', '--target', target]), + stdout: (o) => (output += o), + silentErrors: true + }); + + const [_, snapshotId] = output.split('Snapshot found:'); + return {snapshotId: notEmptyString(snapshotId) ? snapshotId.trim() : undefined}; + } + + /** + * @override + */ + async close(): Promise { + await this.revertConfig(); + await this.logout(); + } +} diff --git a/e2e/page-objects/console.page.ts b/e2e/page-objects/console.page.ts index 842acdd0..40f0cc1e 100644 --- a/e2e/page-objects/console.page.ts +++ b/e2e/page-objects/console.page.ts @@ -1,13 +1,13 @@ import {InternetIdentityPage} from '@dfinity/internet-identity-playwright'; import {expect} from '@playwright/test'; -import type {Page} from 'playwright-core'; import {testIds} from '../constants/test-ids.constants'; import {IdentityPage, type IdentityPageParams} from './identity.page'; +import {SatellitePage} from './satellite.page'; export class ConsolePage extends IdentityPage { readonly #consoleIIPage: InternetIdentityPage; - constructor(params: IdentityPageParams) { + private constructor(params: IdentityPageParams) { super(params); this.#consoleIIPage = new InternetIdentityPage({ @@ -17,6 +17,18 @@ export class ConsolePage extends IdentityPage { }); } + static async initWithSignIn(params: IdentityPageParams): Promise { + const consolePage = new ConsolePage(params); + + await consolePage.waitReady(); + + await consolePage.goto(); + + await consolePage.signIn(); + + return consolePage; + } + async goto(): Promise { await this.page.goto('/'); } @@ -53,7 +65,9 @@ export class ConsolePage extends IdentityPage { await this.page.getByTestId(testIds.createSatellite.continue).click(); } - async visitSatellite(): Promise { + async visitSatelliteSite( + {title}: {title: string} = {title: 'Juno / Satellite'} + ): Promise { await expect(this.page.getByTestId(testIds.satelliteOverview.visit)).toBeVisible(); const satellitePagePromise = this.context.waitForEvent('page'); @@ -62,8 +76,12 @@ export class ConsolePage extends IdentityPage { const satellitePage = await satellitePagePromise; - await expect(satellitePage).toHaveTitle('Juno / Satellite'); + await expect(satellitePage).toHaveTitle(title); - return satellitePage; + return new SatellitePage({ + page: satellitePage, + browser: this.browser, + context: this.context + }); } } diff --git a/e2e/page-objects/identity.page.ts b/e2e/page-objects/identity.page.ts index 5bfa9162..b364b58f 100644 --- a/e2e/page-objects/identity.page.ts +++ b/e2e/page-objects/identity.page.ts @@ -1,4 +1,5 @@ import type {Browser, BrowserContext, Page} from '@playwright/test'; +import {TestPage} from './_page'; export interface IdentityPageParams { page: Page; @@ -6,7 +7,7 @@ export interface IdentityPageParams { browser: Browser; } -export abstract class IdentityPage { +export abstract class IdentityPage extends TestPage { protected identity: number | undefined; protected readonly page: Page; @@ -14,11 +15,16 @@ export abstract class IdentityPage { protected readonly browser: Browser; protected constructor({page, context, browser}: IdentityPageParams) { + super(); + this.page = page; this.context = context; this.browser = browser; } + /** + * @override + */ async close(): Promise { await this.page.close(); } diff --git a/e2e/page-objects/satellite.page.ts b/e2e/page-objects/satellite.page.ts new file mode 100644 index 00000000..679c19e9 --- /dev/null +++ b/e2e/page-objects/satellite.page.ts @@ -0,0 +1,23 @@ +import {expect} from '@playwright/test'; +import {IdentityPage} from './identity.page'; + +export class SatellitePage extends IdentityPage { + async reload({title}: {title: string} = {title: 'Juno / Satellite'}): Promise { + await expect + .poll( + async () => { + await this.page.reload({waitUntil: 'load'}); + return await this.page.title(); + }, + { + timeout: 30000, + intervals: [1_000, 2_000, 10_000] + } + ) + .toBe(title); + } + + async assertScreenshot(): Promise { + await expect(this.page).toHaveScreenshot({fullPage: true}); + } +} diff --git a/e2e/snapshots.spec.ts b/e2e/snapshots.spec.ts new file mode 100644 index 00000000..04cd7517 --- /dev/null +++ b/e2e/snapshots.spec.ts @@ -0,0 +1,54 @@ +import {testWithII} from '@dfinity/internet-identity-playwright'; +import {expect} from '@playwright/test'; +import {initTestSuite} from './utils/init.utils'; + +const getTestPages = initTestSuite(); + +const SNAPSHOT_TARGET = {target: 'satellite' as const}; + +testWithII('should create and restore a snapshot', async () => { + const {consolePage, cliPage} = getTestPages(); + + await cliPage.createSnapshot(SNAPSHOT_TARGET); + + await cliPage.clearHosting(); + + const satellitePage = await consolePage.visitSatelliteSite({ + title: 'Internet Computer - Error: response verification error' + }); + await satellitePage.assertScreenshot(); + + await cliPage.restoreSnapshot(SNAPSHOT_TARGET); + + await satellitePage.reload(); + await satellitePage.assertScreenshot(); +}); + +testWithII('should create, download, delete, upload and restore a snapshot', async () => { + testWithII.slow(); + + const {consolePage, cliPage} = getTestPages(); + + await cliPage.createSnapshot(SNAPSHOT_TARGET); + + const {snapshotFolder} = await cliPage.downloadSnapshot(SNAPSHOT_TARGET); + + await cliPage.deleteSnapshot(SNAPSHOT_TARGET); + + const {snapshotId} = await cliPage.listSnapshot(SNAPSHOT_TARGET); + expect(snapshotId).toBeUndefined(); + + await cliPage.clearHosting(); + + const satellitePage = await consolePage.visitSatelliteSite({ + title: 'Internet Computer - Error: response verification error' + }); + await satellitePage.assertScreenshot(); + + await cliPage.uploadSnapshot({...SNAPSHOT_TARGET, folder: snapshotFolder}); + + await cliPage.restoreSnapshot(SNAPSHOT_TARGET); + + await satellitePage.reload(); + await satellitePage.assertScreenshot(); +}); diff --git a/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-and-restore-a-snapshot-1-chromium-linux.png b/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-and-restore-a-snapshot-1-chromium-linux.png new file mode 100644 index 00000000..c6cc43eb Binary files /dev/null and b/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-and-restore-a-snapshot-1-chromium-linux.png differ diff --git a/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-and-restore-a-snapshot-2-chromium-linux.png b/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-and-restore-a-snapshot-2-chromium-linux.png new file mode 100644 index 00000000..7b6610c0 Binary files /dev/null and b/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-and-restore-a-snapshot-2-chromium-linux.png differ diff --git a/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-download-delete-upload-and-restore-a-snapshot-1-chromium-linux.png b/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-download-delete-upload-and-restore-a-snapshot-1-chromium-linux.png new file mode 100644 index 00000000..c6cc43eb Binary files /dev/null and b/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-download-delete-upload-and-restore-a-snapshot-1-chromium-linux.png differ diff --git a/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-download-delete-upload-and-restore-a-snapshot-2-chromium-linux.png b/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-download-delete-upload-and-restore-a-snapshot-2-chromium-linux.png new file mode 100644 index 00000000..7b6610c0 Binary files /dev/null and b/e2e/snapshots/snapshots.spec.ts-snapshots/should-create-download-delete-upload-and-restore-a-snapshot-2-chromium-linux.png differ diff --git a/e2e/utils/init.utils.ts b/e2e/utils/init.utils.ts index 91a33410..f0705266 100644 --- a/e2e/utils/init.utils.ts +++ b/e2e/utils/init.utils.ts @@ -1,10 +1,18 @@ import {testWithII} from '@dfinity/internet-identity-playwright'; +import {assertNonNullish} from '@dfinity/utils'; +import {CliPage} from '../page-objects/cli.page'; import {ConsolePage} from '../page-objects/console.page'; -export const initTestSuite = (): (() => ConsolePage) => { +interface TestSuitePages { + consolePage: ConsolePage; + cliPage: CliPage; +} + +export const initTestSuite = (): (() => TestSuitePages) => { testWithII.describe.configure({mode: 'serial'}); let consolePage: ConsolePage; + let cliPage: CliPage; testWithII.beforeAll(async ({playwright}) => { testWithII.setTimeout(120000); @@ -14,22 +22,36 @@ export const initTestSuite = (): (() => ConsolePage) => { const context = await browser.newContext(); const page = await context.newPage(); - consolePage = new ConsolePage({ + consolePage = await ConsolePage.initWithSignIn({ page, context, browser }); - await consolePage.waitReady(); + await consolePage.createSatellite({kind: 'website'}); - await consolePage.goto(); + // TODO: replace with a testId that copies to Satellite ID from the Overview + const currentUrl = await page.evaluate(() => document.location.href); + const url = URL.parse(currentUrl); + assertNonNullish(url); + const urlParams = new URLSearchParams(url.searchParams); + const satelliteId = urlParams.get('s'); + assertNonNullish(satelliteId); - await consolePage.signIn(); + cliPage = await CliPage.initWithEmulatorLogin({satelliteId}); }); testWithII.afterAll(async () => { - await consolePage.close(); + const results = await Promise.allSettled([consolePage.close(), cliPage.close()]); + + if (results.find(({status}) => status === 'rejected')) { + console.error(results); + throw new Error('Failed to close test suite!'); + } }); - return (): ConsolePage => consolePage; + return (): TestSuitePages => ({ + consolePage, + cliPage + }); }; diff --git a/package.json b/package.json index 8e68863b..7be80d33 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "e2e": "NODE_ENV=development playwright test", "e2e:ci": "playwright test --reporter=html", "e2e:snapshots": "playwright test --update-snapshots --reporter=list", + "e2e:snapshots:local": "NODE_ENV=development playwright test --update-snapshots --reporter=list", "e2e:report": "playwright show-report", "e2e:playwright:install": "playwright install chromium --with-deps" },