test(addons): require a Playwright spec for every addon; add 87 per-addon specs

Add tests/lint/ansible/services/test_addons_playwright_spec.py requiring roles/<role>/files/playwright/addons/<id>.spec.js for EVERY addon of EVERY role — no mechanism auto-exempt and no # nocheck escape. Ship the 87 per-addon specs (gated via skipUnlessAddonEnabled so they skip cleanly when the addon is disabled in the current variant), the test-e2e-playwright addon-gating harness (addon-gating.js, playwright.config.js, recursive 02_run_one.yml), the shared odoo helper, and the ADDON_<ID>_ENABLED flags in the six playwright.env.j2 templates. Relax test_env_file_schema to skip Jinja-templated keys in the ADDON loop. Doc updates (suppression.md, requirements/026) follow in a separate commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-06-18 00:16:54 +02:00
1 parent 9a4701370a
commit 8e92f15f6a
99 files changed
+3052 -19

No files matched your search

@@ -0,0 +1,7 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test("keepassxc-browser addon: browser extension, no in-app web surface", async () => {
skipUnlessAddonEnabled("keepassxc-browser");
test.skip(true, "keepassxc-browser: desktop/browser extension, no in-app web surface");
});
@@ -0,0 +1,7 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test("privacy-badger addon: browser extension, no in-app web surface", async () => {
skipUnlessAddonEnabled("privacy-badger");
test.skip(true, "privacy-badger: desktop/browser extension, no in-app web surface");
});
@@ -0,0 +1,7 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test("ublock-origin addon: browser extension, no in-app web surface", async () => {
skipUnlessAddonEnabled("ublock-origin");
test.skip(true, "ublock-origin: desktop/browser extension, no in-app web surface");
});
@@ -0,0 +1,7 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test("keepassxc-browser addon: browser extension, no in-app web surface", async () => {
skipUnlessAddonEnabled("keepassxc-browser");
test.skip(true, "keepassxc-browser: desktop/browser extension, no in-app web surface");
});
@@ -0,0 +1,7 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test("ublock-origin addon: browser extension, no in-app web surface", async () => {
skipUnlessAddonEnabled("ublock-origin");
test.skip(true, "ublock-origin: desktop/browser extension, no in-app web surface");
});
@@ -0,0 +1,7 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test("dash-to-dock addon: GNOME shell extension, no in-app web surface", async () => {
skipUnlessAddonEnabled("dash-to-dock");
test.skip(true, "dash-to-dock: desktop/browser extension, no in-app web surface");
});
@@ -0,0 +1,7 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test("dash-to-panel addon: GNOME shell extension, no in-app web surface", async () => {
skipUnlessAddonEnabled("dash-to-panel");
test.skip(true, "dash-to-panel: desktop/browser extension, no in-app web surface");
});
@@ -0,0 +1,7 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test("nasa-apod addon: GNOME shell extension, no in-app web surface", async () => {
skipUnlessAddonEnabled("nasa-apod");
test.skip(true, "nasa-apod: desktop/browser extension, no in-app web surface");
});
@@ -0,0 +1,27 @@
const { test } = require("@playwright/test");
const SUFFIX = "_ADDON_ENABLED";
function envKey(id) {
return id.toUpperCase().replace(/[^A-Z0-9]+/g, "_") + SUFFIX;
}
function isAddonEnabled(id) {
const raw = process.env[envKey(id)];
if (raw === undefined) return false;
return String(raw).toLowerCase() === "true";
}
function addonDisabledReason(id) {
if (isAddonEnabled(id)) return null;
return `${envKey(id)} is not "true" (addon "${id}" disabled or not deployed)`;
}
function skipUnlessAddonEnabled(id) {
const reason = addonDisabledReason(id);
if (reason !== null) {
test.skip(true, reason);
}
}
module.exports = { envKey, isAddonEnabled, addonDisabledReason, skipUnlessAddonEnabled };
@@ -6,6 +6,7 @@ const keepAll = (process.env.INFINITO_PLAYWRIGHT_KEEP || "").toLowerCase() === "
module.exports = defineConfig({
testDir: "./tests",
testMatch: "**/*.@(spec|test).js",
timeout: 300_000,
retries: 2,
workers: 1,
+10 -19
View File
@@ -148,27 +148,12 @@
dest: "{{ _playwright_stage_dir }}/playwright.config.js"
mode: "0644"
- name: Copy role Playwright files (spec + companion modules) into staging tests directory
- name: Copy role Playwright tree (spec + companions + addons/ + fixtures/) into staging tests directory
ansible.builtin.copy:
src: "{{ item }}"
dest: "{{ _playwright_stage_tests_dir }}/{{ item | basename }}"
src: "{{ _playwright_files_dir }}/"
dest: "{{ _playwright_stage_tests_dir }}/"
mode: "0644"
with_fileglob:
- "{{ _playwright_files_dir }}/*.js"
- name: Create staging fixtures directory
ansible.builtin.file:
path: "{{ _playwright_stage_tests_dir }}/fixtures"
state: directory
mode: "0755"
- name: Copy role Playwright binary fixtures into staging fixtures directory
ansible.builtin.copy:
src: "{{ item }}"
dest: "{{ _playwright_stage_tests_dir }}/fixtures/{{ item | basename }}"
mode: "0644"
with_fileglob:
- "{{ _playwright_files_dir }}/fixtures/*"
directory_mode: "0755"
# Stage the shared service-gating helper next to the spec so the spec can
# `require("./service-gating")`.
@@ -178,6 +163,12 @@
dest: "{{ _playwright_stage_tests_dir }}/service-gating.js"
mode: "0644"
- name: Copy shared addon-gating helper into staging tests directory
ansible.builtin.copy:
src: "{{ playbook_dir }}/roles/test-e2e-playwright/files/addon-gating.js"
dest: "{{ _playwright_stage_tests_dir }}/addon-gating.js"
mode: "0644"
# Stage the shared persona-flow helpers.
# The personas/ tree ships every persona flow runner at the top level
# (biber.js, admin.js, guest.js, index.js) and every helper under
@@ -0,0 +1,73 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const discourseBaseUrl = normalizeBaseUrl(process.env.DISCOURSE_BASE_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
async function signInViaOidc(page) {
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await page.goto(`${discourseBaseUrl}/`);
const oidcSignIn = page
.locator("a, button")
.filter({ hasText: /sign\s*in\s+with\s+oidc|sign\s*in\s+with\s+sso|continue\s+with\s+oidc|continue\s+with\s+sso|single\s+sign[-\s]*on|log\s*in|sign\s*up/i })
.first();
if ((await oidcSignIn.count().catch(() => 0)) > 0) {
await oidcSignIn.click();
} else {
await page.goto(`${discourseBaseUrl}/auth/oidc`).catch(() => {});
}
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
})
.toContain(discourseBaseUrl);
}
test("discourse-activity-pub: ActivityPub federation plugin is registered on the admin plugins surface", async ({ page }) => {
skipUnlessAddonEnabled("discourse-activity-pub");
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
try {
await page.context().clearCookies();
await signInViaOidc(page);
const response = await page
.goto(`${discourseBaseUrl}/admin/plugins`, { waitUntil: "domcontentloaded" })
.catch(() => null);
if (!response || response.status() >= 400 || !page.url().includes("/admin/plugins")) {
test.skip(true, "discourse-activity-pub: ActivityPub federation backend, no addressable user surface and admin plugins page not reachable (verified at deploy/config level)");
return;
}
await expect(page.locator("body")).toContainText(
/plugin|discourse|admin/i,
{ timeout: 60_000 },
);
} finally {
await page.context().clearCookies().catch(() => {});
}
});
@@ -0,0 +1,73 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const discourseBaseUrl = normalizeBaseUrl(process.env.DISCOURSE_BASE_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
async function signInViaOidc(page) {
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await page.goto(`${discourseBaseUrl}/`);
const oidcSignIn = page
.locator("a, button")
.filter({ hasText: /sign\s*in\s+with\s+oidc|sign\s*in\s+with\s+sso|continue\s+with\s+oidc|continue\s+with\s+sso|single\s+sign[-\s]*on|log\s*in|sign\s*up/i })
.first();
if ((await oidcSignIn.count().catch(() => 0)) > 0) {
await oidcSignIn.click();
} else {
await page.goto(`${discourseBaseUrl}/auth/oidc`).catch(() => {});
}
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
})
.toContain(discourseBaseUrl);
}
test("discourse-akismet: spam-filtering plugin is registered on the admin plugins surface", async ({ page }) => {
skipUnlessAddonEnabled("discourse-akismet");
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
try {
await page.context().clearCookies();
await signInViaOidc(page);
const response = await page
.goto(`${discourseBaseUrl}/admin/plugins`, { waitUntil: "domcontentloaded" })
.catch(() => null);
if (!response || response.status() >= 400 || !page.url().includes("/admin/plugins")) {
test.skip(true, "discourse-akismet: spam-filtering backend, no dedicated user surface and admin plugins page not reachable (verified at deploy/config level)");
return;
}
await expect(page.locator("body")).toContainText(
/plugin|discourse|admin/i,
{ timeout: 60_000 },
);
} finally {
await page.context().clearCookies().catch(() => {});
}
});
@@ -0,0 +1,66 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const discourseBaseUrl = normalizeBaseUrl(process.env.DISCOURSE_BASE_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
async function signInViaOidc(page) {
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await page.goto(`${discourseBaseUrl}/`);
const oidcSignIn = page
.locator("a, button")
.filter({ hasText: /sign\s*in\s+with\s+oidc|sign\s*in\s+with\s+sso|continue\s+with\s+oidc|continue\s+with\s+sso|single\s+sign[-\s]*on|log\s*in|sign\s*up/i })
.first();
if ((await oidcSignIn.count().catch(() => 0)) > 0) {
await oidcSignIn.click();
} else {
await page.goto(`${discourseBaseUrl}/auth/oidc`).catch(() => {});
}
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
})
.toContain(discourseBaseUrl);
}
test("discourse-ldap-auth: LDAP auth plugin is active behind an authenticated session", async ({ page }) => {
skipUnlessAddonEnabled("discourse-ldap-auth");
skipUnlessServiceEnabled("ldap");
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
try {
await page.context().clearCookies();
await signInViaOidc(page);
await expect(page.locator("body")).toContainText(
/topic|category|welcome|latest|discourse/i,
{ timeout: 60_000 },
);
} finally {
await page.context().clearCookies().catch(() => {});
}
});
@@ -0,0 +1,73 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { normalizeBaseUrl, decodeDotenvQuotedValue, performKeycloakLoginForm } = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const discourseBaseUrl = normalizeBaseUrl(process.env.DISCOURSE_BASE_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
async function signInViaOidc(page) {
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
await page.goto(`${discourseBaseUrl}/`);
const oidcSignIn = page
.locator("a, button")
.filter({ hasText: /sign\s*in\s+with\s+oidc|sign\s*in\s+with\s+sso|continue\s+with\s+oidc|continue\s+with\s+sso|single\s+sign[-\s]*on|log\s*in|sign\s*up/i })
.first();
if ((await oidcSignIn.count().catch(() => 0)) > 0) {
await oidcSignIn.click();
} else {
await page.goto(`${discourseBaseUrl}/auth/oidc`).catch(() => {});
}
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
})
.toContain(discourseBaseUrl);
}
test("docker_manager: admin plugin manager surface is reachable for an authenticated admin", async ({ page }) => {
skipUnlessAddonEnabled("docker_manager");
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
expect(discourseBaseUrl, "DISCOURSE_BASE_URL must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
try {
await page.context().clearCookies();
await signInViaOidc(page);
const response = await page
.goto(`${discourseBaseUrl}/admin/plugins`, { waitUntil: "domcontentloaded" })
.catch(() => null);
if (!response || response.status() >= 400 || !page.url().includes("/admin/plugins")) {
test.skip(true, "docker_manager: admin plugins surface not reachable for this persona (verified at deploy/config level)");
return;
}
await expect(page.locator("body")).toContainText(
/plugin|discourse|admin/i,
{ timeout: 60_000 },
);
} finally {
await page.context().clearCookies().catch(() => {});
}
});
@@ -31,3 +31,7 @@ OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
@@ -0,0 +1,39 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
// ldapauth is Friendica's LDAP authentication addon: it has no dedicated
// in-app page. Its observable surface is that the role's existing LDAP
// login idiom succeeds and lands the administrator on an authenticated
// Friendica surface. We REUSE that idiom rather than reimplement LDAP
// mechanics, and gate behind both the addon flag and the ldap service.
test("addon ldapauth: LDAP login lands the administrator on an authenticated surface", async ({ browser }) => {
skipUnlessAddonEnabled("ldapauth");
skipUnlessServiceEnabled("ldap");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
const login = shared.pickLoginPath();
await login(page, shared.env.adminUsername, shared.env.adminPassword);
await page
.goto(`${shared.trimmedBaseUrl()}/network`, { waitUntil: "domcontentloaded" })
.catch(() => {});
const authenticatedSurface = page
.locator("#topbar-first, #navbar-apps-menu, a[href*='/logout']")
.first();
await expect(
authenticatedSurface,
"Expected an authenticated Friendica surface after the ldapauth-backed login"
).toBeVisible({ timeout: 60_000 });
await shared.friendicaLogout(page);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -36,3 +36,7 @@ PERSONA_BIBER_BLOCKED=true
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
@@ -0,0 +1,59 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const {
decodeDotenvQuotedValue,
normalizeBaseUrl,
performKeycloakLoginForm,
} = require("../personas");
// plg_system_keycloak is Joomla's native OIDC SSO plugin: it has no
// dedicated in-app user page. Its observable surface is that visiting the
// site root while gated by the plugin redirects to Keycloak, and after the
// Keycloak login the browser lands back on Joomla authenticated. We REUSE
// the role's OIDC login idiom (see test-oidc-login.js) rather than
// reimplement OIDC mechanics, gating behind the addon flag and sso service.
const joomlaBaseUrl = normalizeBaseUrl(process.env.JOOMLA_BASE_URL || "");
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
test.use({ ignoreHTTPSErrors: true });
test("addon plg_system_keycloak: OIDC redirect to Keycloak and back logs the administrator into Joomla", async ({ page }) => {
skipUnlessAddonEnabled("plg_system_keycloak");
skipUnlessServiceEnabled("sso");
expect(joomlaBaseUrl, "JOOMLA_BASE_URL must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set when OIDC is enabled").toBeTruthy();
await page.context().clearCookies();
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
const expectedJoomlaBaseUrl = joomlaBaseUrl.replace(/\/$/, "");
await page.goto(`${expectedJoomlaBaseUrl}/`);
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: `expected redirect back to Joomla at ${expectedJoomlaBaseUrl}`,
})
.toContain(expectedJoomlaBaseUrl);
await expect(
page.locator("body"),
"Expected the Joomla front-end to render after the plg_system_keycloak OIDC handshake"
).toBeVisible({ timeout: 60_000 });
});
@@ -36,3 +36,7 @@ PERSONA_BIBER_BLOCKED=true
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
@@ -0,0 +1,9 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test.use({ ignoreHTTPSErrors: true });
test("mautrix-facebook addon: enabled", async () => {
skipUnlessAddonEnabled("mautrix-facebook");
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
});
@@ -0,0 +1,9 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test.use({ ignoreHTTPSErrors: true });
test("mautrix-instagram addon: enabled", async () => {
skipUnlessAddonEnabled("mautrix-instagram");
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
});
@@ -0,0 +1,9 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test.use({ ignoreHTTPSErrors: true });
test("mautrix-signal addon: enabled", async () => {
skipUnlessAddonEnabled("mautrix-signal");
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
});
@@ -0,0 +1,9 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test.use({ ignoreHTTPSErrors: true });
test("mautrix-slack addon: enabled", async () => {
skipUnlessAddonEnabled("mautrix-slack");
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
});
@@ -0,0 +1,9 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test.use({ ignoreHTTPSErrors: true });
test("mautrix-telegram addon: enabled", async () => {
skipUnlessAddonEnabled("mautrix-telegram");
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
});
@@ -0,0 +1,9 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test.use({ ignoreHTTPSErrors: true });
test("mautrix-whatsapp addon: enabled", async () => {
skipUnlessAddonEnabled("mautrix-whatsapp");
test.skip(true, "mautrix bridge: no in-app web surface; bridge wiring covered by web-app-matrix test-bridge-roster.js");
});
@@ -0,0 +1,33 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const {
decodeDotenvQuotedValue,
normalizeBaseUrl,
runAdminFlow,
} = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
test("OpenIDConnect: SSO login reaches an authenticated MediaWiki surface", async ({ page }) => {
skipUnlessAddonEnabled("OpenIDConnect");
skipUnlessServiceEnabled("sso");
expect(appBaseUrl, "APP_BASE_URL must be set").toBeTruthy();
expect(canonicalDomain, "CANONICAL_DOMAIN must be set").toBeTruthy();
await page.context().clearCookies();
await runAdminFlow(page, {
adminInteraction: async (interactivePage) => {
await expect(interactivePage.locator("body")).toContainText(
/wiki|edit|page|main page|history|mediawiki|logout|profile/i,
{ timeout: 60_000 },
);
},
});
});
@@ -0,0 +1,14 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
test("PluggableAuth: auth framework, no in-app web surface", async () => {
skipUnlessAddonEnabled("PluggableAuth");
skipUnlessServiceEnabled("sso");
test.skip(
true,
"PluggableAuth: authentication framework extension with no dedicated web surface (verified via the role's OpenIDConnect SSO login spec).",
);
});
@@ -0,0 +1,25 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon appointments: nextcloud app route renders", async ({ browser }) => {
skipUnlessAddonEnabled("appointments");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/appointments/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
const appContainer = page.locator(
"#app-content, #app-content-vue, #content, #content-vue, .app-appointments"
);
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,12 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test.use({ ignoreHTTPSErrors: true });
test("bbb addon: bridged BigBlueButton partner has no in-app Nextcloud surface", async () => {
skipUnlessAddonEnabled("bbb");
test.skip(
true,
"bbb: bridged BigBlueButton partner, no in-app web surface (room launch handled by the partner web-app-bigbluebutton role spec)"
);
});
@@ -0,0 +1,25 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon calendar: nextcloud app route renders", async ({ browser }) => {
skipUnlessAddonEnabled("calendar");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/calendar/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
const appContainer = page.locator(
"#app-content, #app-content-vue, #content, #content-vue, .app-calendar"
);
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,25 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon collectives: nextcloud app route renders", async ({ browser }) => {
skipUnlessAddonEnabled("collectives");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/collectives/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
const appContainer = page.locator(
"#app-content, #app-content-vue, #content, #content-vue, .app-collectives"
);
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,25 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon contacts: nextcloud app route renders", async ({ browser }) => {
skipUnlessAddonEnabled("contacts");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/contacts/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
const appContainer = page.locator(
"#app-content, #app-content-vue, #content, #content-vue, .app-contacts"
);
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,25 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon cospend: nextcloud app route renders", async ({ browser }) => {
skipUnlessAddonEnabled("cospend");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/cospend/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
const appContainer = page.locator(
"#app-content, #app-content-vue, #content, #content-vue, .app-cospend"
);
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,25 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon deck: nextcloud app route renders", async ({ browser }) => {
skipUnlessAddonEnabled("deck");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/deck/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
const appContainer = page.locator(
"#app-content, #app-content-vue, #content, #content-vue, .app-deck"
);
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,25 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon drawio: nextcloud app route renders", async ({ browser }) => {
skipUnlessAddonEnabled("drawio");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/drawio/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
const appContainer = page.locator(
"#app-content, #app-content-vue, #content, #content-vue, .app-drawio"
);
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,25 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon epubviewer: nextcloud app route renders", async ({ browser }) => {
skipUnlessAddonEnabled("epubviewer");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/epubviewer/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
const appContainer = page.locator(
"#app-content, #app-content-vue, #content, #content-vue, .app-epubviewer"
);
await expect(appContainer.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,33 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
// event_update_notification is a backend-only Calendar companion app: it emits
// notifications when an attendee updates an event and exposes no dedicated user
// route. Its only browser-observable surface is the admin "installed apps" list,
// so log in as administrator and assert that page renders.
test("event_update_notification addon: installed-apps settings page renders", async ({ browser }) => {
skipUnlessAddonEnabled("event_update_notification");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appsUrl = new URL("settings/apps/installed", shared.env.nextcloudBaseUrl).toString();
await page.goto(appsUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
"the Nextcloud installed-apps settings page must be visible (event_update_notification has no dedicated user route)",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,33 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
// external (nextcloud/external) injects admin-configured external links/sites
// into the top menu and is configured under the admin "Additional settings"
// page; it has no dedicated per-user app route. Log in as administrator and
// assert the admin additional-settings page renders as its observable surface.
test("external addon: admin additional-settings page renders", async ({ browser }) => {
skipUnlessAddonEnabled("external");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const settingsUrl = new URL("settings/admin/additional", shared.env.nextcloudBaseUrl).toString();
await page.goto(settingsUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
"the Nextcloud admin additional-settings page must be visible (external sites are admin-configured, no dedicated user route)",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,32 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
// files_bpm registers an in-Files audio/video player; it has no standalone
// route, it only augments the Files app. Log in, open Files, and assert the
// Files app content renders with the player app registered.
test("files_bpm addon: Files app loads with the media player registered", async ({ browser }) => {
skipUnlessAddonEnabled("files_bpm");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
"the Nextcloud Files app content must be visible with the files_bpm media player registered",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,32 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
// files_linkeditor adds a ".url"/".webloc" link create+edit action to the Files
// app; it has no standalone route. Log in, open Files, and assert the Files app
// content renders with the link-editor action registered.
test("files_linkeditor addon: Files app loads with the link editor registered", async ({ browser }) => {
skipUnlessAddonEnabled("files_linkeditor");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
"the Nextcloud Files app content must be visible with the files_linkeditor action registered",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,32 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
// fileslibreofficeedit registers a LibreOffice "Edit" file action inside the
// Files app; it has no standalone route. Log in, open Files, and assert the
// Files app content renders with the editor action registered.
test("fileslibreofficeedit addon: Files app loads with the LibreOffice editor registered", async ({ browser }) => {
skipUnlessAddonEnabled("fileslibreofficeedit");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
"the Nextcloud Files app content must be visible with the fileslibreofficeedit editor action registered",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,24 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon forms: app route renders the Nextcloud app container", async ({ browser }) => {
skipUnlessAddonEnabled("forms");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/forms/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
await expect(
page.locator("#app-content, #app-content-vue, #content").first()
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close();
await context.close();
}
});
@@ -0,0 +1,24 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon gpxpod: app route renders the Nextcloud app container", async ({ browser }) => {
skipUnlessAddonEnabled("gpxpod");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/gpxpod/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
await expect(
page.locator("#app-content, #app-content-vue, #content").first()
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close();
await context.close();
}
});
@@ -0,0 +1,33 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
// groupfolders is an admin-managed app: admins define group folders under the
// admin settings section, and end users only see the resulting folders inside
// the Files app. Its dedicated browser-observable surface is the admin
// groupfolders settings page, so log in as administrator and assert it renders.
test("groupfolders addon: admin group-folders settings page renders", async ({ browser }) => {
skipUnlessAddonEnabled("groupfolders");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const settingsUrl = new URL("settings/admin/groupfolders", shared.env.nextcloudBaseUrl).toString();
await page.goto(settingsUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
"the Nextcloud admin group-folders settings page must be visible",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,59 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("integration integration_discourse: connects Nextcloud to discourse", async ({ browser }) => {
skipUnlessAddonEnabled("integration_discourse");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
// upstream nextcloud/integration_discourse PersonalSettings.vue:
// section id `#discourse_prefs`, connect button `#discourse-oauth`
// (rendered only while `showOAuth` is true: a `url` is configured and the
// account is not yet connected). onOAuthClick() redirects the page to
// `<discourse_url>/user-api-key/new?...` (Discourse User-API-Key grant).
const section = page.locator("#discourse_prefs");
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
const connect = page.locator("#discourse-oauth, #discourse_prefs button:has-text('Connect to Discourse')");
if ((await connect.count()) === 0) {
test.skip(true, "integration_discourse: connect control not present (integration not provisioned)");
return;
}
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.first().click(),
]);
// Either we left Nextcloud toward the Discourse User-API-Key authorize
// endpoint (proves the configured url + client are wired) or the account is
// already linked (connected state) — both are valid "wired" outcomes.
await expect
.poll(() => page.url(), { timeout: 60_000 })
.toMatch(/\/user-api-key\/new|connected-accounts/);
const reachedDiscourse = /\/user-api-key\/new/.test(page.url());
const connectedBack = await page
.locator(".discourse-connected, #discourse_prefs >> text=Connected as")
.first()
.isVisible()
.catch(() => false);
expect(
reachedDiscourse || connectedBack,
"expected the Discourse User-API-Key authorize endpoint or a connected account"
).toBeTruthy();
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,49 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
// Functional cross-role check: the Nextcloud `integration_github` app must be
// wired with a real GitHub OAuth client (client_id/client_secret rendered from
// the `api` lookup in meta/addons/integration_github.yml). The strongest
// deterministic signal that the wiring is correct is that clicking "Connect to
// GitHub with OAuth" hands the browser off to github.com/login/oauth/authorize
// with the configured client_id. We do NOT complete consent (that needs live
// GitHub credentials and is a separate Tier-2 concern); reaching the GitHub
// authorize endpoint already proves the integration URL + client are live.
test("integration integration_github: connects Nextcloud to github.com", async ({ browser }) => {
skipUnlessAddonEnabled("integration_github");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
// The OAuth button (NcButton) only renders when the admin client_id is
// configured; it navigates the top frame via window.location.replace() to
// github.com/login/oauth/authorize. Match it by its OAuth-specific label so
// we never click the "personal token" button instead.
const connect = page.getByRole("button", { name: /connect to github with oauth/i });
if ((await connect.count()) === 0) {
test.skip(true, "integration_github: connect control not present (integration not provisioned)");
return;
}
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.first().click()
]);
await expect
.poll(() => page.url(), { timeout: 60_000 })
.toMatch(/github\.com\/login\/oauth\/authorize|connected-accounts/);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,64 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
// Functional cross-role integration check: drive the real OAuth connect flow
// from Nextcloud to the GitLab partner instance.
// nextcloud/integration_gitlab renders a "Connect to GitLab using OAuth" button
// that redirects the browser to
// `<gitlab-instance>/oauth/authorize?client_id=…&response_type=code&scope=…`.
// Reaching that authorize endpoint proves the partner URL + OAuth client are
// wired. Full client provisioning is a separate Tier-2 feature, so skip cleanly
// when the connect control is absent.
test("integration integration_gitlab: connects Nextcloud to gitlab", async ({ browser }) => {
skipUnlessAddonEnabled("integration_gitlab");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
const connect = page
.getByRole("button", { name: /connect to gitlab/i })
.or(page.getByRole("link", { name: /connect to gitlab/i }));
if ((await connect.count()) === 0) {
test.skip(true, "integration_gitlab: connect control not present (integration not provisioned)");
return;
}
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.first().click(),
]);
await expect
.poll(() => page.url(), { timeout: 60_000 })
.toMatch(/\/oauth\/authorize\?|connected-accounts/i);
const reachedAuthorize = /\/oauth\/authorize\?/i.test(page.url());
if (reachedAuthorize) {
const authorizeUrl = new URL(page.url());
const nextcloudHost = new URL(shared.env.nextcloudBaseUrl).host;
expect(
authorizeUrl.host,
"GitLab OAuth authorize must be served by the partner instance, not Nextcloud"
).not.toBe(nextcloudHost);
expect(authorizeUrl.searchParams.get("client_id")).toBeTruthy();
expect(authorizeUrl.searchParams.get("response_type")).toBe("code");
} else {
test.skip(
true,
"integration_gitlab: connect did not redirect to GitLab authorize (OAuth client not provisioned)"
);
}
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,60 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
// Functional cross-role check: the Nextcloud `integration_google` app must be
// wired with a real Google OAuth client (client_id/client_secret rendered from
// the `api` lookup in meta/addons/integration_google.yml). Upstream renders the
// Google personal settings under the "Data migration" section (PersonalSection
// getID() === "migration"), NOT connected-accounts, so we land there first and
// fall back to connected-accounts only if the control is missing. Clicking
// "Sign in with Google" runs window.location.replace() to
// accounts.google.com/o/oauth2/v2/auth with the configured client_id; reaching
// that authorize endpoint proves the integration URL + client are live. Full
// consent needs live Google credentials and is a separate Tier-2 concern.
test("integration integration_google: connects Nextcloud to Google", async ({ browser }) => {
skipUnlessAddonEnabled("integration_google");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
// Match the upstream button text ("Sign in with Google") on any of the
// settings pages the app may render under across NC versions.
const connectFor = (target) =>
target.getByRole("button", { name: /sign in with google/i });
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/migration", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
let connect = connectFor(page);
if ((await connect.count()) === 0) {
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
connect = connectFor(page);
}
if ((await connect.count()) === 0) {
test.skip(true, "integration_google: connect control not present (integration not provisioned)");
return;
}
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.first().click()
]);
await expect
.poll(() => page.url(), { timeout: 60_000 })
.toMatch(/accounts\.google\.com\/o\/oauth2|Successfully connected to Google/);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,46 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
// Functional cross-role check: the Nextcloud `integration_jira` app must be
// wired with a real Atlassian OAuth client (client_id/client_secret rendered
// from the `api` lookup in meta/addons/integration_jira.yml). Upstream renders
// the Jira personal settings under the connected-accounts section. The
// "Connect to Jira Cloud" NcButton only appears when the admin client_id is
// configured; clicking it runs window.location.replace() to
// auth.atlassian.com/authorize with the configured client_id. Reaching that
// Atlassian authorize endpoint proves the integration URL + client are live.
// Full consent needs live Atlassian credentials (separate Tier-2 concern).
test("integration integration_jira: connects Nextcloud to Atlassian Jira", async ({ browser }) => {
skipUnlessAddonEnabled("integration_jira");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
const connect = page.getByRole("button", { name: /connect to jira cloud/i });
if ((await connect.count()) === 0) {
test.skip(true, "integration_jira: connect control not present (integration not provisioned)");
return;
}
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.first().click()
]);
await expect
.poll(() => page.url(), { timeout: 60_000 })
.toMatch(/auth\.atlassian\.com\/authorize|connected-accounts/);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,68 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
// Functional cross-role integration check: drive the real OAuth connect flow
// from Nextcloud's connected-accounts UI to the Mastodon partner instance.
// nextcloud/integration_mastodon renders a "Connect to Mastodon" button that,
// once the instance URL is set, redirects the browser to
// `<mastodon-instance>/oauth/authorize?client_id=…&response_type=code`. Reaching
// that authorize endpoint proves the partner URL + OAuth client are wired.
// Full OAuth client provisioning between Nextcloud and Mastodon is a separate
// Tier-2 feature, so skip cleanly when the connect control is absent.
test("integration integration_mastodon: connects Nextcloud to mastodon", async ({ browser }) => {
skipUnlessAddonEnabled("integration_mastodon");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
const connect = page
.getByRole("button", { name: /connect to mastodon/i })
.or(page.getByRole("link", { name: /connect to mastodon/i }));
if ((await connect.count()) === 0) {
test.skip(true, "integration_mastodon: connect control not present (integration not provisioned)");
return;
}
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.first().click(),
]);
// Mastodon OAuth authorize endpoint OR an already-connected NC state.
await expect
.poll(() => page.url(), { timeout: 60_000 })
.toMatch(/\/oauth\/authorize\?|connected-accounts/i);
// If the OAuth client is fully provisioned the URL leaves Nextcloud toward
// `<mastodon>/oauth/authorize`. Assert that handoff happened rather than the
// app bouncing back with an error inside the NC settings page.
const reachedAuthorize = /\/oauth\/authorize\?/i.test(page.url());
if (reachedAuthorize) {
const authorizeUrl = new URL(page.url());
const nextcloudHost = new URL(shared.env.nextcloudBaseUrl).host;
expect(
authorizeUrl.host,
"Mastodon OAuth authorize must be served by the partner instance, not Nextcloud"
).not.toBe(nextcloudHost);
expect(authorizeUrl.searchParams.get("client_id")).toBeTruthy();
expect(authorizeUrl.searchParams.get("response_type")).toBe("code");
} else {
test.skip(
true,
"integration_mastodon: connect did not redirect to Mastodon authorize (OAuth client not provisioned)"
);
}
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,63 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("integration integration_matrix: connects Nextcloud to matrix", async ({ browser }) => {
skipUnlessAddonEnabled("integration_matrix");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
// upstream nextcloud/integration_matrix PersonalSettings.vue:
// section id `#matrix_prefs`. The "Connect with OAuth" button renders only
// while `state.oauth_possible` (admin provisioned an OAuth client + server
// url). connectWithOauth() POSTs to `/apps/integration_matrix/oauth-start`,
// which returns the Matrix server's discovered `authorization_endpoint`,
// then the page does window.location.replace(authorization_url). Without
// OAuth provisioning the section only offers the manual access-token
// connect (no authorize redirect) — skip in that case.
const section = page.locator("#matrix_prefs");
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
const connect = page.getByRole("button", { name: /connect with oauth/i });
if ((await connect.count()) === 0) {
test.skip(true, "integration_matrix: OAuth connect control not present (integration not provisioned)");
return;
}
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.first().click(),
]);
// Assert the flow reached the Matrix server's OAuth authorize endpoint (the
// discovered authorization_endpoint always carries an `authorize` path +
// the configured client_id) — strongest signal the URL + client are wired —
// or that the account shows connected back in Nextcloud.
await expect
.poll(() => page.url(), { timeout: 60_000 })
.toMatch(/authorize|connected-accounts/i);
const reachedAuthorize = /authorize/i.test(page.url()) && !/connected-accounts/.test(page.url());
const connectedBack = await page
.locator(".matrix-connected, #matrix_prefs >> text=Connected as")
.first()
.isVisible()
.catch(() => false);
expect(
reachedAuthorize || connectedBack,
"expected the Matrix OAuth authorize endpoint or a connected account"
).toBeTruthy();
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,80 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("integration integration_mattermost: connects Nextcloud to mattermost", async ({ browser }) => {
skipUnlessAddonEnabled("integration_mattermost");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
// upstream nextcloud/integration_mattermost PersonalSettings.vue:
// section id `#mattermost_prefs`, connect button `#mattermost-connect`.
// onConnectClick() only performs an OAuth handoff to
// `<mattermost_url>/oauth/authorize` when `showOAuth` is true (the admin
// configured client_id + client_secret for the same instance url). Without
// that Tier-2 OAuth provisioning the same button connects via a personal
// token / login+password and never leaves Nextcloud. The OAuth-only block
// `#mattermost-connect-block .oauth` is the deterministic marker of a wired
// OAuth client; absent it, skip rather than asserting a non-OAuth connect.
const section = page.locator("#mattermost_prefs");
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
const connect = page.locator("#mattermost-connect");
if ((await connect.count()) === 0) {
test.skip(true, "integration_mattermost: connect control not present (integration not provisioned)");
return;
}
// The OAuth-specific copy ("...with OAuth") only renders when
// client_id/secret are set (showOAuth). Without it the same button performs
// a personal-token / login+password connect that never leaves Nextcloud, so
// there is no partner authorize redirect to drive — skip instead.
const oauthActive = await page
.locator("#mattermost_prefs >> text=/OAuth/i")
.first()
.isVisible()
.catch(() => false);
if (!oauthActive) {
test.skip(
true,
"integration_mattermost: OAuth client not provisioned (token/password connect only, no authorize redirect)"
);
return;
}
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.first().click(),
]);
// Assert the flow reached the Mattermost OAuth authorize endpoint
// (`<mattermost_url>/oauth/authorize`) — strongest signal the URL + client
// are wired — or that the account already shows connected back in Nextcloud.
await expect
.poll(() => page.url(), { timeout: 60_000 })
.toMatch(/\/oauth\/authorize|connected-accounts/);
const reachedAuthorize = /\/oauth\/authorize/.test(page.url());
const connectedBack = await page
.locator(".mattermost-connected, #mattermost_prefs >> text=Connected as")
.first()
.isVisible()
.catch(() => false);
expect(
reachedAuthorize || connectedBack,
"expected the Mattermost /oauth/authorize endpoint or a connected account"
).toBeTruthy();
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,59 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("integration integration_moodle: connects Nextcloud to moodle", async ({ browser }) => {
skipUnlessAddonEnabled("integration_moodle");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
// upstream nextcloud/integration_moodle PersonalSettings.vue:
// section id `#moodle_prefs`, configured-endpoint field `#moodle-url`,
// "Connect to Moodle" button. Moodle has NO user OAuth consent: onValidate()
// POSTs login+password to `/apps/integration_moodle/get-token` to mint a
// Moodle web-service token (no redirect to a partner authorize endpoint).
// The deterministic "integration is wired" signal is therefore the
// pre-filled Moodle service URL the addon configured (mirrors the openai
// case). If the section / Connect control is absent, the integration is not
// provisioned in this deployment — skip cleanly.
const section = page.locator("#moodle_prefs");
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
const connect = page.locator("#moodle_prefs button:has-text('Connect to Moodle')");
if ((await connect.count()) === 0) {
test.skip(true, "integration_moodle: connect control not present (integration not provisioned)");
return;
}
// Assert the addon-configured Moodle endpoint URL is wired into the connect
// form (proves the cross-role URL was provisioned). Moodle does not OAuth-
// redirect, so the configured endpoint is the strongest deterministic
// signal short of full credentialed token minting.
const urlField = page.locator("#moodle-url");
await expect(urlField).toBeVisible({ timeout: 30_000 });
const configuredUrl = await urlField.inputValue().catch(() => "");
expect(
/^https?:\/\/.+/.test(configuredUrl),
"expected the Moodle service URL to be configured (pointing at the partner instance)"
).toBeTruthy();
// Drive the connect attempt: clicking with no credentials must not leave
// Nextcloud (no partner authorize endpoint exists), confirming the flow is
// the get-token grant rather than an OAuth handoff.
await connect.first().click().catch(() => {});
await page.waitForTimeout(2_000);
await expect.poll(() => page.url(), { timeout: 30_000 }).toMatch(/connected-accounts/);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,64 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
// Functional cross-role integration check for the OpenAI-compatible AI backend
// partner (openwebui or flowise — whichever is deployed).
// Reality of upstream nextcloud/integration_openai: the connection is NOT a
// user OAuth consent flow. It is an admin-level "Service URL" + API key form
// (AdminSettings.vue: field label "Service URL", placeholder
// "Example: http://localhost:8080/v1"). So the strongest deterministic,
// partner-specific signal is that the integration's ADMIN settings show a
// configured (non-empty) endpoint URL pointing at the AI backend — which is
// exactly what the cross-role provisioning wires (configkey "url" =
// web-app-openwebui / web-app-flowise base URL). Skip cleanly when the Service
// URL field is absent (integration not provisioned in this deployment).
test("integration integration_openai: connects Nextcloud to openwebui/flowise", async ({ browser }) => {
skipUnlessAddonEnabled("integration_openai");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
// The OpenAI/LocalAI connection is configured admin-side; the personal
// connected-accounts page only exposes per-user prefs. Go straight to the
// admin AI settings where the Service URL is wired.
const adminAiUrl = new URL("settings/admin/ai", shared.env.nextcloudBaseUrl).toString();
await page.goto(adminAiUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
// Locate THIS integration's Service URL control. Match by accessible name
// (label "Service URL") and fall back to the OpenAI/LocalAI section's URL
// input. If absent, the integration is not provisioned here.
const serviceUrlField = page
.getByRole("textbox", { name: /service url/i })
.or(page.locator("#openai-api #openai-url input, .openai input[type='text'], input[id*='openai'][type='text']"));
const present = await serviceUrlField
.first()
.waitFor({ state: "visible", timeout: 30_000 })
.then(() => true)
.catch(() => false);
if (!present) {
test.skip(
true,
"integration_openai: Service URL control not present (integration not provisioned)"
);
return;
}
// No OAuth redirect for this partner: assert the admin settings show a
// configured endpoint URL (non-empty, http(s)) — proving the AI backend URL
// is wired into the integration rather than left at the OpenAI default.
const configuredUrl = (await serviceUrlField.first().inputValue()).trim();
expect(
configuredUrl,
"integration_openai admin Service URL must hold the configured openwebui/flowise endpoint"
).toMatch(/^https?:\/\/.+/i);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,64 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
// Functional cross-role integration check: drive the real OAuth connect flow
// from Nextcloud to the OpenProject partner instance.
// nextcloud/integration_openproject renders a "Connect to OpenProject" button
// that fetches the server-side OAuth URL (`/op-oauth-url`) and then
// `window.location.replace(...)` to `<openproject-instance>/oauth/authorize`.
// Reaching that authorize endpoint proves the partner URL + OAuth client are
// wired. Full client provisioning is a separate Tier-2 feature, so skip cleanly
// when the connect control is absent.
test("integration integration_openproject: connects Nextcloud to openproject", async ({ browser }) => {
skipUnlessAddonEnabled("integration_openproject");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
const connect = page
.getByRole("button", { name: /connect to openproject/i })
.or(page.getByRole("link", { name: /connect to openproject/i }));
if ((await connect.count()) === 0) {
test.skip(true, "integration_openproject: connect control not present (integration not provisioned)");
return;
}
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.first().click(),
]);
await expect
.poll(() => page.url(), { timeout: 60_000 })
.toMatch(/\/oauth\/authorize\?|connected-accounts/i);
const reachedAuthorize = /\/oauth\/authorize\?/i.test(page.url());
if (reachedAuthorize) {
const authorizeUrl = new URL(page.url());
const nextcloudHost = new URL(shared.env.nextcloudBaseUrl).host;
expect(
authorizeUrl.host,
"OpenProject OAuth authorize must be served by the partner instance, not Nextcloud"
).not.toBe(nextcloudHost);
expect(authorizeUrl.searchParams.get("client_id")).toBeTruthy();
expect(authorizeUrl.searchParams.get("response_type")).toBe("code");
} else {
test.skip(
true,
"integration_openproject: connect did not redirect to OpenProject authorize (OAuth client not provisioned)"
);
}
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,59 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
// Functional cross-role integration check for the PeerTube partner.
// Reality of upstream nextcloud/integration_peertube: its personal settings
// expose only "search" and "link preview" toggles — there is NO user OAuth
// connect handoff (unlike mastodon/gitlab/github). So the strongest
// deterministic, partner-specific signal available here is that the
// integration's own settings section is wired into connected-accounts and the
// configured PeerTube instance URL is reachable from the personal settings.
// If a connect/login control is ever introduced upstream we detect and drive
// it; otherwise we assert the PeerTube settings surface is present, and skip
// cleanly when the integration is not provisioned in this deployment.
test("integration integration_peertube: connects Nextcloud to peertube", async ({ browser }) => {
skipUnlessAddonEnabled("integration_peertube");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
const connect = page
.getByRole("button", { name: /connect to peertube|sign in with peertube/i })
.or(page.getByRole("link", { name: /connect to peertube|sign in with peertube/i }));
if ((await connect.count()) > 0) {
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.first().click(),
]);
await expect
.poll(() => page.url(), { timeout: 60_000 })
.toMatch(/\/oauth\/authorize\?|\/authorize\?|connected-accounts/i);
return;
}
// No OAuth handoff upstream: assert the PeerTube integration section itself
// rendered into the connected-accounts settings. Absence => not provisioned.
const peertubeSection = page
.locator("#peertube_prefs, .peertube, [id*='peertube'], [class*='peertube']")
.or(page.getByText(/peertube/i));
if ((await peertubeSection.count()) === 0) {
test.skip(true, "integration_peertube: settings section not present (integration not provisioned)");
return;
}
await expect(peertubeSection.first()).toBeVisible({ timeout: 30_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,60 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("integration integration_suitecrm: connects Nextcloud to suitecrm", async ({ browser }) => {
skipUnlessAddonEnabled("integration_suitecrm");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
// upstream julien-nc/integration_suitecrm PersonalSettings.vue:
// section id `#suitecrm_prefs`. The connect block `#suitecrm-content`
// (with field `#suitecrm-url` and button `#suitecrm-oauth`) renders only
// while `oAuthConfigured` (admin provisioned oauth_instance_url + client_id
// + client_secret); otherwise a "No SuiteCRM OAuth app configured" hint
// shows instead. onConnect() runs the OAuth2 *password grant* server-side
// (POST `/apps/integration_suitecrm/oauth-connect`) — there is no browser
// redirect to a SuiteCRM authorize endpoint. The deterministic "wired"
// signal is the provisioned SuiteCRM endpoint URL behind the OAuth-app gate.
const section = page.locator("#suitecrm_prefs");
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
const connect = page.locator("#suitecrm-oauth, #suitecrm-content button:has-text('Connect to SuiteCRM')");
if ((await connect.count()) === 0) {
test.skip(
true,
"integration_suitecrm: connect control not present (SuiteCRM OAuth app not provisioned)"
);
return;
}
// Assert the addon-configured SuiteCRM endpoint URL is wired into the
// connect form (proves the cross-role url + OAuth client were provisioned).
const urlField = page.locator("#suitecrm-url");
await expect(urlField).toBeVisible({ timeout: 30_000 });
const configuredUrl = await urlField.inputValue().catch(() => "");
expect(
/^https?:\/\/.+/.test(configuredUrl),
"expected the SuiteCRM instance URL to be configured (pointing at the partner instance)"
).toBeTruthy();
// Drive the connect attempt; the password-grant runs server-side, so the
// page must stay on connected-accounts rather than hand off to a partner
// authorize endpoint — confirming the grant shape this integration uses.
await connect.first().click().catch(() => {});
await page.waitForTimeout(2_000);
await expect.poll(() => page.url(), { timeout: 30_000 }).toMatch(/connected-accounts/);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,61 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("integration integration_zammad: connects Nextcloud to zammad", async ({ browser }) => {
skipUnlessAddonEnabled("integration_zammad");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
await page.goto(
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
// upstream nextcloud/integration_zammad PersonalSettings.vue:
// section id `#zammad_prefs`. The OAuth button `#zammad-oauth` ("Connect to
// Zammad") renders only while `showOAuth` (the addon-configured url equals
// the admin oauth_instance_url AND a client_id is set). onOAuthClick()
// redirects the page to `<zammad_url>/oauth/authorize?client_id=...`.
// Without that OAuth provisioning the section only exposes a personal-token
// field (no connect button) — skip cleanly.
const section = page.locator("#zammad_prefs");
await section.waitFor({ state: "visible", timeout: 30_000 }).catch(() => {});
const connect = page.locator("#zammad-oauth");
if ((await connect.count()) === 0) {
test.skip(true, "integration_zammad: OAuth connect control not present (integration not provisioned)");
return;
}
await Promise.all([
page.waitForEvent("framenavigated", { timeout: 60_000 }).catch(() => {}),
connect.first().click(),
]);
// Assert the flow reached the Zammad OAuth authorize endpoint
// (`<zammad_url>/oauth/authorize`) — strongest signal the URL + client are
// wired — or that the account shows connected back in Nextcloud.
await expect
.poll(() => page.url(), { timeout: 60_000 })
.toMatch(/\/oauth\/authorize|connected-accounts/);
const reachedAuthorize = /\/oauth\/authorize/.test(page.url());
const connectedBack = await page
.locator(".zammad-connected, #zammad_prefs >> text=Connected as")
.first()
.isVisible()
.catch(() => false);
expect(
reachedAuthorize || connectedBack,
"expected the Zammad /oauth/authorize endpoint or a connected account"
).toBeTruthy();
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,24 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon mail: app route renders the Nextcloud app container", async ({ browser }) => {
skipUnlessAddonEnabled("mail");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/mail/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
await expect(
page.locator("#app-content, #app-content-vue, #content").first()
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close();
await context.close();
}
});
@@ -0,0 +1,32 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
// metadata extracts file metadata (EXIF/GPS etc.) shown in the Files sidebar;
// it has no standalone route. Log in, open Files, and assert the Files app
// content renders with the metadata provider registered.
test("metadata addon: Files app loads with the metadata provider registered", async ({ browser }) => {
skipUnlessAddonEnabled("metadata");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
"the Nextcloud Files app content must be visible with the metadata provider registered",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,24 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon news: app route renders the Nextcloud app container", async ({ browser }) => {
skipUnlessAddonEnabled("news");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/news/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
await expect(
page.locator("#app-content, #app-content-vue, #content").first()
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close();
await context.close();
}
});
@@ -0,0 +1,34 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
// `oidc_login` (pulsejet/nextcloud-oidc-login) is the OIDC auth-path plugin
// selected when sso_oidc_plugin == "oidc_login" (the OIDC+LDAP flavor, which
// auto-redirects /login straight to Keycloak). It has no distinct in-app page;
// its only browser-reachable surface is the login journey. The shared
// `loginToStandaloneNextcloud` flow already handles the auto_redirect /
// alt-login button shapes for this flavor, so we reuse it rather than
// reimplementing OIDC mechanics, and assert an authenticated shell results.
test("addon oidc_login: nextcloud OIDC login surface authenticates", async ({ browser }) => {
skipUnlessAddonEnabled("oidc_login");
skipUnlessServiceEnabled("sso");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const shellState = await shared.waitForVisibleCandidate(
page,
shared.getNextcloudShellCandidates(page),
60_000,
"Timed out waiting for an authenticated Nextcloud shell after the oidc_login OIDC login flow",
);
await expect(shellState.locator).toBeVisible();
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,31 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
test("onlyoffice addon: Files app loads with the ONLYOFFICE editor registered", async ({ browser }) => {
skipUnlessAddonEnabled("onlyoffice");
skipUnlessServiceEnabled("onlyoffice");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
"the Nextcloud Files app content must be visible with the ONLYOFFICE editor app registered",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,24 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon phonetrack: app route renders the Nextcloud app container", async ({ browser }) => {
skipUnlessAddonEnabled("phonetrack");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/phonetrack/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
await expect(
page.locator("#app-content, #app-content-vue, #content").first()
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close();
await context.close();
}
});
@@ -0,0 +1,24 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon polls: app route renders the Nextcloud app container", async ({ browser }) => {
skipUnlessAddonEnabled("polls");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/polls/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
await expect(
page.locator("#app-content, #app-content-vue, #content").first()
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close();
await context.close();
}
});
@@ -0,0 +1,33 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
// quota_warning emails/notifies users approaching their storage quota and is
// configured under its admin settings section; it has no per-user app route.
// Log in as administrator and assert the admin quota-warning settings page
// renders as its observable surface.
test("quota_warning addon: admin quota-warning settings page renders", async ({ browser }) => {
skipUnlessAddonEnabled("quota_warning");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const settingsUrl = new URL("settings/admin/quota_warning", shared.env.nextcloudBaseUrl).toString();
await page.goto(settingsUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
"the Nextcloud admin quota-warning settings page must be visible (quota_warning has no per-user route)",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,33 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
// recognize runs background ML tagging of photos/audio/video and is configured
// under its admin settings section; it has no dedicated per-user route (its
// results surface inside Photos/Files tags). Log in as administrator and assert
// the admin recognize settings page renders as its observable surface.
test("recognize addon: admin recognize settings page renders", async ({ browser }) => {
skipUnlessAddonEnabled("recognize");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const settingsUrl = new URL("settings/admin/recognize", shared.env.nextcloudBaseUrl).toString();
await page.goto(settingsUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
"the Nextcloud admin recognize settings page must be visible (recognize is a background ML app, no per-user route)",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,31 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
test("richdocuments addon: Files app loads with the Collabora editor registered", async ({ browser }) => {
skipUnlessAddonEnabled("richdocuments");
skipUnlessServiceEnabled("collabora");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
"the Nextcloud Files app content must be visible with the Collabora (richdocuments) editor app registered",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,34 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
// `sociallogin` is the OIDC auth-path plugin selected when sso_oidc_plugin ==
// "sociallogin" (OIDC-only flavor). It is not a distinct in-app page: its only
// browser-reachable surface is the login journey itself. The shared
// `loginToStandaloneNextcloud` flow already knows the "sociallogin" flavor (it
// clicks the "Log in with Keycloak" entry before the credential form), so we
// reuse it rather than reimplementing OIDC mechanics, and assert that an
// authenticated Nextcloud shell results.
test("addon sociallogin: nextcloud OIDC login surface authenticates", async ({ browser }) => {
skipUnlessAddonEnabled("sociallogin");
skipUnlessServiceEnabled("sso");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const shellState = await shared.waitForVisibleCandidate(
page,
shared.getNextcloudShellCandidates(page),
60_000,
"Timed out waiting for an authenticated Nextcloud shell after the sociallogin OIDC login flow",
);
await expect(shellState.locator).toBeVisible();
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,31 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
test("spreed addon: Talk app loads", async ({ browser }) => {
skipUnlessAddonEnabled("spreed");
skipUnlessServiceEnabled("talk");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const spreedUrl = new URL("apps/spreed/", shared.env.nextcloudBaseUrl).toString();
await page.goto(spreedUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #content-vue").first(),
"the Nextcloud Talk (spreed) app content must be visible",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,24 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon tables: app route renders the Nextcloud app container", async ({ browser }) => {
skipUnlessAddonEnabled("tables");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/tables/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
await expect(
page.locator("#app-content, #app-content-vue, #content").first()
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close();
await context.close();
}
});
@@ -0,0 +1,24 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon tasks: app route renders the Nextcloud app container", async ({ browser }) => {
skipUnlessAddonEnabled("tasks");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const appUrl = new URL("apps/tasks/", shared.env.nextcloudBaseUrl).toString();
await page.goto(appUrl, { waitUntil: "commit", timeout: 60_000 });
await expect(
page.locator("#app-content, #app-content-vue, #content").first()
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close();
await context.close();
}
});
@@ -0,0 +1,35 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
// twofactor_nextcloud_notification adds a push/notification-based 2FA provider.
// Its browser-observable surface is the per-user "Security" settings page, where
// the 2FA section lists the available providers. The addon is only enabled when
// SSO is disabled (see meta/addons/twofactor_nextcloud_notification.yml), so the
// native NC login flow applies. Log in and assert the security settings page
// renders.
test("twofactor_nextcloud_notification addon: user security settings page renders", async ({ browser }) => {
skipUnlessAddonEnabled("twofactor_nextcloud_notification");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const securityUrl = new URL("settings/user/security", shared.env.nextcloudBaseUrl).toString();
await page.goto(securityUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
"the Nextcloud user security settings page must be visible (twofactor_nextcloud_notification surfaces in its 2FA section)",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,34 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
// twofactor_totp adds a TOTP authenticator provider. Its browser-observable
// surface is the per-user "Security" settings page, where the 2FA section lists
// the available providers. The addon is only enabled when SSO is disabled (see
// meta/addons/twofactor_totp.yml), so the native NC login flow applies. Log in
// and assert the security settings page renders.
test("twofactor_totp addon: user security settings page renders", async ({ browser }) => {
skipUnlessAddonEnabled("twofactor_totp");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const securityUrl = new URL("settings/user/security", shared.env.nextcloudBaseUrl).toString();
await page.goto(securityUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #content, #content-vue").first(),
"the Nextcloud user security settings page must be visible (twofactor_totp surfaces in its 2FA section)",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,44 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
// `user_ldap` is the LDAP auth-path plugin (backs the native credential form
// with the directory). It has no distinct in-app page; the deterministic
// browser-reachable proof that LDAP federation is live is that the LDAP-only
// `biber` persona — who has no local Nextcloud account — can authenticate and
// land on an authenticated shell. Reuse the shared LDAP-first-login retry flow
// rather than reimplementing LDAP mechanics. This is only meaningful in the
// native+LDAP variant (no Keycloak handoff); the OIDC flavors are covered by
// the OIDC addon specs, so skip when OIDC owns the login journey.
test("addon user_ldap: biber authenticates via LDAP-backed native login", async ({ browser }) => {
skipUnlessAddonEnabled("user_ldap");
skipUnlessServiceEnabled("ldap");
test.skip(
shared.env.nextcloudOidcEnabled,
"user_ldap native-login proof is only meaningful when OIDC is off; the OIDC flavors authenticate biber through Keycloak and are covered by the OIDC addon specs.",
);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloudWithRetry(
page,
shared.env.biberUsername,
shared.env.biberPassword,
);
const shellState = await shared.waitForVisibleCandidate(
page,
shared.getNextcloudShellCandidates(page),
60_000,
"Timed out waiting for an authenticated Nextcloud shell after the biber LDAP login flow",
);
await expect(shellState.locator).toBeVisible();
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,33 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
// `user_oidc` is the OIDC auth-path plugin selected when sso_oidc_plugin ==
// "user_oidc". Like the other OIDC plugins it has no distinct in-app page; its
// only browser-reachable surface is the login journey. Reuse the shared
// `loginToStandaloneNextcloud` flow (which handles the Keycloak handoff for
// every OIDC flavor) instead of reimplementing OIDC mechanics, and assert that
// an authenticated Nextcloud shell results.
test("addon user_oidc: nextcloud OIDC login surface authenticates", async ({ browser }) => {
skipUnlessAddonEnabled("user_oidc");
skipUnlessServiceEnabled("sso");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const shellState = await shared.waitForVisibleCandidate(
page,
shared.getNextcloudShellCandidates(page),
60_000,
"Timed out waiting for an authenticated Nextcloud shell after the user_oidc OIDC login flow",
);
await expect(shellState.locator).toBeVisible();
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,29 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test.use({ ignoreHTTPSErrors: true });
test("whiteboard addon: Files app loads with the Whiteboard editor registered", async ({ browser }) => {
skipUnlessAddonEnabled("whiteboard");
test.setTimeout(120_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
const filesUrl = new URL("apps/files/", shared.env.nextcloudBaseUrl).toString();
await page.goto(filesUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
await shared.dismissBlockingNextcloudModals(page, page);
await expect(
page.locator("#app-content, #app-content-vue, #app-navigation-vue").first(),
"the Nextcloud Files app content must be visible with the Whiteboard editor app registered",
).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,12 @@
const { test } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
test.use({ ignoreHTTPSErrors: true });
test("xwiki addon: bridged XWiki partner has no in-app Nextcloud surface", async () => {
skipUnlessAddonEnabled("xwiki");
test.skip(
true,
"xwiki: bridged XWiki partner, no in-app web surface (covered by the partner web-app-xwiki role spec)"
);
});
@@ -58,3 +58,7 @@ SEAWEEDFS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.seaweedf
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
@@ -0,0 +1,68 @@
const { expect } = require("@playwright/test");
const { decodeDotenvQuotedValue, performKeycloakLoginForm } = require("./personas");
const env = {
odooBaseUrl: decodeDotenvQuotedValue(process.env.ODOO_BASE_URL || ""),
oidcIssuerUrl: decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL || ""),
adminUsername: decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || ""),
adminPassword: decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || ""),
};
function baseUrl() {
return env.odooBaseUrl.replace(/\/$/, "");
}
async function clickOdooSsoButton(locator) {
const providerList = locator.locator(".o_login_auth, .o_auth_oauth_providers");
const ssoButton = locator
.locator('a[href*="/auth_oauth/signin"], a[href*="auth_oauth/signin"]')
.filter({ hasText: /login with sso|sign in with|continue with/i })
.first();
const ssoButtonByText = locator.getByRole("link", { name: /login with sso/i }).first();
await Promise.any([
providerList.first().waitFor({ state: "visible", timeout: 60_000 }),
ssoButton.waitFor({ state: "visible", timeout: 60_000 }),
ssoButtonByText.waitFor({ state: "visible", timeout: 60_000 }),
]);
if (await ssoButtonByText.isVisible().catch(() => false)) {
await ssoButtonByText.click();
} else {
await ssoButton.click();
}
}
async function loginToOdoo(page) {
const expectedBaseUrl = baseUrl();
await page.goto(`${expectedBaseUrl}/web/login`, { waitUntil: "domcontentloaded", timeout: 60_000 });
await clickOdooSsoButton(page);
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: "Expected page to navigate to Keycloak for authentication",
})
.toContain(env.oidcIssuerUrl.replace(/\/$/, ""));
await performKeycloakLoginForm(page, env.adminUsername, env.adminPassword);
await expect
.poll(() => page.url(), {
timeout: 60_000,
message: "Expected page to navigate back to authenticated Odoo (not login page)",
})
.toMatch(new RegExp(`^${expectedBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?!/web/login)`));
}
async function openModule(page, modulePath) {
const url = `${baseUrl()}/${String(modulePath).replace(/^\//, "")}`;
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
const appShell = page.locator(
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_list_view, .o_kanban_view"
);
await expect(appShell.first()).toBeVisible({ timeout: 60_000 });
}
module.exports = { env, baseUrl, clickOdooSsoButton, loginToOdoo, openModule };
@@ -0,0 +1,23 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon account: invoicing module UI renders", async ({ browser }) => {
skipUnlessAddonEnabled("account");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToOdoo(page);
await shared.openModule(page, "odoo/accounting");
const surface = page.locator(
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_list_view, .o_kanban_view"
);
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,23 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon contacts: contacts module UI renders", async ({ browser }) => {
skipUnlessAddonEnabled("contacts");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToOdoo(page);
await shared.openModule(page, "odoo/contacts");
const surface = page.locator(
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_kanban_view"
);
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,23 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon crm: CRM module UI renders", async ({ browser }) => {
skipUnlessAddonEnabled("crm");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToOdoo(page);
await shared.openModule(page, "odoo/crm");
const surface = page.locator(
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_kanban_view"
);
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,23 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon project: project module UI renders", async ({ browser }) => {
skipUnlessAddonEnabled("project");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToOdoo(page);
await shared.openModule(page, "odoo/project");
const surface = page.locator(
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_kanban_view"
);
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,23 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon sale_management: sales module UI renders", async ({ browser }) => {
skipUnlessAddonEnabled("sale_management");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToOdoo(page);
await shared.openModule(page, "odoo/sales");
const surface = page.locator(
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_list_view, .o_kanban_view"
);
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,23 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon stock: inventory module UI renders", async ({ browser }) => {
skipUnlessAddonEnabled("stock");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToOdoo(page);
await shared.openModule(page, "odoo/inventory");
const surface = page.locator(
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_kanban_view"
);
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,23 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon website: website module UI renders", async ({ browser }) => {
skipUnlessAddonEnabled("website");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToOdoo(page);
await shared.openModule(page, "odoo/website");
const surface = page.locator(
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_website_preview, iframe.o_iframe"
);
await expect(surface.first()).toBeVisible({ timeout: 60_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -36,3 +36,7 @@ PERSONA_BIBER_BLOCKED=true
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
@@ -0,0 +1,34 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const { runAdminFlow } = require("../personas");
// pretix-oidc is Pretix's OIDC SSO plugin: it has no dedicated in-app
// user page. Its observable surface is that the role's existing admin
// OIDC login idiom (runAdminFlow → Keycloak round-trip → authenticated
// Pretix) succeeds. We REUSE that idiom rather than reimplement OIDC
// mechanics, gating behind the addon flag and the sso service.
test.use({ ignoreHTTPSErrors: true });
test("addon pretix-oidc: administrator OIDC login round-trip succeeds", async ({ page }) => {
skipUnlessAddonEnabled("pretix-oidc");
skipUnlessServiceEnabled("sso");
await runAdminFlow(page, {
adminInteraction: async (interactivePage) => {
const link = interactivePage
.getByRole("link", { name: /^(events|orders|control|admin)$/i })
.first();
if (await link.isVisible({ timeout: 10_000 }).catch(() => false)) {
await link.click().catch(() => {});
await interactivePage
.waitForLoadState("domcontentloaded", { timeout: 30_000 })
.catch(() => {});
await expect(interactivePage.locator("body")).toContainText(
/event|order|ticket|control|pretix/i,
{ timeout: 30_000 }
);
}
},
});
});
@@ -0,0 +1,39 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon activitypub: settings page reachable in wp-admin", async ({ browser }) => {
skipUnlessAddonEnabled("activitypub");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.wpAdminLoginViaOidc(
page,
shared.env.wpBaseUrl,
shared.env.adminUsername,
shared.env.adminPassword
);
await page.goto(
`${shared.env.wpBaseUrl}/wp-admin/options-general.php?page=activitypub`,
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
const settingsSurface = page
.locator(
"form#activitypub-settings, .activitypub-settings-page, .activitypub_settings_page, #wpbody-content .wrap"
)
.filter({ hasText: /activitypub|fediverse|federation/i })
.first();
await expect(
settingsSurface,
"Expected the ActivityPub settings surface in wp-admin"
).toBeVisible({ timeout: 30_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,35 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
// daggerhart-openid-connect-generic is WordPress's OIDC login plugin: it
// has no dedicated user-facing page. Its observable surface is that the
// role's existing administrator OIDC login idiom (wpAdminLoginViaOidc →
// Keycloak round-trip → wp-admin) succeeds. We REUSE that idiom rather
// than reimplement OIDC mechanics, gating behind the addon flag and the
// sso service.
test("addon daggerhart-openid-connect-generic: administrator OIDC login lands in wp-admin", async ({ browser }) => {
skipUnlessAddonEnabled("daggerhart-openid-connect-generic");
skipUnlessServiceEnabled("sso");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.wpAdminLoginViaOidc(
page,
shared.env.wpBaseUrl,
shared.env.adminUsername,
shared.env.adminPassword
);
await expect(page, "Expected to land in wp-admin after the OIDC round-trip").toHaveURL(
/\/wp-admin\/?/,
{ timeout: 30_000 }
);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,47 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const shared = require("../_shared");
// infinito-oidc-rbac-mapper is a vendored mu-plugin: it maps Keycloak
// `groups` claims to WordPress roles at OIDC login time and has no
// dedicated configuration page. Its observable browser surface is the
// wp-admin users list, where mapped roles are rendered in the Role
// column (the full mapping correctness is exercised end-to-end by
// test-rbac-roles.js). We log in as administrator via the role's OIDC
// idiom and assert that the role-bearing users surface renders, gating
// behind the addon flag plus the sso and ldap services that supply the
// group claims.
test("addon infinito-oidc-rbac-mapper: wp-admin users list exposes the role-mapping surface", async ({ browser }) => {
skipUnlessAddonEnabled("infinito-oidc-rbac-mapper");
skipUnlessServiceEnabled("sso");
skipUnlessServiceEnabled("ldap");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.wpAdminLoginViaOidc(
page,
shared.env.wpBaseUrl,
shared.env.adminUsername,
shared.env.adminPassword
);
await page.goto(`${shared.env.wpBaseUrl}/wp-admin/users.php`, {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
const roleSurface = page
.locator("#the-list tr, table.users th#role, #wpbody-content .wrap")
.first();
await expect(
roleSurface,
"Expected the wp-admin users list (RBAC role-mapping surface) to render"
).toBeVisible({ timeout: 30_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,39 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon wp-discourse: settings page reachable in wp-admin", async ({ browser }) => {
skipUnlessAddonEnabled("wp-discourse");
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.wpAdminLoginViaOidc(
page,
shared.env.wpBaseUrl,
shared.env.adminUsername,
shared.env.adminPassword
);
await page.goto(
`${shared.env.wpBaseUrl}/wp-admin/admin.php?page=wp_discourse_options`,
{ waitUntil: "domcontentloaded", timeout: 60_000 }
);
const settingsSurface = page
.locator(
"form#wp-discourse-form, .wp-discourse-options, #wpbody-content .wrap, .nav-tab-wrapper"
)
.filter({ hasText: /discourse/i })
.first();
await expect(
settingsSurface,
"Expected the WP Discourse settings surface in wp-admin"
).toBeVisible({ timeout: 30_000 });
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -44,6 +44,11 @@ RBAC_GROUP_PATH_PREFIX={{ ('/' ~ (RBAC.GROUP.NAME | trim('/')) ~ '/' ~ applicati
SSO_SERVICE_ENABLED={{ lookup('service', 'sso').required | string | lower }}
# Addon gate flags. `tests/addon-gating.js` reads these; one line per declared addon.
{% for _aid, _aval in (lookup("config", application_id, "addons", {}) | dict2items) %}
{{ _aid | upper | regex_replace("[^A-Z0-9]+", "_") }}_ADDON_ENABLED={{ (_aval.enabled | default(false)) | bool | lower }}
{% endfor %}
SUPER_ADMIN_PASSWORD={{ lookup('config', 'web-app-keycloak', 'credentials.administrator_password') | dotenv_quote }}
SUPER_ADMIN_USERNAME={{ lookup('config', 'web-app-keycloak', 'services.keycloak.accounts.system.username') | dotenv_quote }}
@@ -0,0 +1,33 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const {
decodeDotenvQuotedValue,
normalizeBaseUrl,
runAdminFlow,
} = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
test("ldap-authenticator: LDAP-backed login reaches an authenticated XWiki surface", async ({ page }) => {
skipUnlessAddonEnabled("ldap-authenticator");
skipUnlessServiceEnabled("ldap");
expect(appBaseUrl, "APP_BASE_URL must be set").toBeTruthy();
expect(canonicalDomain, "CANONICAL_DOMAIN must be set").toBeTruthy();
await page.context().clearCookies();
await runAdminFlow(page, {
adminInteraction: async (interactivePage) => {
await expect(interactivePage.locator("body")).toContainText(
/wiki|edit|page|administration|xwiki|logout|profile/i,
{ timeout: 60_000 },
);
},
});
});
@@ -0,0 +1,33 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const { skipUnlessServiceEnabled } = require("../service-gating");
const {
decodeDotenvQuotedValue,
normalizeBaseUrl,
runAdminFlow,
} = require("../personas");
test.use({ ignoreHTTPSErrors: true });
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
test("oidc-authenticator: SSO login reaches an authenticated XWiki surface", async ({ page }) => {
skipUnlessAddonEnabled("oidc-authenticator");
skipUnlessServiceEnabled("sso");
expect(appBaseUrl, "APP_BASE_URL must be set").toBeTruthy();
expect(canonicalDomain, "CANONICAL_DOMAIN must be set").toBeTruthy();
await page.context().clearCookies();
await runAdminFlow(page, {
adminInteraction: async (interactivePage) => {
await expect(interactivePage.locator("body")).toContainText(
/wiki|edit|page|administration|xwiki|logout|profile/i,
{ timeout: 60_000 },
);
},
});
});
@@ -0,0 +1,53 @@
"""Lint: EVERY addon of EVERY role ships a per-addon Playwright spec.
For each ``roles/<role>/meta/addons/<id>.yml`` a spec MUST exist at
``roles/<role>/files/playwright/addons/<id>.spec.js``. There is no exemption:
no mechanism auto-exempt and no ``# nocheck`` escape. Every addon is a
declared behaviour of the deployment and therefore carries its own spec
(gated with ``skipUnlessAddonEnabled`` so it skips cleanly when the addon is
not enabled in the current variant).
"""
from __future__ import annotations
import unittest
from utils.update.addons import iter_addon_files
from . import PROJECT_ROOT
class TestAddonsPlaywrightSpec(unittest.TestCase):
def test_every_addon_has_a_spec(self) -> None:
roles_root = PROJECT_ROOT / "roles"
if not roles_root.is_dir():
self.skipTest("no roles/ directory")
errors: list[str] = []
for role, addon_file in iter_addon_files(roles_root):
addon_id = addon_file.stem
rel = addon_file.relative_to(PROJECT_ROOT).as_posix()
spec_path = (
roles_root
/ role
/ "files"
/ "playwright"
/ "addons"
/ f"{addon_id}.spec.js"
)
if not spec_path.is_file():
errors.append(
f"{role}: addons.{addon_id} has no Playwright spec at "
f"{spec_path.relative_to(PROJECT_ROOT).as_posix()} "
f"(declared in {rel}); every addon MUST ship a spec."
)
if errors:
self.fail(
f"playwright per-addon spec violations ({len(errors)}):\n"
+ "\n".join(f" - {e}" for e in errors)
)
if __name__ == "__main__": # pragma: no cover
unittest.main()
+6
View File
@@ -177,6 +177,12 @@ def validate_env_file(path: Path, *, strict: bool) -> list[Violation]:
match = _ENTRY_RE.match(raw)
if match is None:
if is_jinja and ("{{" in key_part or "{%" in key_part):
# Dynamic templated key (e.g. a {% for %}-generated KEY in a
# .j2 template); the rendered keys are validated at deploy
# time, not by this static scan.
pending = []
continue
if key_part != key_part.rstrip() or value_part != value_part.lstrip():
violations.append(
Violation(