diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 2608bf7b2..1a8c9da90 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +concurrency: + group: playwright-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + jobs: playwright: timeout-minutes: 60 @@ -73,6 +77,7 @@ jobs: run: npx playwright test ${{ matrix.project-args }} --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} env: COLLECTIVES_SEMANTIC_E2E: ${{ matrix.semantic-e2e }} + COLLECTIVES_COMPARISON_INITIAL_VIEW: changes PLAYWRIGHT_NC_SERVER_BRANCH: ${{ matrix.server-branch }} PLAYWRIGHT_TEXT_REPOSITORY: ${{ matrix.text-repository }} PLAYWRIGHT_TEXT_REF: ${{ matrix.text-ref }} @@ -87,11 +92,74 @@ jobs: blob-report/ retention-days: 7 + comparison-nc35: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - name: Checkout app + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check composer.json + id: check_composer + uses: andstor/file-existence-action@558493d6c74bf472d87c84eab196434afc2fa029 # v3.1.0 + with: + files: 'composer.json' + + - name: Install composer dependencies + if: steps.check_composer.outputs.files_exists == 'true' + run: composer install --no-dev + + - name: Read package.json + uses: nextcloud-libraries/parse-package-engines-action@122ae05d4257008180a514e1ddeb0c1b9d094bdd # v0.1.0 + id: versions + + - name: Set up node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ steps.versions.outputs.node-version }} + + - name: Set up npm + run: npm i -g 'npm@${{ steps.versions.outputs.package-manager-version }}' + + - name: Install node dependencies & build app + env: + CYPRESS_INSTALL_BINARY: 0 + run: | + npm ci + npm run build --if-present + + - name: Install Playwright Browsers + run: npx playwright install --with-deps chromium + + - name: Run Playwright tests + run: ./node_modules/.bin/playwright test playwright/e2e/version-comparison.spec.ts --project=comparison-chromium --workers=1 + env: + COLLECTIVES_SEMANTIC_E2E: '1' + COLLECTIVES_COMPARISON_INITIAL_VIEW: changes + NEXTCLOUD_E2E_LOG_FILE: test-results/nextcloud.log + PLAYWRIGHT_NC_SERVER_BRANCH: stable35 + PLAYWRIGHT_TEXT_REPOSITORY: nextcloud/text + PLAYWRIGHT_TEXT_REF: 'b07c97d488f7a2c44edea8b2ad3b88a85f6612bf' + + - name: Upload results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: ${{ !cancelled() }} + with: + name: playwright-report_stable35_comparison + path: | + test-results/ + blob-report/ + retention-days: 7 + summary: runs-on: ubuntu-latest-low - needs: playwright + needs: [playwright, comparison-nc35] if: always() name: playwright-summary steps: - name: Summary status - run: if ${{ needs.playwright.result != 'success' && needs.playwright.result != 'skipped' }}; then exit 1; fi + run: | + if ${{ needs.playwright.result != 'success' && needs.playwright.result != 'skipped' }}; then exit 1; fi + if ${{ needs.comparison-nc35.result != 'success' }}; then exit 1; fi diff --git a/playwright.config.ts b/playwright.config.ts index 096c1af17..67a3d9933 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -38,6 +38,10 @@ const CI_CONFIG = { const baseURL = process.env.baseURL || 'http://localhost:8089/index.php/' const externalServer = Boolean(process.env.baseURL) const SEMANTIC_E2E = process.env.COLLECTIVES_SEMANTIC_E2E === '1' +const comparisonInitialView = process.env.COLLECTIVES_COMPARISON_INITIAL_VIEW ?? (process.env.CI ? undefined : 'changes') +if (SEMANTIC_E2E && comparisonInitialView !== 'changes' && comparisonInitialView !== 'documents') { + throw new Error('Set COLLECTIVES_COMPARISON_INITIAL_VIEW to changes or documents for the pinned Text revision') +} const VERSION_COMPARISON_TESTS = '**/version-comparison.spec.ts' const VIEWER_FALLBACK_TAG = /@viewer-fallback/ @@ -59,30 +63,31 @@ const webServer = externalServer // Don't set `url` as it would take precedence over `wait.stdout` and tests start too early // url: 'http://127.0.0.1:8089', // Starts the Nextcloud docker container - command: 'node playwright/start-nextcloud-server.js', - // we use sigterm to notify the script to stop the container - // if it does not respond, we force kill it after 10 seconds - gracefulShutdown: { - signal: 'SIGTERM' as const, - timeout: 10000, - }, - // `start-nextcloud-server.mjs` only starts the server if not reachable yet. - reuseExistingServer: false, - stderr: 'pipe' as const, - stdout: 'pipe' as const, - // max. 5 minutes for creating the container - timeout: 5 * 60 * 1000, - wait: { + command: 'node playwright/start-nextcloud-server.js', + // we use sigterm to notify the script to stop the container + // if it does not respond, we force kill it after 10 seconds + gracefulShutdown: { + signal: 'SIGTERM' as const, + timeout: 10000, + }, + reuseExistingServer: false, + stderr: 'pipe' as const, + stdout: 'pipe' as const, + // Semantic tests also install dependencies and compile the pinned Text revision. + timeout: (SEMANTIC_E2E ? 10 : 5) * 60 * 1000, + wait: { // we wait for this line to appear in the output of the webserver until consider it done - stdout: /Nextcloud is now ready to use/, - }, - } + stdout: /Collectives test runtime ready/, + }, + } /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './playwright', + outputDir: './test-results/browser', + metadata: { comparisonInitialView }, ...(process.env.CI ? CI_CONFIG : LOCAL_CONFIG), use: { // Base URL to use in actions like `await page.goto('./')`. diff --git a/playwright/e2e/page-share.spec.ts b/playwright/e2e/page-share.spec.ts index f438173dc..2f612cf03 100644 --- a/playwright/e2e/page-share.spec.ts +++ b/playwright/e2e/page-share.spec.ts @@ -3,9 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Locator } from '@playwright/test' +import type { APIRequestContext, Locator } from '@playwright/test' -import { runOcc } from '@nextcloud/e2e-test-server/docker' import { expect, mergeTests } from '@playwright/test' import { test as collectiveShareTest } from '../support/fixtures/collective-share.ts' import { test as createCollectiveTest } from '../support/fixtures/create-collectives.ts' @@ -14,6 +13,23 @@ import { ocsHeaders } from '../support/helpers/urls.ts' const test = mergeTests(createCollectiveTest, pageSidebarTest, collectiveShareTest) +async function setPasswordProtection(request: APIRequestContext, enabled: boolean) { + const headers = { ...ocsHeaders, Authorization: `Basic ${Buffer.from('admin:admin').toString('base64')}` } + // HTTP writes invalidate the web server's config cache; CLI writes use a separate cache. + for (const key of ['shareapi_enable_link_password_by_default', 'shareapi_enforce_links_password']) { + await request.post(`/ocs/v2.php/apps/provisioning_api/api/v1/config/apps/core/${key}`, { + headers, + data: { value: enabled ? 'yes' : 'no' }, + failOnStatusCode: true, + }) + } + await request.fetch('/ocs/v2.php/cloud/apps/password_policy', { + method: enabled ? 'POST' : 'DELETE', + headers, + failOnStatusCode: true, + }) +} + test.describe('Page share', () => { test('Create share and open share unauthenticated', async ({ user, page, collective, pageSidebar, sharePage, sharePageList, shareEditor }) => { const collectivePage = await collective.createPage({ title: 'Sharepage', content: 'Some content', user, page }) @@ -43,20 +59,15 @@ test.describe('Page share enforced password protection', () => { let sharingTab: Locator let shareActionsPanel: Locator - test.beforeAll(async () => { - await runOcc(['config:app:set', 'core', 'shareapi_enable_link_password_by_default', '--value', 'yes']) - await runOcc(['config:app:set', 'core', 'shareapi_enforce_links_password', '--value', 'yes']) + test.beforeAll(async ({ request }) => { + await setPasswordProtection(request, true) }) - test.afterAll(async () => { - await runOcc(['config:app:set', 'core', 'shareapi_enable_link_password_by_default', '--value', 'no']) - await runOcc(['config:app:set', 'core', 'shareapi_enforce_links_password', '--value', 'no']) + test.afterAll(async ({ request }) => { + await setPasswordProtection(request, false) }) test.beforeEach(async ({ user, page, collective, pageSidebar }) => { - await runOcc(['app:enable', '--force', 'password_policy']) - - // Dirty hack to wait for config changes to propagate through cache await expect.poll(async () => { const resp = await page.request.get('/ocs/v2.php/cloud/capabilities?format=json', { headers: ocsHeaders, failOnStatusCode: true }) const caps = await resp.json() @@ -76,10 +87,6 @@ test.describe('Page share enforced password protection', () => { await expect(shareActionsPanel.locator('input[autocomplete="new-password"]')).not.toHaveValue('') }) - test.afterEach(async () => { - await runOcc(['app:disable', 'password_policy']) - }) - test('Create share and open share unauthenticated', async ({ page, collective, pageSidebar, sharePage, shareEditor }) => { await shareActionsPanel.locator('input[autocomplete="new-password"]').fill('fiej2Ahl5pae') await shareActionsPanel.getByRole('button', { name: 'Create share' }).click() @@ -95,7 +102,7 @@ test.describe('Page share enforced password protection', () => { await sharePage.goto(shares[0].getShareUrl()) await sharePage.getByRole('textbox', { name: 'Password' }).pressSequentially('fiej2Ahl5pae') await sharePage.getByRole('button', { name: 'Submit' }).click() - await expect(shareEditor.getContent()).toBeVisible() + await shareEditor.getContent().waitFor({ state: 'visible' }) await expect(shareEditor.getContent()).toHaveText('Some content') }) diff --git a/playwright/e2e/version-comparison.spec.ts b/playwright/e2e/version-comparison.spec.ts index dfb8a32a7..c426c29a7 100644 --- a/playwright/e2e/version-comparison.spec.ts +++ b/playwright/e2e/version-comparison.spec.ts @@ -180,11 +180,14 @@ async function freshAuthenticatedContext(page: Page, serviceWorkers: 'allow' | ' } test.describe('Version comparison route and current-byte contract', () => { - test('AUD-06 scheduled browser uses the compatible Text comparison API', async ({ user, page, collective }) => { + test('AUD-06 scheduled browser uses the compatible Text comparison API', async ({ user, page, collective }, testInfo) => { const { dialog } = await openSeededComparison(collective, user, page, 'c599-e2e-text-api-page') + const initialTab = testInfo.config.metadata.comparisonInitialView === 'documents' ? 'Full documents' : 'Changes' + await expect(page.getByRole('tab', { name: initialTab, exact: true })).toHaveAttribute('aria-selected', 'true') await expect(page.getByRole('tab', { name: 'Changes' })).toBeVisible() await expect(page.getByRole('tab', { name: 'Full documents' })).toBeVisible() const changesTab = page.locator('.text-comparison .view-tabs').getByRole('tab', { name: 'Changes' }) + await changesTab.click() const selectedBorderColor = await changesTab.evaluate((element) => getComputedStyle(element).borderBottomColor) await changesTab.hover() await expect(changesTab).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') @@ -226,10 +229,7 @@ test.describe('Version comparison route and current-byte contract', () => { const collectivePage = await collective.createPage({ title: 'c599-e2e-comparison-page', user, page }) await seedVersionPair(collectivePage, user, page) await collectivePage.open() - const sessionCreated = page.waitForResponse((response) => response.request().method() === 'PUT' - && /\/apps\/text\/session\/.*\/create/.test(response.url())) await collectivePage.switchMode(true) - await sessionCreated await expect(page.locator('.text-menubar--ready')).toBeVisible() editor.setMode(true) const typedBytes = 'No-wait Playwright bytes 7f56c599' @@ -380,6 +380,7 @@ test.describe('Version comparison route and current-byte contract', () => { test('C16 rejects public comparison parameters without snapshot requests', async ({ user, page, collective }) => { const collectivePage = await collective.createPage({ title: 'c599-e2e-public-comparison-page', user, page }) + await seedVersionPair(collectivePage, user, page) const share = await collective.createShare({ page }) try { const snapshotRequests: string[] = [] @@ -425,10 +426,7 @@ test.describe('Version comparison route and current-byte contract', () => { const collectivePage = await collective.createPage({ title: 'c599-e2e-committed-generation-page', user, page }) await seedVersionPair(collectivePage, user, page) await collectivePage.open() - const sessionCreated = page.waitForResponse((response) => response.request().method() === 'PUT' - && /\/apps\/text\/session\/.*\/create/.test(response.url())) await collectivePage.switchMode(true) - await sessionCreated await expect(page.locator('.text-menubar--ready')).toBeVisible() editor.setMode(true) const typedBytes = 'Committed generation bytes 7f56c599' diff --git a/playwright/start-nextcloud-server.js b/playwright/start-nextcloud-server.js index 90f117752..547e31858 100644 --- a/playwright/start-nextcloud-server.js +++ b/playwright/start-nextcloud-server.js @@ -7,14 +7,17 @@ import { configureNextcloud, getContainer, runExec, + runOcc, startNextcloud, stopNextcloud, waitOnNextcloud, } from '@nextcloud/e2e-test-server/docker' import { execFileSync } from 'node:child_process' -import { mkdtempSync, rmSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' const serverBranch = process.env.PLAYWRIGHT_NC_SERVER_BRANCH ?? 'master' const textRef = process.env.PLAYWRIGHT_TEXT_REF ?? (serverBranch === 'master' ? 'main' : serverBranch) @@ -26,7 +29,7 @@ const textRepositoryUrl = `https://github.com/${textRepository}.git` */ async function isServerRunning() { try { - const res = await fetch('https://127.0.0.1:8089/status.php') + const res = await fetch('http://127.0.0.1:8089/status.php') return res.ok } catch { return false @@ -39,30 +42,33 @@ async function isServerRunning() { async function start() { return await startNextcloud(serverBranch, true, { exposePort: 8089, + forceRecreate: true, }) } -// eslint-disable-next-line jsdoc/require-jsdoc -async function checkoutText() { +/** + * Check out Text and verify the resolved commit before building it. + */ +export async function checkoutText() { if (!/^[0-9a-f]{40}$/.test(textRef)) { await runExec(['git', 'clone', '--depth=1', `--branch=${textRef}`, textRepositoryUrl, 'apps/text'], { verbose: true }) - return + } else { + await runExec(['git', 'init', 'apps/text'], { verbose: true }) + await runExec(['git', '-C', 'apps/text', 'remote', 'add', 'origin', textRepositoryUrl], { verbose: true }) + await runExec(['git', '-C', 'apps/text', 'fetch', '--depth=1', 'origin', textRef], { verbose: true }) + await runExec(['git', '-C', 'apps/text', 'checkout', '--detach', 'FETCH_HEAD'], { verbose: true }) } - - await runExec(['git', 'init', 'apps/text'], { verbose: true }) - await runExec(['git', '-C', 'apps/text', 'remote', 'add', 'origin', textRepositoryUrl], { verbose: true }) - await runExec(['git', '-C', 'apps/text', 'fetch', '--depth=1', 'origin', textRef], { verbose: true }) - await runExec(['git', '-C', 'apps/text', 'checkout', '--detach', 'FETCH_HEAD'], { verbose: true }) const { stdout } = await runExec(['git', '-C', 'apps/text', 'rev-parse', 'HEAD']) - if (stdout.trim() !== textRef) { + if (!/^[0-9a-f]{40}$/.test(stdout.trim()) || (/^[0-9a-f]{40}$/.test(textRef) && stdout.trim() !== textRef)) { throw new Error(`Text checkout resolved to ${stdout.trim()}, expected ${textRef}`) } + return stdout.trim() } /** * Build Text with the host Node runtime and install its assets in the test server. */ -function buildText() { +export async function buildText() { const directory = mkdtempSync(join(tmpdir(), 'collectives-text-')) const target = `${getContainer().id}:/var/www/html/apps/text` try { @@ -73,9 +79,22 @@ function buildText() { env: { ...process.env, CYPRESS_INSTALL_BINARY: '0' }, }) execFileSync('npm', ['run', 'build'], { cwd: directory, stdio: 'inherit' }) + const assets = ['js', 'css'].flatMap((path) => readdirSync(join(directory, path), { recursive: true }) + .filter((file) => /\.(?:m?js|css)$/.test(file)) + .map((file) => ({ + path: `${path}/${file}`, + sha256: createHash('sha256').update(readFileSync(join(directory, path, file))).digest('hex'), + }))) + if (!assets.some(({ path }) => path === 'js/text-editor.mjs') + || !assets.some(({ path }) => /markdownSourceComparison\.worker-.*\.mjs$/.test(path))) { + throw new Error('The Text build is missing its editor entry or source comparison worker') + } + await runExec(['rm', '-rf', 'apps/text/js', 'apps/text/css']) + await runExec(['mkdir', '-p', 'apps/text/js', 'apps/text/css']) for (const path of ['js', 'css']) { execFileSync('docker', ['cp', `${directory}/${path}/.`, `${target}/${path}`], { stdio: 'inherit' }) } + return assets.toSorted((a, b) => a.path.localeCompare(b.path)) } finally { rmSync(directory, { recursive: true, force: true }) } @@ -90,24 +109,41 @@ async function stop() { process.exit(0) } -process.on('SIGTERM', stop) -process.on('SIGINT', stop) - -// Start the Nextcloud docker container -if (await isServerRunning()) { - console.log('└─ Nextcloud is now ready to use') -} else { +/** + * Prepare a managed runtime and retain its source, installed app and asset identities. + */ +export async function prepareServer() { + if (await isServerRunning()) { + throw new Error('Port 8089 already serves Nextcloud. Use baseURL with an explicitly verified external runtime, or stop it first.') + } const ip = await start() await waitOnNextcloud(ip) await runExec(['git', 'clone', '--depth=1', `--branch=${serverBranch}`, 'https://github.com/nextcloud/password_policy.git', 'apps/password_policy'], { verbose: true }) - await checkoutText() - if (process.env.COLLECTIVES_SEMANTIC_E2E === '1') { - buildText() - } + const textCommit = await checkoutText() + const textAssets = process.env.COLLECTIVES_SEMANTIC_E2E === '1' ? await buildText() : [] await configureNextcloud(['collectives', 'circles', 'files_pdfviewer', 'files_lock', 'notifications', 'text', 'viewer']) + const identity = await getContainer().inspect() + const server = await runExec(['git', 'rev-parse', 'HEAD']) + const status = await runOcc(['status', '--output=json']) + const apps = await runOcc(['app:list', '--output=json']) + mkdirSync('test-results', { recursive: true }) + writeFileSync('test-results/runtime.json', JSON.stringify({ + container: identity.Id, + image: identity.Image, + server: { branch: serverBranch, commit: server.stdout.trim(), status: JSON.parse(status.stdout) }, + collectives: { commit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() }, + text: { repository: textRepository, ref: textRef, commit: textCommit, assets: textAssets }, + apps: JSON.parse(apps.stdout), + comparisonInitialView: process.env.COLLECTIVES_COMPARISON_INITIAL_VIEW, + }, null, 2)) + console.log('Collectives test runtime ready') } -// Idle to wait for shutdown -while (true) { - await new Promise((resolve) => setTimeout(resolve, 5000)) +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.on('SIGTERM', stop) + process.on('SIGINT', stop) + await prepareServer() + while (true) { + await new Promise((resolve) => setTimeout(resolve, 5000)) + } } diff --git a/src/tests/playwrightConfig.spec.ts b/src/tests/playwrightConfig.spec.ts index fe8b7e6d4..68a3dc02c 100644 --- a/src/tests/playwrightConfig.spec.ts +++ b/src/tests/playwrightConfig.spec.ts @@ -9,6 +9,7 @@ const originalBaseURL = process.env.baseURL const originalSemanticE2E = process.env.COLLECTIVES_SEMANTIC_E2E afterEach(() => { + vi.unstubAllEnvs() if (originalBaseURL === undefined) { delete process.env.baseURL } else { @@ -33,11 +34,26 @@ async function loadConfig(baseURL: string | undefined, semanticE2E = false) { } describe('Playwright server selection', () => { + it.each(['changes', 'documents'])('records the explicit %s default for the pinned semantic runtime', async (initialView) => { + vi.stubEnv('CI', '1') + vi.stubEnv('COLLECTIVES_COMPARISON_INITIAL_VIEW', initialView) + const config = await loadConfig(undefined, true) + expect(config.metadata?.comparisonInitialView).toBe(initialView) + expect(config.webServer).toMatchObject({ timeout: 10 * 60 * 1000 }) + }) + + it.each([undefined, '', 'source'])('rejects a missing or invalid semantic CI default: %s', async (initialView) => { + vi.stubEnv('CI', '1') + vi.stubEnv('COLLECTIVES_COMPARISON_INITIAL_VIEW', initialView) + await expect(loadConfig(undefined, true)).rejects.toThrow('COLLECTIVES_COMPARISON_INITIAL_VIEW') + }) + it.each([undefined, ''])('uses the managed server for %s baseURL', async (baseURL) => { const config = await loadConfig(baseURL) expect(config.use?.baseURL).toBe('http://localhost:8089/index.php/') expect(config.webServer).toBeDefined() + expect(config.webServer).toMatchObject({ timeout: 5 * 60 * 1000, wait: { stdout: /Collectives test runtime ready/ } }) }) it('uses a non-empty external baseURL without the managed server', async () => { @@ -56,6 +72,7 @@ describe('Playwright server selection', () => { }) it('runs semantic comparison in Chromium without the Viewer fallback', async () => { + vi.stubEnv('COLLECTIVES_COMPARISON_INITIAL_VIEW', 'changes') const config = await loadConfig(undefined, true) const projects = config.projects?.filter(({ name }) => name?.startsWith('comparison-') && name !== 'comparison-viewer-chromium') diff --git a/src/tests/playwrightRuntime.spec.ts b/src/tests/playwrightRuntime.spec.ts new file mode 100644 index 000000000..628fd42b3 --- /dev/null +++ b/src/tests/playwrightRuntime.spec.ts @@ -0,0 +1,84 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { runExec, startNextcloud } from '@nextcloud/e2e-test-server/docker' +import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@nextcloud/e2e-test-server/docker', () => ({ + configureNextcloud: vi.fn(), + getContainer: vi.fn(() => ({ id: 'test-container' })), + runExec: vi.fn(), + runOcc: vi.fn(), + startNextcloud: vi.fn(), + stopNextcloud: vi.fn(), + waitOnNextcloud: vi.fn(), +})) +vi.mock('node:child_process', () => ({ execFileSync: vi.fn() })) + +const commit = 'b07c97d488f7a2c44edea8b2ad3b88a85f6612bf' + +beforeEach(() => { + vi.resetModules() + vi.resetAllMocks() +}) +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +describe('Playwright runtime identity', () => { + it.each([commit, 'stable35'])('records the resolved Text commit for %s', async (ref) => { + vi.stubEnv('PLAYWRIGHT_TEXT_REF', ref) + vi.mocked(runExec).mockResolvedValue({ stdout: `${commit}\n`, stderr: '', exitCode: 0 }) + const { checkoutText } = await import('../../playwright/start-nextcloud-server.js') + expect(await checkoutText()).toBe(commit) + }) + + it.each(['0'.repeat(40), '', 'not-a-commit'])('rejects an unexpected Text checkout: %s', async (resolved) => { + vi.stubEnv('PLAYWRIGHT_TEXT_REF', commit) + vi.mocked(runExec).mockResolvedValue({ stdout: resolved, stderr: '', exitCode: 0 }) + const { checkoutText } = await import('../../playwright/start-nextcloud-server.js') + await expect(checkoutText()).rejects.toThrow('Text checkout resolved') + }) + + it('rejects an already reachable server before changing its runtime', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })) + const { prepareServer } = await import('../../playwright/start-nextcloud-server.js') + await expect(prepareServer()).rejects.toThrow('explicitly verified external runtime') + expect(startNextcloud).not.toHaveBeenCalled() + expect(runExec).not.toHaveBeenCalled() + }) + + it.each([true, false])('requires the Source worker before replacing installed assets (worker present: %s)', async (includeWorker) => { + let directory = '' + vi.mocked(execFileSync).mockImplementation((command, args) => { + if (command === 'docker' && args?.[1]?.endsWith('apps/text/.')) { + directory = String(args[2]) + mkdirSync(join(directory, 'js')) + mkdirSync(join(directory, 'css')) + writeFileSync(join(directory, 'js/text-editor.mjs'), 'editor') + writeFileSync(join(directory, 'css/text-editor.css'), 'css') + if (includeWorker) { + writeFileSync(join(directory, 'js/markdownSourceComparison.worker-example.worker.mjs'), 'worker') + } + } + return Buffer.from('') + }) + const { buildText } = await import('../../playwright/start-nextcloud-server.js') + if (includeWorker) { + const assets = await buildText() + expect(assets).toContainEqual({ path: 'js/text-editor.mjs', sha256: createHash('sha256').update('editor').digest('hex') }) + expect(assets).toHaveLength(3) + } else { + await expect(buildText()).rejects.toThrow('missing its editor entry or source comparison worker') + expect(runExec).not.toHaveBeenCalled() + } + expect(existsSync(directory)).toBe(false) + }) +})