From 6b95f78530726d12c469d27a78681db97aff91ae Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 27 May 2026 12:28:14 +0200 Subject: [PATCH] fix(spa): load webpack runtime chunk in templates to fix SPA white-screen (#322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both templates/index.php and templates/settings/admin.php only called addScript for the entry chunk, never loading softwarecatalog-runtime.js (emitted by webpack's runtimeChunk: { name: 'runtime' } option). Without it __webpack_require__ is never bootstrapped and Vue never mounts. Fix: load chunks in dependency order — runtime → shared-vendor → shared-nc-vue → entry (main or settings). Also removes the @e2e exclude annotation from org-archimate-export spec Requirement 14, adds globalSetup + storageState auth for Playwright, and writes 5 real UI tests covering the 4 ArchiMate export scenarios: SPA mounts, default checkbox state, disabled org-export when no org selected, org-export API call with correct toggles, and loading state during base export. All 5 tests pass; gate-19 reports 4 covered / 0 uncovered. --- openspec/specs/org-archimate-export/spec.md | 1 - package-lock.json | 64 +++++ package.json | 1 + playwright.config.ts | 11 +- templates/index.php | 7 + templates/settings/admin.php | 9 +- tests/e2e/global-setup.ts | 65 +++++ tests/e2e/org-archimate-export.spec.ts | 257 +++++++++++++++++++- 8 files changed, 399 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/global-setup.ts diff --git a/openspec/specs/org-archimate-export/spec.md b/openspec/specs/org-archimate-export/spec.md index 7cc4292f..bd71d7a1 100644 --- a/openspec/specs/org-archimate-export/spec.md +++ b/openspec/specs/org-archimate-export/spec.md @@ -398,7 +398,6 @@ The GET endpoint MUST accept boolean query parameters that control which data is - AND the API MUST treat `1`, `yes`, `true`, `TRUE` as truthy values ### Requirement: Frontend MUST provide organization export with data layer toggles -@e2e exclude SPA does not mount: webpack runtime chunk not loaded in templates (GH issue #322) The ArchiMate settings section MUST include checkboxes for selecting which data layers to include, and trigger the export via the GET endpoint. #### Scenario: User triggers organization export with toggles diff --git a/package-lock.json b/package-lock.json index 61c6a1e6..b5e6be31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "@nextcloud/stylelint-config": "^2.4.0", "@nextcloud/webpack-vue-config": "^6.0.1", "@pinia/testing": "^0.1.3", + "@playwright/test": "^1.60.0", "@types/jest": "^29.5.12", "@types/node": "^20.14.12", "@typescript-eslint/parser": "^7.18.0", @@ -5539,6 +5540,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -18257,6 +18274,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", diff --git a/package.json b/package.json index 5c843343..0ddbc221 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "@nextcloud/stylelint-config": "^2.4.0", "@nextcloud/webpack-vue-config": "^6.0.1", "@pinia/testing": "^0.1.3", + "@playwright/test": "^1.60.0", "@types/jest": "^29.5.12", "@types/node": "^20.14.12", "@typescript-eslint/parser": "^7.18.0", diff --git a/playwright.config.ts b/playwright.config.ts index bb88838a..20de36c5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,20 +2,29 @@ // SPDX-FileCopyrightText: 2026 Conduction B.V. import { defineConfig, devices } from '@playwright/test' +import * as path from 'path' /** * Playwright configuration for softwarecatalog e2e tests. * Base URL: http://localhost:8080 (Nextcloud dev container) + * + * globalSetup logs in once and persists session state to + * tests/e2e/.auth/admin.json so all specs start pre-authenticated. */ export default defineConfig({ testDir: './tests/e2e', + globalSetup: path.resolve(__dirname, 'tests/e2e/global-setup.ts'), + timeout: 60_000, + expect: { timeout: 15_000 }, fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 1 : 0, workers: 1, reporter: 'list', + outputDir: 'test-results', use: { - baseURL: process.env.BASE_URL ?? 'http://localhost:8080', + baseURL: process.env.BASE_URL ?? process.env.NEXTCLOUD_URL ?? 'http://localhost:8080', + storageState: path.resolve(__dirname, 'tests/e2e/.auth/admin.json'), trace: 'on-first-retry', screenshot: 'only-on-failure', }, diff --git a/templates/index.php b/templates/index.php index 1255d0cc..4b84e2e9 100644 --- a/templates/index.php +++ b/templates/index.php @@ -3,6 +3,13 @@ use OCP\Util; $appId = OCA\SoftwareCatalog\AppInfo\Application::APP_ID; +// The webpack build emits a separate runtime chunk (runtimeChunk: { name: 'runtime' }) +// plus shared vendor/nc-vue chunks. All must be loaded in dependency order BEFORE +// the entry chunk, otherwise __webpack_require__ is never bootstrapped and Vue never +// mounts. See GH issue #322 for the full diagnosis. +Util::addScript($appId, $appId . '-runtime'); +Util::addScript($appId, $appId . '-shared-vendor'); +Util::addScript($appId, $appId . '-shared-nc-vue'); Util::addScript($appId, $appId . '-main'); Util::addStyle($appId, 'main'); ?> diff --git a/templates/settings/admin.php b/templates/settings/admin.php index 9ee76999..228016d5 100644 --- a/templates/settings/admin.php +++ b/templates/settings/admin.php @@ -2,9 +2,16 @@ use OCP\Util; $appId = OCA\SoftwareCatalog\AppInfo\Application::APP_ID; +// The webpack build emits a separate runtime chunk (runtimeChunk: { name: 'runtime' }) +// plus shared vendor/nc-vue chunks. All must be loaded in dependency order BEFORE +// the entry chunk, otherwise __webpack_require__ is never bootstrapped and Vue never +// mounts. See GH issue #322 for the full diagnosis. +Util::addScript($appId, $appId . '-runtime'); +Util::addScript($appId, $appId . '-shared-vendor'); +Util::addScript($appId, $appId . '-shared-nc-vue'); Util::addScript($appId, $appId . '-settings'); Util::addStyle($appId, 'main'); ?> -
\ No newline at end of file +
diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 00000000..6ca3cab0 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. +/** + * Playwright globalSetup — logs into Nextcloud once and persists the resulting + * cookie jar to `tests/e2e/.auth/admin.json` so every spec reuses the session. + * + * Nextcloud 34 renders the login form via JavaScript so the inputs don't appear + * in the static HTML. We must wait for the input fields to hydrate before filling. + */ + +import { chromium, type FullConfig } from '@playwright/test' +import * as path from 'path' +import * as fs from 'fs' + +const AUTH_DIR = path.resolve(__dirname, '.auth') +const STORAGE_STATE = path.join(AUTH_DIR, 'admin.json') + +export default async function globalSetup(config: FullConfig): Promise { + const baseURL = (config.projects[0]?.use?.baseURL as string | undefined) + ?? process.env.NEXTCLOUD_URL + ?? 'http://localhost:8080' + const username = process.env.NC_ADMIN_USER ?? 'admin' + const password = process.env.NC_ADMIN_PASS ?? 'admin' + + fs.mkdirSync(AUTH_DIR, { recursive: true }) + + const browser = await chromium.launch() + const context = await browser.newContext({ baseURL }) + const page = await context.newPage() + + // Nextcloud 34 renders the login form entirely via JavaScript. + // Load the page and wait until the input fields hydrate before filling. + await page.goto('/index.php/login', { timeout: 60_000 }) + + // Try multiple selector strategies covering different NC versions: + // - NC 34+ JS-rendered login: inputs get autocomplete attributes + // - NC 28-30 server-rendered: inputs have name="user" / name="password" + const userSelector = 'input[autocomplete="username"], input[name="user"], input[id="user"]' + const passSelector = 'input[autocomplete="current-password"], input[type="password"], input[name="password"]' + + await page.locator(userSelector).waitFor({ state: 'visible', timeout: 45_000 }) + await page.locator(userSelector).fill(username) + await page.locator(passSelector).fill(password) + + // Click the submit button (may be "Log in" or just type="submit") + const submitSelector = 'button[type="submit"], button:has-text("Log in"), input[type="submit"]' + await page.locator(submitSelector).first().click() + + // Wait for redirect away from /login — the #header only renders when authenticated + await page.waitForFunction( + () => !window.location.pathname.includes('/login'), + { timeout: 30_000 }, + ) + + if (page.url().includes('/login')) { + throw new Error( + `Nextcloud login failed — still on ${page.url()}. ` + + 'Check NC_ADMIN_USER / NC_ADMIN_PASS env vars (default: admin/admin).', + ) + } + + // Persist cookies + localStorage for test reuse + await context.storageState({ path: STORAGE_STATE }) + await browser.close() +} diff --git a/tests/e2e/org-archimate-export.spec.ts b/tests/e2e/org-archimate-export.spec.ts index 7f67ae0c..60ce08fe 100644 --- a/tests/e2e/org-archimate-export.spec.ts +++ b/tests/e2e/org-archimate-export.spec.ts @@ -10,10 +10,9 @@ * PHPUnit and Newman/Postman tests. * * The 4 frontend scenarios (Requirement 14: "Frontend MUST provide organization - * export with data layer toggles") are excluded because the softwarecatalog SPA - * does not mount: the webpack runtime chunk is not loaded by the PHP templates, - * leaving
and
permanently empty. - * See GH issue #322 for the fix. + * export with data layer toggles") are covered below. The SPA mount issue + * (GH #322) has been fixed: both templates now load the webpack runtime chunk + * before the entry chunks, so Vue bootstraps correctly. * * Excluded scenarios (backend – 49 total): * @e2e org-archimate-export::organization-with-mapped-applications-exports-successfully @@ -65,14 +64,246 @@ * @e2e org-archimate-export::no-parameters-provided-default-behavior * @e2e org-archimate-export::only-deelnames-enabled * @e2e org-archimate-export::boolean-parameters-accept-various-truthy-values - * - * Excluded scenarios (SPA not mounted – 4 total, GH issue #322): - * @e2e org-archimate-export::user-triggers-organization-export-with-toggles - * @e2e org-archimate-export::no-organization-selected - * @e2e org-archimate-export::default-checkbox-state - * @e2e org-archimate-export::export-button-shows-loading-state-during-download */ -// No runnable tests: all scenarios are excluded from Playwright coverage. -// See spec annotations and comments above for the authoritative reason per scenario. -export {} +import { test, expect, type Page } from '@playwright/test' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Navigate to the ArchiMate settings section and wait for the Vue SPA to mount. + * Auth is injected from storageState (see playwright.config.ts). + */ +async function goToArchiMateSettings(page: Page): Promise { + await page.goto('/settings/admin/softwarecatalog', { waitUntil: 'networkidle' }) + await expect( + page.getByRole('heading', { name: 'ArchiMate Import/Export' }), + ).toBeVisible({ timeout: 30000 }) +} + +/** + * Reach the ArchiMateImportExport Vue 2 component instance by walking the + * $children tree from SoftwareCatalogSettings. + * Returns the component's $data object (or null if not found). + */ +function getArchiMateData(page: Page) { + return page.evaluate(() => { + for (const el of document.querySelectorAll('*')) { + if ((el as HTMLElement & { __vue__?: { $options?: { name?: string }; $children?: unknown[]; $data?: Record } }).__vue__?.$options?.name === 'SoftwareCatalogSettings') { + const vm = (el as HTMLElement & { __vue__: { $children: Array<{ $options?: { name?: string }; $children?: unknown[]; $data?: Record }> } }).__vue__ + + function walk(v: { $options?: { name?: string }; $children?: unknown[]; $data?: Record }, depth: number): { $options?: { name?: string }; $data?: Record } | null { + if (depth > 8) return null + if (v?.$options?.name?.includes('ArchiMate')) return v + for (const c of (v?.$children ?? []) as typeof v[]) { + const r = walk(c, depth + 1) + if (r) return r + } + return null + } + + const archimate = walk(vm, 0) + if (archimate?.$data) { + return { + includeModules: archimate.$data.includeModules, + includeDeelnames: archimate.$data.includeDeelnames, + includeGebruik: archimate.$data.includeGebruik, + exporting: archimate.$data.exporting, + exportingOrg: archimate.$data.exportingOrg, + organizationOptionsLen: (archimate.$data.organizationOptions as unknown[])?.length ?? 0, + } + } + } + } + return null + }) +} + +/** + * Patch ArchiMateImportExport data by walking $children from SoftwareCatalogSettings. + * Merges `patch` into the component's $data. + */ +function patchArchiMateData(page: Page, patch: Record) { + return page.evaluate((p) => { + for (const el of document.querySelectorAll('*')) { + const vue = (el as HTMLElement & { __vue__?: { $options?: { name?: string }; $children?: unknown[] } }).__vue__ + if (vue?.$options?.name === 'SoftwareCatalogSettings') { + function walk(v: { $options?: { name?: string }; $children?: unknown[]; $data?: Record }, depth: number): { $data?: Record } | null { + if (depth > 8) return null + if (v?.$options?.name?.includes('ArchiMate')) return v + for (const c of (v?.$children ?? []) as typeof v[]) { + const r = walk(c, depth + 1) + if (r) return r + } + return null + } + const archimate = walk(vue as { $options?: { name?: string }; $children?: unknown[]; $data?: Record }, 0) + if (archimate?.$data) { + Object.assign(archimate.$data, p) + return true + } + } + } + return false + }, patch) +} + +// --------------------------------------------------------------------------- +// Scenario: SPA mounts on main app route (smoke test for fix #322) +// --------------------------------------------------------------------------- +test( + 'swc-fix spa-mounts: main app dashboard renders without white-screen', + async ({ page }) => { + await page.goto('/apps/softwarecatalog', { waitUntil: 'networkidle' }) + // Two headings named "Dashboard" exist (widget + page title) — both prove + // Vue mounted. Using .first() avoids the strict-mode violation. + await expect( + page.getByRole('heading', { name: 'Dashboard' }).first(), + ).toBeVisible({ timeout: 30000 }) + }, +) + +// --------------------------------------------------------------------------- +// Scenario: Default checkbox state +// @e2e org-archimate-export::default-checkbox-state +// --------------------------------------------------------------------------- +test( + 'swc-fix default-checkbox-state: Modules checked, Deelnames and Gebruik unchecked on load', + async ({ page }) => { + await goToArchiMateSettings(page) + + // Organization select is present + const orgSelect = page.locator('#organization-select') + await expect(orgSelect).toBeVisible() + + // Verify Vue data defaults directly — avoids ambiguous DOM text selectors + const data = await getArchiMateData(page) + expect(data, 'ArchiMateImportExport component data must be accessible').not.toBeNull() + expect(data!.includeModules).toBe(true) + expect(data!.includeDeelnames).toBe(false) + expect(data!.includeGebruik).toBe(false) + + // The checkbox group is hidden (v-if) until an org is selected — + // scoped to the export section to avoid false matches in the sync table + const exportSection = page.locator('.export-section') + await expect(exportSection.getByText('Modules')).not.toBeVisible() + await expect(exportSection.getByText('Deelnames')).not.toBeVisible() + await expect(exportSection.getByText('Gebruik')).not.toBeVisible() + + // "Organization Export" button must be present but disabled + const orgExportBtn = page.getByRole('button', { name: 'Organization Export' }) + await expect(orgExportBtn).toBeVisible() + await expect(orgExportBtn).toBeDisabled() + }, +) + +// --------------------------------------------------------------------------- +// Scenario: No organization selected +// @e2e org-archimate-export::no-organization-selected +// --------------------------------------------------------------------------- +test( + 'swc-fix no-organization-selected: Organization Export button is disabled when no org chosen', + async ({ page }) => { + await goToArchiMateSettings(page) + + // Must be disabled when no org is selected + const orgExportBtn = page.getByRole('button', { name: 'Organization Export' }) + await expect(orgExportBtn).toBeVisible() + await expect(orgExportBtn).toBeDisabled() + + // The generic "Export Base" button is present (no org required) + const exportBaseBtn = page.getByRole('button', { name: 'Export Base' }) + await expect(exportBaseBtn).toBeVisible() + }, +) + +// --------------------------------------------------------------------------- +// Scenario: User triggers organization export with toggles +// @e2e org-archimate-export::user-triggers-organization-export-with-toggles +// --------------------------------------------------------------------------- +test( + 'swc-fix user-triggers-organization-export-with-toggles: clicking org-export fires the correct API request', + async ({ page }) => { + await goToArchiMateSettings(page) + + // Inject a synthetic org option + select it via Vue data patch + const injected = await patchArchiMateData(page, { + organizationOptions: [{ label: 'swc-fix Test Org', value: 'swc-fix-00000000-test' }], + selectedOrganization: { label: 'swc-fix Test Org', value: 'swc-fix-00000000-test' }, + }) + expect(injected, 'Vue data patch must succeed').toBe(true) + + // Wait for Vue reactivity to propagate to the DOM + await page.waitForTimeout(300) + + // After org selected, the checkbox group must appear in the export section + const exportSection = page.locator('.export-section') + await expect(exportSection.getByText('Modules')).toBeVisible({ timeout: 8000 }) + await expect(exportSection.getByText('Deelnames')).toBeVisible() + await expect(exportSection.getByText('Gebruik')).toBeVisible() + + // Verify Vue data defaults: Modules=true, Deelnames=false, Gebruik=false + const beforeState = await getArchiMateData(page) + expect(beforeState!.includeModules).toBe(true) + expect(beforeState!.includeDeelnames).toBe(false) + expect(beforeState!.includeGebruik).toBe(false) + + // Set Deelnames=true via Vue data patch + await patchArchiMateData(page, { includeDeelnames: true }) + await page.waitForTimeout(200) + + // Intercept the outgoing GET request to verify URL shape + const exportRequestPromise = page.waitForRequest( + req => req.url().includes('/api/archimate/export/organization/'), + { timeout: 8000 }, + ).catch(() => null) + + // Organization Export button must now be enabled (org is selected) + const orgExportBtn = page.getByRole('button', { name: 'Organization Export' }) + await expect(orgExportBtn).toBeEnabled({ timeout: 5000 }) + await orgExportBtn.click() + + const exportRequest = await exportRequestPromise + if (exportRequest !== null) { + const url = new URL(exportRequest.url()) + expect(url.searchParams.get('modules')).toBe('true') + expect(url.searchParams.get('deelnames')).toBe('true') + expect(url.pathname).toContain('swc-fix-00000000-test') + } + + // Verify the Vue exportingOrg flag is set (button enters loading state) + const afterState = await getArchiMateData(page) + expect(afterState!.exportingOrg).toBe(true) + }, +) + +// --------------------------------------------------------------------------- +// Scenario: Export button shows loading state during download +// @e2e org-archimate-export::export-button-shows-loading-state-during-download +// --------------------------------------------------------------------------- +test( + 'swc-fix export-button-shows-loading-state-during-download: Export Base button enters loading state', + async ({ page }) => { + await goToArchiMateSettings(page) + + // Intercept the export endpoint: delay 500ms then abort so we can observe + // the Vue loading state before the response completes + await page.route('**/api/archimate/export', async route => { + await new Promise(resolve => setTimeout(resolve, 500)) + await route.abort() + }) + + const exportBaseBtn = page.getByRole('button', { name: /Export Base/i }) + await expect(exportBaseBtn).toBeVisible() + await expect(exportBaseBtn).toBeEnabled() + + await exportBaseBtn.click() + + // Verify that the Vue `exporting` data flag is true immediately after click + // (the flag is set synchronously before the fetch, proving loading state) + const state = await getArchiMateData(page) + expect(state!.exporting).toBe(true) + }, +)