mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
The shared Playwright config passes --unsafely-treat-insecure-origin-as-secure for every .onion origin in the environment, but Playwright's default chrome-headless-shell ignores that switch. On the tor axis the odoo origin therefore stayed an insecure context: navigator.clipboard was undefined and Odoo 20's onboarding tour crashed with "Cannot read properties of undefined (reading 'writeText')" and "Cannot destructure property 'TourInteractive'", opening error dialogs that the addon specs read as a missing module (odoo#2, swarm tor, main run 38038541631). When ODOO_BASE_URL is an onion URL the odoo specs now use channel "chromium", the full Chromium build in the Playwright image, and block service workers; clearnet runs keep every option unchanged. The worker is blocked because a secure context lets Odoo register /web/service-worker.js, whose fetch handler answers a failed navigation with a cached page or /odoo/offline: gotoOnion would get a 200 instead of the transport error it retries on. The switch stays in the role: a use.channel in the shared config would also reach the Firefox launch of onion-test.js, which rejects it with "Unsupported firefox channel", and would move every other role's onion run to a browser none of them was tested on. Verified with Playwright 1.63 in the runner's staging layout and the real odoo specs: an onion ODOO_BASE_URL resolves channel "chromium" and serviceWorkers "block", also for the contexts _shared.js creates itself, and launches chromium-<rev>/chrome-linux64/chrome; a clearnet one, and HEAD's specs on onion, keep the headless shell and "allow". All spec files stay on one worker, which the suite's one-worker test requires. Against Odoo 20's service worker with a dropped connection, full Chromium returned the cached /odoo page with status 200, while with the worker blocked page.goto threw net::ERR_CONNECTION_FAILED as the headless shell does. In the v1.64.0 image full Chromium made no :443 attempt to an onion host and the onion origin reported isSecureContext with crypto.subtle, which the headless shell did not. A local deploy of web-app-odoo variant 2 over Tor ran the odoo suite on /ms-playwright/chromium-1248/chrome-linux64/chrome and passed all seven addon specs, the one-worker test and the guest flow without a retry; in the variant 0 round the suites of the other roles kept chrome-headless-shell. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
166 lines
6.3 KiB
JavaScript
166 lines
6.3 KiB
JavaScript
const { expect } = require("@playwright/test");
|
|
const { resolveTimeout } = require("./timeouts");
|
|
const { decodeDotenvQuotedValue, performKeycloakLoginForm, gotoOnion } = require("./personas");
|
|
const { isServiceEnabled } = require("./service-gating");
|
|
|
|
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 || ""),
|
|
};
|
|
|
|
const onionOptions = /\bhttps?:\/\/[^/,\s"']+\.onion\b/i.test(env.odooBaseUrl)
|
|
? { channel: "chromium", serviceWorkers: "block" }
|
|
: {};
|
|
|
|
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: resolveTimeout(60_000) }),
|
|
ssoButton.waitFor({ state: "visible", timeout: resolveTimeout(60_000) }),
|
|
ssoButtonByText.waitFor({ state: "visible", timeout: resolveTimeout(60_000) }),
|
|
]);
|
|
|
|
if (await ssoButtonByText.isVisible().catch(() => false)) {
|
|
await ssoButtonByText.click({ timeout: resolveTimeout(30_000) });
|
|
} else {
|
|
await ssoButton.click({ timeout: resolveTimeout(30_000) });
|
|
}
|
|
}
|
|
|
|
async function nativeLoginToOdoo(page, expectedBaseUrl, webClient) {
|
|
await gotoOnion(page, `${expectedBaseUrl}/web/login`, { waitUntil: "domcontentloaded", timeout: resolveTimeout(60_000) });
|
|
await page.locator('input[name="login"]').fill(env.adminUsername);
|
|
await page.locator('input[name="password"]').fill(env.adminPassword);
|
|
await page
|
|
.locator('form:has(input[name="login"])')
|
|
.getByRole("button", { name: /log\s*in/i })
|
|
.first()
|
|
.click({ timeout: resolveTimeout(30_000) });
|
|
await gotoOnion(page, `${expectedBaseUrl}/odoo`, { waitUntil: "domcontentloaded", timeout: resolveTimeout(60_000) });
|
|
await expect(
|
|
webClient,
|
|
"native (non-SSO) administrator login must land on the authenticated Odoo web client"
|
|
).toBeVisible({ timeout: resolveTimeout(60_000) });
|
|
}
|
|
|
|
async function loginToOdoo(page) {
|
|
const expectedBaseUrl = baseUrl();
|
|
const webClient = page.locator(".o_web_client, .o_main_navbar, .o_action_manager").first();
|
|
|
|
if (!isServiceEnabled("sso")) {
|
|
await nativeLoginToOdoo(page, expectedBaseUrl, webClient);
|
|
return;
|
|
}
|
|
|
|
const notLoginUrl = new RegExp(
|
|
`^${expectedBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?!/web/login)`
|
|
);
|
|
const issuer = env.oidcIssuerUrl.replace(/\/$/, "");
|
|
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
await gotoOnion(page, `${expectedBaseUrl}/web/login`, { waitUntil: "domcontentloaded", timeout: resolveTimeout(60_000) });
|
|
await clickOdooSsoButton(page);
|
|
|
|
await Promise.race([
|
|
page.waitForURL((u) => u.toString().startsWith(issuer), { timeout: resolveTimeout(60_000) }).catch(() => {}),
|
|
webClient.waitFor({ state: "visible", timeout: resolveTimeout(120_000) }).catch(() => {}),
|
|
]);
|
|
if (page.url().startsWith(issuer)) {
|
|
await performKeycloakLoginForm(page, env.adminUsername, env.adminPassword);
|
|
await expect
|
|
.poll(() => page.url(), { timeout: resolveTimeout(60_000), message: "Expected page to navigate back from Keycloak to Odoo" })
|
|
.toMatch(notLoginUrl);
|
|
}
|
|
|
|
await gotoOnion(page, `${expectedBaseUrl}/odoo`, { waitUntil: "domcontentloaded", timeout: resolveTimeout(60_000) });
|
|
const rendered = await webClient
|
|
.waitFor({ state: "visible", timeout: resolveTimeout(120_000) })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
if (rendered) {
|
|
return;
|
|
}
|
|
}
|
|
throw new Error(
|
|
"Odoo SSO login did not establish an authenticated session: the web client did not render after the Keycloak OAuth round-trip"
|
|
);
|
|
}
|
|
|
|
let cachedStorageState = null;
|
|
|
|
/**
|
|
* Open a context that is already signed in to Odoo.
|
|
*
|
|
* The first caller performs the full SSO round-trip and caches the storage
|
|
* state; later callers restore it. A restored state that no longer renders the
|
|
* web client is discarded and the full login runs instead.
|
|
*
|
|
* @param {import('@playwright/test').Browser} browser
|
|
* @returns {Promise<{context: import('@playwright/test').BrowserContext, page: import('@playwright/test').Page}>}
|
|
*/
|
|
async function authenticatedContext(browser) {
|
|
const webClient = ".o_web_client, .o_main_navbar, .o_action_manager";
|
|
if (cachedStorageState) {
|
|
const context = await browser.newContext({
|
|
ignoreHTTPSErrors: true,
|
|
storageState: cachedStorageState,
|
|
});
|
|
const page = await context.newPage();
|
|
const restored = await gotoOnion(page, `${baseUrl()}/odoo`, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: resolveTimeout(60_000),
|
|
})
|
|
.then(() =>
|
|
page
|
|
.locator(webClient)
|
|
.first()
|
|
.waitFor({ state: "visible", timeout: resolveTimeout(60_000) })
|
|
)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
if (restored) {
|
|
return { context, page };
|
|
}
|
|
cachedStorageState = null;
|
|
await context.close();
|
|
}
|
|
|
|
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
const page = await context.newPage();
|
|
await loginToOdoo(page);
|
|
cachedStorageState = await context.storageState();
|
|
return { context, page };
|
|
}
|
|
|
|
async function openModule(page, modulePath) {
|
|
const url = `${baseUrl()}/${String(modulePath).replace(/^\//, "")}`;
|
|
await gotoOnion(page, url, { waitUntil: "domcontentloaded", timeout: resolveTimeout(90_000) });
|
|
const appShell = page.locator(
|
|
".o_web_client, .o_action_manager, .o_main_navbar, .o_content, .o_list_view, .o_kanban_view"
|
|
);
|
|
// Odoo bootstraps a heavy web-client asset bundle per module; under serial test
|
|
// load the first render can take well over a minute, so allow generous time.
|
|
await expect(appShell.first()).toBeVisible({ timeout: resolveTimeout(120_000) });
|
|
}
|
|
|
|
module.exports = {
|
|
env,
|
|
onionOptions,
|
|
baseUrl,
|
|
clickOdooSsoButton,
|
|
loginToOdoo,
|
|
authenticatedContext,
|
|
openModule,
|
|
};
|