mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
Twenty-five specs called the Playwright APIs with default or hardcoded timeouts. Routed over Tor those budgets are too small, so a spec fails on transport latency rather than on the behaviour it asserts. Route the waits through resolveTimeout and reach pages via gotoOnion, the helpers the tor branch introduced for exactly this. The remaining changes in the diff are import wrapping around those calls. The repository lint enforces this: a raw numeric timeout in a role spec is rejected with a pointer to resolveTimeout, which is how a hardcoded 500ms wait added during this session was caught. Merge work rather than authored here; not deployed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
218 lines
8.2 KiB
JavaScript
218 lines
8.2 KiB
JavaScript
const { test, expect } = require("@playwright/test");
|
|
const { resolveTimeout } = require("../timeouts");
|
|
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
|
const {
|
|
normalizeBaseUrl,
|
|
decodeDotenvQuotedValue,
|
|
performKeycloakLoginForm,
|
|
gotoOnion,
|
|
} = 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);
|
|
const expectedEndpoint = decodeDotenvQuotedValue(process.env.DISCOURSE_LITELLM_CHAT_ENDPOINT);
|
|
const expectedModel = decodeDotenvQuotedValue(process.env.DISCOURSE_LITELLM_MODEL);
|
|
const expectedDisplayName = decodeDotenvQuotedValue(process.env.DISCOURSE_AI_LLM_DISPLAY_NAME);
|
|
|
|
async function signInViaOidc(page) {
|
|
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
|
|
|
|
await gotoOnion(page, `${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 gotoOnion(page, `${discourseBaseUrl}/auth/oidc`).catch(() => {});
|
|
}
|
|
|
|
await expect
|
|
.poll(() => page.url(), {
|
|
timeout: resolveTimeout(60_000),
|
|
message: `expected redirect to Keycloak OIDC auth (${expectedOidcAuthUrl})`,
|
|
})
|
|
.toContain(expectedOidcAuthUrl);
|
|
|
|
await performKeycloakLoginForm(page, adminUsername, adminPassword);
|
|
|
|
await expect
|
|
.poll(() => page.url(), {
|
|
timeout: resolveTimeout(60_000),
|
|
message: `expected redirect back to discourse at ${discourseBaseUrl}`,
|
|
})
|
|
.toContain(discourseBaseUrl);
|
|
}
|
|
|
|
function settingValue(settings, name) {
|
|
const found = settings.find((s) => s && s.setting === name);
|
|
expect(found, `${name} site setting must exist (discourse-ai plugin loaded)`).toBeTruthy();
|
|
return String(found.value);
|
|
}
|
|
|
|
test("discourse-ai: the Discourse AI model routes prompts through the in-cluster gateway and answers one", async ({ page }) => {
|
|
skipUnlessAddonEnabled("discourse-ai");
|
|
test.setTimeout(resolveTimeout(180_000));
|
|
|
|
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();
|
|
expect(expectedEndpoint, "DISCOURSE_LITELLM_CHAT_ENDPOINT must be set").toBeTruthy();
|
|
expect(expectedModel, "DISCOURSE_LITELLM_MODEL must be set").toBeTruthy();
|
|
expect(expectedDisplayName, "DISCOURSE_AI_LLM_DISPLAY_NAME must be set").toBeTruthy();
|
|
|
|
const discourseHost = new URL(discourseBaseUrl).host;
|
|
|
|
try {
|
|
await page.context().clearCookies();
|
|
await signInViaOidc(page);
|
|
|
|
await expect(page.locator("body")).toContainText(
|
|
/topic|category|welcome|latest|discourse/i,
|
|
{ timeout: resolveTimeout(60_000) },
|
|
);
|
|
|
|
const llmIndex = await page.evaluate(async (base) => {
|
|
const res = await fetch(`${base}/admin/plugins/discourse-ai/ai-llms.json`, {
|
|
headers: { Accept: "application/json" },
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) return { ok: false, status: res.status };
|
|
const body = await res.json();
|
|
return { ok: true, llms: (body && body.ai_llms) || [] };
|
|
}, discourseBaseUrl);
|
|
|
|
expect(
|
|
llmIndex.ok,
|
|
`expected /admin/plugins/discourse-ai/ai-llms.json to be reachable as admin (status ${llmIndex.status}); a 404 means the bundled discourse-ai plugin never loaded`,
|
|
).toBe(true);
|
|
|
|
const llm = llmIndex.llms.find((entry) => entry && entry.display_name === expectedDisplayName);
|
|
expect(
|
|
llm,
|
|
`the LLM row "${expectedDisplayName}" must be registered (the deploy provisions it; its absence means the gateway wiring never ran)`,
|
|
).toBeTruthy();
|
|
|
|
expect(
|
|
String(llm.url),
|
|
"the LLM endpoint must be the in-cluster gateway chat-completions URL the deploy configured, not a vendor endpoint",
|
|
).toBe(expectedEndpoint);
|
|
|
|
const endpointUrl = new URL(String(llm.url));
|
|
expect(
|
|
endpointUrl.protocol,
|
|
"the gateway endpoint must be plain http on the container network; https would mean it leaves through the public ingress",
|
|
).toBe("http:");
|
|
expect(
|
|
endpointUrl.hostname.includes("."),
|
|
`the gateway host must be an undotted container name (got "${endpointUrl.hostname}"); a dotted host picks up the dns-search suffix and routes prompts through the public ingress`,
|
|
).toBe(false);
|
|
expect(
|
|
endpointUrl.host.toLowerCase(),
|
|
"the gateway host must be a distinct in-cluster service, not Discourse itself",
|
|
).not.toBe(discourseHost.toLowerCase());
|
|
|
|
expect(
|
|
String(llm.provider),
|
|
"the provider must be open_ai so the OpenAI-compatible endpoint of the gateway is used verbatim",
|
|
).toBe("open_ai");
|
|
|
|
expect(
|
|
String(llm.name),
|
|
"the model name sent in the request body must be the model the gateway serves",
|
|
).toBe(expectedModel);
|
|
|
|
expect(
|
|
String(llm.api_key || "").length,
|
|
"the LLM row must carry this role's own virtual key, otherwise the gateway answers 401",
|
|
).toBeGreaterThan(0);
|
|
|
|
const siteSettings = await page.evaluate(async (base) => {
|
|
const res = await fetch(`${base}/admin/site_settings.json`, {
|
|
headers: { Accept: "application/json" },
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) return { ok: false, status: res.status };
|
|
const body = await res.json();
|
|
return { ok: true, settings: (body && body.site_settings) || [] };
|
|
}, discourseBaseUrl);
|
|
|
|
expect(
|
|
siteSettings.ok,
|
|
`expected /admin/site_settings.json to be reachable as admin (status ${siteSettings.status})`,
|
|
).toBe(true);
|
|
|
|
expect(
|
|
settingValue(siteSettings.settings, "discourse_ai_enabled").toLowerCase(),
|
|
"discourse_ai_enabled must be on, otherwise no AI surface is served at all",
|
|
).toBe("true");
|
|
|
|
expect(
|
|
settingValue(siteSettings.settings, "ai_default_llm_model"),
|
|
"ai_default_llm_model must point at the gateway row, so every AI feature resolves to it",
|
|
).toBe(String(llm.id));
|
|
|
|
const completion = await page.evaluate(async ({ base, model }) => {
|
|
const csrfRes = await fetch(`${base}/session/csrf.json`, {
|
|
headers: { Accept: "application/json", "X-Requested-With": "XMLHttpRequest" },
|
|
credentials: "include",
|
|
});
|
|
const csrfBody = csrfRes.ok ? await csrfRes.json() : {};
|
|
const csrf =
|
|
(csrfBody && csrfBody.csrf) ||
|
|
document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ||
|
|
"";
|
|
|
|
const res = await fetch(`${base}/admin/plugins/discourse-ai/ai-llms/test.json`, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
"X-Requested-With": "XMLHttpRequest",
|
|
"X-CSRF-Token": csrf,
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify({ ai_llm: model }),
|
|
});
|
|
const text = await res.text();
|
|
let body;
|
|
try {
|
|
body = JSON.parse(text);
|
|
} catch {
|
|
body = { raw: text.slice(0, 500) };
|
|
}
|
|
return { status: res.status, body };
|
|
}, {
|
|
base: discourseBaseUrl,
|
|
model: {
|
|
provider: llm.provider,
|
|
name: llm.name,
|
|
url: llm.url,
|
|
api_key: llm.api_key,
|
|
tokenizer: llm.tokenizer,
|
|
max_prompt_tokens: llm.max_prompt_tokens,
|
|
},
|
|
});
|
|
|
|
expect(
|
|
completion.status,
|
|
`the admin LLM probe must be accepted (status ${completion.status})`,
|
|
).toBe(200);
|
|
|
|
expect(
|
|
completion.body.success,
|
|
`the gateway must answer the probe prompt with non-empty text in both the streaming and the non-streaming mode: ${JSON.stringify(completion.body.error || completion.body.validation_errors || completion.body.raw || completion.body)}`,
|
|
).toBe(true);
|
|
} finally {
|
|
await page.context().clearCookies().catch(() => {});
|
|
}
|
|
});
|