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)
+ },
+)