Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions scripts/browser-acceptance-multi-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const root = process.cwd();
const runId = `${process.pid}-${Date.now()}`;
const temporaryRoot = await mkdtemp(join(tmpdir(), "opengui-multi-user-acceptance-"));
const dataDirectory = join(temporaryRoot, "host-data");
const homeDirectory = join(temporaryRoot, "home");
const projectDirectory = join(temporaryRoot, "project");
const writableDirectory = join(projectDirectory, "writable");
const logs: string[] = [];
Expand Down Expand Up @@ -95,6 +96,8 @@ function startDevelopmentStack(frontendPort: number, backendPort: number) {
detached: process.platform !== "win32",
env: {
...process.env,
HOME: homeDirectory,
XDG_CONFIG_HOME: join(homeDirectory, ".config"),
HOST: "127.0.0.1",
OPENGUI_WEB_BACKEND_HOST: "127.0.0.1",
OPENGUI_WEB_BACKEND_PORT: String(backendPort),
Expand All @@ -117,7 +120,7 @@ function startDevelopmentStack(frontendPort: number, backendPort: number) {
children.push(child);
}

async function waitForUrl(url: string, timeoutMilliseconds = 25_000) {
async function waitForUrl(url: string, timeoutMilliseconds = 45_000) {
const deadline = Date.now() + timeoutMilliseconds;
while (Date.now() < deadline) {
try {
Expand Down Expand Up @@ -231,7 +234,7 @@ async function runScenarios(frontendUrl: string, backendUrl: string, modelPort:
log("owner bootstrap uses the product setup gate on a fresh isolated Host");
await browser(ownerBrowser!, "open", frontendUrl);
await browser(ownerBrowser!, "set", "viewport", "1440", "900");
await waitForText(ownerBrowser!, "Set up this Host");
await waitForText(ownerBrowser!, "Set up this Host", 45_000);
await fillLabel(ownerBrowser!, "Username", "acceptance_owner");
await fillLabel(ownerBrowser!, "Email", "owner@example.test");
await fillLabel(ownerBrowser!, "Password", "acceptance-password");
Expand All @@ -241,9 +244,8 @@ async function runScenarios(frontendUrl: string, backendUrl: string, modelPort:
const owner = await login(backendUrl, "acceptance_owner", "acceptance-password");

log("logout invalidates the old session and username/password login restores the account");
await clickButton(ownerBrowser!, "Settings");
await waitForText(ownerBrowser!, "Host account");
await clickButton(ownerBrowser!, "Sign out");
await clickButton(ownerBrowser!, "Open profile menu");
await find(ownerBrowser!, "role", "menuitem", "click", "Sign out");
await waitForText(ownerBrowser!, "Sign in to OpenGUI");
await fillLabel(ownerBrowser!, "Username", "acceptance_owner");
await fillLabel(ownerBrowser!, "Password", "acceptance-password");
Expand All @@ -265,9 +267,9 @@ async function runScenarios(frontendUrl: string, backendUrl: string, modelPort:
},
});
assert(closedRegistration.status === 403, "Invite-only Host accepted open registration");
await clickButton(ownerBrowser!, "Users & access");
await clickButton(ownerBrowser!, "People");
await waitForText(ownerBrowser!, "Members");
await clickButton(ownerBrowser!, "Host & admin");
await clickButton(ownerBrowser!, "System");
await expectText(ownerBrowser!, "Invite only");

log("an invite with initial read access is accepted in a second isolated browser");
Expand Down Expand Up @@ -605,9 +607,9 @@ async function runScenarios(frontendUrl: string, backendUrl: string, modelPort:

log("UI boundary: members cannot see owner Team controls; canInvite remains API-only today");
await clickButton(invitedBrowser!, "Settings");
await waitForText(invitedBrowser!, "Models & providers");
await expectNoText(invitedBrowser!, "Users & access");
await expectText(invitedBrowser!, "Models & providers");
await waitForText(invitedBrowser!, "Models");
await expectNoText(invitedBrowser!, "People");
await expectText(invitedBrowser!, "Models");

for (const sessionName of browserSessions) {
const errors = await browser(sessionName, "errors");
Expand Down Expand Up @@ -637,7 +639,11 @@ async function cleanup() {
}

try {
await Promise.all([mkdir(dataDirectory), mkdir(writableDirectory, { recursive: true })]);
await Promise.all([
mkdir(dataDirectory),
mkdir(homeDirectory),
mkdir(writableDirectory, { recursive: true }),
]);
await writeFile(join(projectDirectory, "read-only.txt"), "owner fixture");
const [modelPort, backendPort, frontendPort] = await Promise.all([
freePort(),
Expand All @@ -648,6 +654,7 @@ try {
startDevelopmentStack(frontendPort, backendPort);
const frontendUrl = `http://127.0.0.1:${frontendPort}`;
await waitForUrl(frontendUrl);
await waitForUrl(`${frontendUrl}/api/identity/policy`);
await runScenarios(frontendUrl, `http://127.0.0.1:${backendPort}`, modelPort);
log("PASS: deterministic remote multi-user identity and sharing acceptance completed");
} catch (error) {
Expand Down
48 changes: 35 additions & 13 deletions scripts/browser-acceptance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const runId = `${process.pid}-${Date.now()}`;
const browserSession = `opengui-acceptance-${runId}`;
const temporaryRoot = await mkdtemp(join(tmpdir(), "opengui-browser-acceptance-"));
const dataDirectory = join(temporaryRoot, "host-data");
const homeDirectory = join(temporaryRoot, "home");
const projectDirectory = join(temporaryRoot, "project");
const logs: string[] = [];
const providerRequests: string[] = [];
Expand Down Expand Up @@ -54,6 +55,15 @@ async function streamFakeCompletion(response: ServerResponse, request: Record<st
[...messages].reverse().find((message) => message.role === "user")?.content ?? "";
const lastMessage = messages.at(-1);
const toolResult = lastMessage?.role === "tool" ? lastMessage.content : undefined;
// These requests contain only the isolated acceptance fixture's prompts.
logs.push(
`[fixture-provider] prompt=${JSON.stringify(String(lastUser).slice(0, 200))} toolResult=${Boolean(toolResult)}\n`,
);
response.once("close", () => {
logs.push(
`[fixture-provider] closed prompt=${JSON.stringify(String(lastUser).slice(0, 200))}\n`,
);
});
const sendDelta = (delta: Record<string, unknown>) =>
writeSse(response, { choices: [{ delta }] });

Expand Down Expand Up @@ -127,6 +137,8 @@ function startDevelopmentStack(frontendPort: number, backendPort: number) {
detached: process.platform !== "win32",
env: {
...process.env,
HOME: homeDirectory,
XDG_CONFIG_HOME: join(homeDirectory, ".config"),
HOST: "127.0.0.1",
OPENGUI_WEB_BACKEND_HOST: "127.0.0.1",
OPENGUI_WEB_BACKEND_PORT: String(backendPort),
Expand All @@ -149,7 +161,7 @@ function startDevelopmentStack(frontendPort: number, backendPort: number) {
children.push(child);
}

async function waitForUrl(url: string, timeoutMilliseconds = 20_000) {
async function waitForUrl(url: string, timeoutMilliseconds = 45_000) {
const deadline = Date.now() + timeoutMilliseconds;
while (Date.now() < deadline) {
try {
Expand Down Expand Up @@ -245,7 +257,7 @@ async function auditAccessibleResponsiveSurface(width: number, height: number) {
}

async function finishSetup(modelPort: number) {
await waitForText("Set up this Host");
await waitForText("Set up this Host", 45_000);
await fillLabel("Username", "acceptance_owner");
await fillLabel("Email", "acceptance-owner@example.com");
await fillLabel("Password", "acceptance-password");
Expand Down Expand Up @@ -289,7 +301,7 @@ async function connectProject() {
await clickButton("Open project");
await browser("wait", "--fn", "!document.querySelector('[data-slot=dialog-content]')");
await waitForText("project");
await clickButton("New session");
await clickButton("New Chat");
const content = await body();
if (!content.includes("fixture-model")) {
await clickButton("Choose model");
Expand All @@ -304,6 +316,17 @@ async function sendAndWait(prompt: string, expected: string) {
await waitForText(expected, 20_000);
}

async function reloadAndReopenSession() {
await browser("reload");
// Reopen the persisted Session through the sidebar, independently of the
// transient selection on the landing page. Desktop exposes the same public
// control in all tested languages; responsive audits set their own viewport.
await browser("set", "viewport", "1440", "900");
await waitForText("Acceptance Session", 45_000);
await find("text", "Acceptance Session", "click", undefined);
await waitForText("Tool result observed:", 20_000);
}

async function runJourneys(frontendUrl: string, modelPort: number) {
log("first launch, fresh owner identity, model setup, and Host folder selection");
await browser("open", frontendUrl);
Expand Down Expand Up @@ -370,15 +393,13 @@ async function runJourneys(frontendUrl: string, modelPort: number) {
await delay(500);
await expectText("queued prompt edited");

log("refresh/reconnect and Host persistence");
await browser("reload");
await waitForText("Acceptance Session", 20_000);
await waitForText("Tool result observed:", 20_000);
log("refresh/reopen and Host persistence");
await reloadAndReopenSession();

log("settings/provider persistence and keyboard focus");
await clickButton("Settings");
await waitForText("Preferences, models, people, and Host administration in one place.");
await clickButton("Models & providers");
await clickButton("Models");
await expectText("fixture-model");
await browser("press", "Tab");

Expand Down Expand Up @@ -484,17 +505,15 @@ async function runJourneys(frontendUrl: string, modelPort: number) {
"eval",
`localStorage.setItem('opengui:web:settings:opengui:language', '${language}')`,
);
await browser("reload");
await waitForText("Tool result observed:", 20_000);
await reloadAndReopenSession();
await expectBrowserInvariant(
`${language} document language`,
`document.documentElement.lang === '${language}'`,
);
await auditAccessibleResponsiveSurface(390, 844);
}
await browser("eval", "localStorage.setItem('opengui:web:settings:opengui:language', 'en')");
await browser("reload");
await waitForText("Tool result observed:", 20_000);
await reloadAndReopenSession();

const errors = await browser("errors");
if (errors && !/No page errors/i.test(errors)) throw new Error(`Browser errors:\n${errors}`);
Expand All @@ -519,7 +538,7 @@ async function cleanup() {
}

try {
await Promise.all([mkdir(dataDirectory), mkdir(projectDirectory)]);
await Promise.all([mkdir(dataDirectory), mkdir(projectDirectory), mkdir(homeDirectory)]);
await writeFile(
join(projectDirectory, "notes.txt"),
"fixture content from the isolated project\n",
Expand All @@ -533,6 +552,9 @@ try {
startDevelopmentStack(frontendPort, backendPort);
const frontendUrl = `http://127.0.0.1:${frontendPort}`;
await waitForUrl(frontendUrl);
// Do not let the initial identity request race a cold Host startup and leave
// the browser on the connection-error gate before the test journey begins.
await waitForUrl(`${frontendUrl}/api/identity/policy`);
await runJourneys(frontendUrl, modelPort);
log("PASS: deterministic full-stack browser acceptance completed");
} catch (error) {
Expand Down
Loading