const { test, expect } = require("@playwright/test"); const { resolveTimeout, isOnionTarget } = require("./timeouts"); const { decodeDotenvQuotedValue } = require("./personas"); // All Talk-admin assertion logic lives in this module so the gate flag, // the env-presence guard, and the spreed-specific scraping helpers stay // co-located with the single test that consumes them. Disabled when // NEXTCLOUD_TALK_SETTINGS_CHECK_ENABLED is not "true" in the rendered // `.env` (i.e. when the role's `services.talk.enabled` resolves false). const nextcloudTalkSettingsCheckEnabled = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_SETTINGS_CHECK_ENABLED) === "true"; const nextcloudTalkSettingsUrl = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_SETTINGS_URL); const nextcloudTalkExpectedSignalingUrl = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_EXPECTED_SIGNALING_URL); const nextcloudTalkExpectedStunServer = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_EXPECTED_STUN_SERVER); const nextcloudTalkExpectedTurnServer = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_EXPECTED_TURN_SERVER); const nextcloudTalkUnexpectedStunServer = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_UNEXPECTED_STUN_SERVER); const nextcloudTalkUnexpectedTurnServer = decodeDotenvQuotedValue(process.env.NEXTCLOUD_TALK_UNEXPECTED_TURN_SERVER); // Error strings emitted by the spreed admin UI when a signaling / STUN / TURN // test button reports a failure. Kept in one place so the list stays easy to // audit against the spreed source. const talkTestServerErrorPatterns = [ /Error:\s*Cannot connect to server/i, /Error:\s*No working ICE candidates returned by the TURN server/i, /Error:\s*Server seems to be a Signaling server/i, /Testing server seems to be broken/i ]; // Positive success marker emitted by the spreed admin UI. Both the HPB // signaling-server row (SignalingServer.vue) and the recording-backend row // (RecordingServer.vue) render their reachability result inline via // `{{ connectionState }}`, where // connectionState becomes `"OK: Running version: "` once the // auto-mounted check (or our explicit Test-button click) succeeds. // // We require at least ONE successful row, not two: is // `v-if="server && checked"` AND its parent only renders // when `hasSignalingServers === true`, so the recording row can legitimately // be absent on a stack that does not yet have an HPB signaling row stored // (typical first-deploy ordering). Asserting two OK markers in that // situation would false-fail on a healthy install. const talkTestServerRequiredOkPattern = /OK:\s*Running version:\s*\S+/i; // Talk admin settings are partly rendered as plain text and partly as // `` fields. `innerText()` alone would miss the input // values, so collect both text and form values before asserting presence or // absence of configured / legacy endpoints. async function collectNextcloudSettingsText(target) { const bodyText = await target.locator("body").innerText().catch(() => ""); const formValues = await target.locator("input, textarea, select").evaluateAll((elements) => { return elements.flatMap((element) => { const values = []; const value = typeof element.value === "string" ? element.value.trim() : ""; const text = typeof element.textContent === "string" ? element.textContent.trim() : ""; if (value) { values.push(value); } if (text) { values.push(text); } return values; }); }).catch(() => []); return [bodyText, ...formValues].filter(Boolean).join("\n"); } async function expectNextcloudSettingValue(page, expectedValue, label) { await expect .poll( async () => collectNextcloudSettingsText(page), { timeout: resolveTimeout(30_000), message: `Expected ${label} to be visible in the Nextcloud Talk admin settings: ${expectedValue}` } ) .toContain(expectedValue); } async function expectNextcloudSettingAbsent(page, unexpectedValue, label) { await expect .poll( async () => collectNextcloudSettingsText(page), { timeout: resolveTimeout(30_000), message: `Expected ${label} to stay absent in the Nextcloud Talk admin settings: ${unexpectedValue}` } ) .not.toContain(unexpectedValue); } async function clickAllTalkTestServerButtonsAndVerify(page) { // Spreed renders the test result inside `` // and the explicit re-test button (icon-only NcButton with // aria-label "Test this server") is `v-if="server && checked"` — i.e. it // only appears AFTER the auto-mount `checkServerVersion()` call returns, // and at that point the connectionState text is already in the span. // Wait for the result span to exist before measuring buttons so we do not // race the conditional render and silently click zero buttons. const connectionSpans = page.locator("span.test-connection"); await connectionSpans .first() .waitFor({ state: "attached", timeout: resolveTimeout(60_000) }) .catch(() => { // Continue — the assertion below will surface the real diagnostic. }); // Re-trigger the check by clicking every Test button we can find. Spreed's // accessible name (English) is "Test this server" for both HPB signaling // and recording rows; older releases used "Test server", so match both. const testButtons = page.getByRole("button", { name: /^test( this)? server$/i }); const total = await testButtons.count(); for (let i = 0; i < total; i += 1) { const button = testButtons.nth(i); if (!(await button.isVisible().catch(() => false))) { continue; } await button.scrollIntoViewIfNeeded().catch(() => {}); await button.click({ timeout: resolveTimeout(5_000) }).catch(() => {}); } // Read the test-connection span texts directly instead of `document.body // .innerText`. innerText can omit content inside collapsed admin panels // and the explicit locator surfaces the exact element where spreed writes // the result, so failure messages name the actual rendered string. const readConnectionTexts = async () => connectionSpans.allInnerTexts().catch(() => []); await expect .poll(readConnectionTexts, { timeout: resolveTimeout(60_000), message: "Expected at least one Talk admin row (.test-connection span) to render " + "'OK: Running version: ' after the auto-check / Test-button click. " + "Empty array means the row was never rendered (HPB signaling server " + "config missing); a 'Status: Checking connection' value means the " + "auto-check is still pending; an 'Error:' value means HPB unreachable." }) .toEqual(expect.arrayContaining([expect.stringMatching(talkTestServerRequiredOkPattern)])); // Iterate connection-span texts so the failure message points at the // exact row that broke instead of dumping the whole admin chrome. const texts = await readConnectionTexts(); for (const text of texts) { for (const pattern of talkTestServerErrorPatterns) { expect( text, `Talk admin row reported a connection error matching ${pattern}: ${text}` ).not.toMatch(pattern); } } } exports.register = function (shared) { test("nextcloud talk admin settings", async ({ browser }) => { test.skip(!nextcloudTalkSettingsCheckEnabled, "Talk admin checks are disabled in the current Playwright env"); test.skip(isOnionTarget(), "Nextcloud Talk realtime (HPB signaling + STUN/TURN) is WebRTC; not reachable over Tor"); expect(nextcloudTalkSettingsUrl, "NEXTCLOUD_TALK_SETTINGS_URL must be set when Talk admin checks are enabled").toBeTruthy(); expect(nextcloudTalkExpectedSignalingUrl, "NEXTCLOUD_TALK_EXPECTED_SIGNALING_URL must be set when Talk admin checks are enabled").toBeTruthy(); expect(nextcloudTalkExpectedStunServer, "NEXTCLOUD_TALK_EXPECTED_STUN_SERVER must be set when Talk admin checks are enabled").toBeTruthy(); expect(nextcloudTalkExpectedTurnServer, "NEXTCLOUD_TALK_EXPECTED_TURN_SERVER must be set when Talk admin checks are enabled").toBeTruthy(); const browserContext = await browser.newContext({ ignoreHTTPSErrors: true }); try { const expectedTalkSettingsUrl = new URL(nextcloudTalkSettingsUrl); const adminPage = await browserContext.newPage(); try { await shared.loginToStandaloneNextcloud(adminPage); await adminPage.goto(nextcloudTalkSettingsUrl, { waitUntil: "domcontentloaded", timeout: resolveTimeout(60_000) }); await expect .poll( async () => { const currentUrl = new URL(adminPage.url()); return { pathname: currentUrl.pathname, search: currentUrl.search }; }, { timeout: resolveTimeout(30_000), message: `Expected Nextcloud admin Talk settings page to load: ${nextcloudTalkSettingsUrl}` } ) .toMatchObject({ pathname: expectedTalkSettingsUrl.pathname, search: expectedTalkSettingsUrl.search }); await shared.dismissBlockingNextcloudModals(adminPage, adminPage); await expectNextcloudSettingValue(adminPage, nextcloudTalkExpectedSignalingUrl, "Talk signaling URL"); await expectNextcloudSettingValue(adminPage, nextcloudTalkExpectedStunServer, "Talk STUN server"); await expectNextcloudSettingValue(adminPage, nextcloudTalkExpectedTurnServer, "Talk TURN server"); if (nextcloudTalkUnexpectedStunServer) { await expectNextcloudSettingAbsent(adminPage, nextcloudTalkUnexpectedStunServer, "legacy Talk STUN server"); } if (nextcloudTalkUnexpectedTurnServer) { await expectNextcloudSettingAbsent(adminPage, nextcloudTalkUnexpectedTurnServer, "legacy Talk TURN server"); } await clickAllTalkTestServerButtonsAndVerify(adminPage); } finally { await adminPage.close().catch(() => {}); } } finally { await browserContext.close().catch(() => {}); } }); };