Files
computer-playbook/roles/web-app-nextcloud/files/playwright/addons/integration_mastodon.spec.js
kevinveenbirkenbachandClaude Opus 5 de1efdf0d5 fix(nextcloud): let the Mastodon connect click survive the first-run wizard
The addon spec reached the per-user connected-accounts page, resolved the
"Connect to Mastodon" button and found it visible, enabled and stable, then
spent its whole 30s click timeout being refused:

    <div id="firstrunwizard" class="first-run-wizard modal-mask modal-mask--opaque">
    subtree intercepts pointer events

The single best-effort dismissal before the click is bounded and returns as
soon as two checks find no modal, so a wizard that appears late outlives it.
That the wizard, not the bridge, is the problem shows in the same job: the
spec passed eleven minutes later against the same stack, and passes in both
swarm rows.

The retry loop for exactly this interception already exists, written for the
user-menu click and named after it. Widen the name, take any locator, and use
it here. Nothing outside the shared module referenced the old name, so it is
renamed rather than aliased.

This does not turn the job green: it also fails the onlyoffice addon, which
is a separate cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 11:59:06 +02:00

151 lines
6.9 KiB
JavaScript

const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
const { gotoOnion } = require("../personas");
// Functional cross-role coupling check for nextcloud/integration_mastodon.
//
// This is a bridge addon: integration_mastodon links Nextcloud to a deployed
// Mastodon partner instance. tasks/addons/integration_mastodon.yml +
// meta/addons/integration_mastodon.yml provision the app key oauth_instance_url
// (Settings/Admin.php) to the partner microblog host — the admin-level default the
// per-user OAuth connect flow falls back to. The real coupling is therefore twofold:
// 1. the admin "Mastodon integration" panel renders the provisioned partner
// instance URL (a host distinct from Nextcloud), and
// 2. the per-user "Connect to Mastodon" handoff actually drives to the PARTNER's
// /oauth/authorize endpoint carrying the provisioned client_id &
// response_type=code (i.e. the bridge reaches the partner, not Nextcloud).
// When the addon is enabled, both MUST hold; a missing panel, missing connect
// control, or a redirect that never leaves Nextcloud means the coupling failed to
// provision and the test FAILS (it does not skip).
test("integration integration_mastodon: connects Nextcloud to the partner Mastodon via provisioned OAuth", async ({ browser }) => {
skipUnlessAddonEnabled("integration_mastodon");
test.setTimeout(resolveTimeout(120_000));
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.loginToStandaloneNextcloud(page);
// Coupling signal 1: the admin Mastodon settings panel must render the
// configured default instance address (oauth_instance_url) provisioned by the
// addon hook. The field is an NcTextField bound to oauth_instance_url inside the
// app's admin section (#mastodon_prefs / #mastodon-content) under the
// "connected-accounts" admin settings page.
await gotoOnion(page,
new URL("settings/admin/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: resolveTimeout(60_000) }
);
await shared.dismissBlockingNextcloudModals(page, page);
const mastodonPanel = page
.locator("#mastodon_prefs, #mastodon-content")
.first();
await expect(
mastodonPanel,
"the Mastodon integration admin panel must render when integration_mastodon is enabled — its absence means the app failed to install/configure and the coupling never landed"
).toBeVisible({ timeout: resolveTimeout(60_000) });
// The instance-address NcTextField has no stable id; locate the text input
// inside the Mastodon panel whose value is an absolute URL. NcTextField may
// wrap the native <input>, so search the panel for a URL-shaped value.
const urlInputs = mastodonPanel.locator("input[type='text'], input[type='url'], input:not([type])");
await expect(
urlInputs.first(),
"the Mastodon admin panel must expose the 'Default Mastodon instance address' field"
).toBeVisible({ timeout: resolveTimeout(30_000) });
const inputCount = await urlInputs.count();
let configuredInstanceUrl = null;
for (let i = 0; i < inputCount; i += 1) {
const value = (await urlInputs.nth(i).inputValue().catch(() => "")) || "";
if (/^https?:\/\//i.test(value.trim())) {
configuredInstanceUrl = value.trim();
break;
}
}
expect(
configuredInstanceUrl,
"the Mastodon admin instance-address field must be populated with the partner URL (addon hook sets oauth_instance_url)"
).toBeTruthy();
const partnerBaseUrl = (shared.env.mastodonBaseUrl || "").trim();
expect(
/^https?:\/\//i.test(partnerBaseUrl),
"MASTODON_BASE_URL must resolve to the deployed partner Mastodon base URL when integration_mastodon is enabled (it is the same url.base the addon hook writes into oauth_instance_url)"
).toBeTruthy();
const nextcloudHost = new URL(shared.env.nextcloudBaseUrl).host;
const partnerHost = new URL(partnerBaseUrl).host;
const instanceHost = new URL(configuredInstanceUrl).host;
expect(
instanceHost,
"the configured Mastodon instance must be the partner instance, not Nextcloud itself"
).not.toBe(nextcloudHost);
expect(
instanceHost,
"the Mastodon oauth_instance_url must point at the deployed microblog partner host"
).toBe(partnerHost);
// Coupling signal 2: the per-user "Connect to Mastodon" handoff must drive to
// the PARTNER instance's /oauth/authorize endpoint, carrying the provisioned
// OAuth client_id & response_type=code. This proves the bridge actually reaches
// the partner (not Nextcloud) — the real federation/login round-trip.
await gotoOnion(page,
new URL("settings/user/connected-accounts", shared.env.nextcloudBaseUrl).toString(),
{ waitUntil: "domcontentloaded", timeout: resolveTimeout(60_000) }
);
await shared.dismissBlockingNextcloudModals(page, page);
const connect = page
.getByRole("button", { name: /connect to mastodon/i })
.or(page.getByRole("link", { name: /connect to mastodon/i }))
.first();
await expect(
connect,
"the 'Connect to Mastodon' control must render once oauth_instance_url is provisioned — its absence means the per-user OAuth bridge never wired up"
).toBeVisible({ timeout: resolveTimeout(60_000) });
const authorizeRequests = [];
context.on("request", (request) => {
if (/\/oauth\/authorize\?/i.test(request.url())) authorizeRequests.push(request.url());
});
const popupPromise = page.waitForEvent("popup", { timeout: resolveTimeout(15_000) }).catch(() => null);
await Promise.all([
page.waitForEvent("framenavigated", { timeout: resolveTimeout(60_000) }).catch(() => {}),
shared.clickWithModalRetry(page, page, connect),
]);
const popup = await popupPromise;
const currentUrl = () => authorizeRequests[0] ?? (popup ? popup.url() : page.url());
await expect
.poll(currentUrl, { timeout: resolveTimeout(60_000) })
.toMatch(/\/oauth\/authorize\?/i);
const authorizeUrl = new URL(currentUrl());
expect(
authorizeUrl.host,
"Mastodon OAuth authorize must be served by the partner instance, not Nextcloud"
).not.toBe(nextcloudHost);
expect(
authorizeUrl.host,
"Mastodon OAuth authorize host must match the configured partner instance"
).toBe(instanceHost);
expect(
authorizeUrl.searchParams.get("client_id"),
"the authorize redirect must carry the provisioned Mastodon OAuth client_id"
).toBeTruthy();
expect(authorizeUrl.searchParams.get("response_type")).toBe("code");
if (popup) await popup.close().catch(() => {});
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});