scripts/testing/model-switch-ui-smoke.cjs
Rama main · réplica de lectura del repositorio original.
// Real model picker + private desktop confirmation, against the disposable
// approval fixture. Provider replies are synthetic; no account is contacted.
const { BrowserWindow, ipcMain } = require("electron");
const assert = require("node:assert/strict");
const { mkdirSync, writeFileSync } = require("node:fs");
const { join } = require("node:path");
const { pathToFileURL } = require("node:url");
module.exports = async function verifyModelSwitch({ root, url, api, until, grant }) {
const { mountPreview } = await import(pathToFileURL(join(root, "scripts/testing/preview-fixture.ts")).href);
const bot = (await api("/api/bots", "POST", { name: "Model switch fixture", modelSelection: { instanceId: "codex", model: "gpt-fake-default" } })).body.bot;
await grant(bot.id, "custom");
const sibling = (await api(`/api/bots/${bot.id}/tasks`, "POST", { title: "Unrelated custom conversation" })).body.task;
const selected = (await api(`/api/bots/${bot.id}/tasks`, "POST", { title: "Research brief" })).body.task;
const read = async () => (await api("/api/bots?messages=0")).body.bots.find(candidate => candidate.id === bot.id);
const selection = { instanceId: "claude", model: "claude-sonnet-5" };
// Neither changing the mode separately nor combining fields can bypass
// the Custom boundary from a bot-accessible HTTP connection.
assert.equal((await api(`/api/bots/${bot.id}/tasks/${selected.threadId}`, "PATCH", { approvalMode: "ask", modelSelection: selection })).status, 403);
assert.equal((await api(`/api/bots/${bot.id}/tasks/${selected.threadId}`, "PATCH", { resetApprovalToAsk: true, modelSelection: selection })).status, 403);
const preview = await mountPreview({ info: { url } }, { entry: "/src/testing/thread-approvals.tsx", route: "/__thread-approvals.html", title: "Isolated model switching", logLevel: "silent" });
const window = new BrowserWindow({ show: false, width: 1100, height: 850, webPreferences: { preload: join(root, "scripts/testing/approval-preview-preload.cjs"), contextIsolation: true, sandbox: true } });
const calls = [];
ipcMain.handle("fixture:thread-approval", (event, botId, mode, options) => {
assert.equal(event.sender, window.webContents);
assert.equal(botId, bot.id);
assert.equal(mode, "ask");
assert.equal(options.threadId, selected.threadId);
calls.push(options);
return grant(botId, mode, options);
});
const evaluate = js => window.webContents.executeJavaScript(js).catch(error => { throw new Error(`${error.message}\nExpression: ${js}`); });
const text = () => evaluate("document.body.innerText");
const click = name => evaluate(`(() => { const button = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(name)} || b.getAttribute('aria-label') === ${JSON.stringify(name)}); if (!button || button.disabled) throw new Error('Missing enabled button: ' + ${JSON.stringify(name)}); button.click(); return true; })()`);
const openPicker = async () => { await evaluate("document.querySelector('[data-tour=model]').click(); true"); await until(() => evaluate("!!document.querySelector('[data-model-picker-content]')")); };
const selectClaude = async () => {
await until(() => evaluate("!!document.querySelector('[data-model-picker-content] button[aria-label=Claude]')"));
await click("Claude");
await until(() => evaluate("[...document.querySelectorAll('[data-model-picker-content] button')].some(b => b.textContent.startsWith('Claude Sonnet 5'))"));
await evaluate("[...document.querySelectorAll('[data-model-picker-content] button')].find(b => b.textContent.startsWith('Claude Sonnet 5')).click(); true");
await until(async () => (await text()).includes("Switch model with Ask permissions?"));
};
const evidence = join(root, ".omb-scratch/verify-evidence/model-switch");
mkdirSync(evidence, { recursive: true });
try {
await window.loadURL(`${preview.previewUrl}?bot=${bot.id}&model-switch=1`);
await until(() => evaluate("!!document.querySelector('[data-tour=model]')"));
await openPicker();
const instances = (await api("/api/instances")).body.instances;
assert.equal(instances.find(instance => instance.instanceId === "claude-signed-out").snapshot.authenticated, false);
assert.notEqual(instances.find(instance => instance.instanceId === "missing-codex").snapshot.state, "available");
assert.equal(await evaluate("!!document.querySelector('[data-model-picker-content] button[aria-label=\"Missing provider fixture\"]')"), false);
assert.ok((await text()).includes("Engines and accounts"));
assert.equal(await evaluate("[...document.querySelectorAll('[aria-label=\"Apply model changes to\"] button')].find(b => b.textContent === 'Only this thread').getAttribute('aria-pressed')"), "true");
await click("Claude");
await until(() => evaluate("!!document.querySelector('[data-model-picker-content] select option[value=claude]')"));
// A signed-out account stays in the picker and offers its sign-in card,
// never its cloud models; the missing, unused engine stays in Settings.
assert.equal(await evaluate("[...document.querySelectorAll('[data-model-picker-content] select option')].some(option => option.value === 'claude-signed-out')"), true);
const chooseAccount = id => evaluate(`(() => { const select = document.querySelector('[data-model-picker-content] select'); select.value = ${JSON.stringify(id)}; select.dispatchEvent(new Event('change', { bubbles: true })); return true; })()`);
const claudeRows = "[...document.querySelectorAll('[data-model-picker-content] button')].some(b => b.textContent.startsWith('Claude Sonnet 5'))";
await chooseAccount("claude-signed-out");
await until(async () => (await text()).includes("Sign in to Signed-out fixture"));
assert.equal(await evaluate(claudeRows), false);
assert.equal(await evaluate("!!document.querySelector('[data-model-picker-content] [data-model-local-entry]')"), true);
writeFileSync(join(evidence, "signed-out-account.png"), (await window.webContents.capturePage()).toPNG());
await chooseAccount("claude");
await until(() => evaluate(claudeRows));
writeFileSync(join(evidence, "configured-providers.png"), (await window.webContents.capturePage()).toPNG());
for (const [width, height] of [[1280, 800], [1000, 600], [800, 480], [390, 844]]) {
window.setSize(width, height);
await until(() => evaluate(`innerWidth === ${width}`));
await evaluate("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))");
const geometry = await evaluate(`(() => {
const panel = document.querySelector('[data-model-picker-content]');
const list = panel.querySelector('[data-model-list]');
const rect = panel.getBoundingClientRect();
return { left: rect.left, right: rect.right, bottom: rect.bottom, width: innerWidth, height: innerHeight,
listHeight: list.clientHeight, effortHeight: panel.querySelector('select[aria-label="Reasoning effort"]').parentElement.getBoundingClientRect().height };
})()`);
assert.ok(geometry.left >= 0 && geometry.right <= geometry.width, JSON.stringify(geometry));
assert.ok(geometry.bottom <= geometry.height, JSON.stringify(geometry));
assert.ok(geometry.listHeight >= Math.min(180, geometry.height * 0.3) - 2, JSON.stringify(geometry));
assert.ok(geometry.effortHeight <= 52, JSON.stringify(geometry));
writeFileSync(join(evidence, `model-picker-${width}x${height}.png`), (await window.webContents.capturePage()).toPNG());
}
window.setSize(1100, 850);
await selectClaude();
assert.equal(await evaluate("document.activeElement.textContent.trim()"), "Cancel");
await click("Cancel");
assert.equal(calls.length, 0);
const cancelledTask = (await read()).tasks.find(task => task.threadId === selected.threadId);
assert.equal(cancelledTask.approvalMode, "custom");
assert.equal(cancelledTask.modelSelection.instanceId, "codex");
await openPicker();
await selectClaude();
window.setSize(390, 844);
await until(() => evaluate("innerWidth === 390"));
assert.equal(await evaluate("(() => { const r = document.querySelector('[role=alertdialog]').getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth; })()"), true);
writeFileSync(join(evidence, "thread-confirmation.png"), (await window.webContents.capturePage()).toPNG());
await click("Switch with Ask");
await until(async () => {
const task = (await read()).tasks.find(task => task.threadId === selected.threadId);
return task.modelSelection.instanceId === "claude" && task.approvalMode === "ask";
});
const threadOnly = await read();
assert.equal(threadOnly.approvalMode, "custom");
assert.equal(threadOnly.modelSelection.instanceId, "codex");
assert.equal(threadOnly.tasks.find(task => task.threadId === sibling.threadId).approvalMode, "custom");
assert.deepEqual(calls[0], { threadId: selected.threadId, modelSelection: selection, updateBotDefault: false });
window.setSize(1100, 850);
await openPicker();
await click("Thread + bot default");
await selectClaude();
assert.ok((await text()).includes("Groups and new threads use this default"));
writeFileSync(join(evidence, "default-confirmation.png"), (await window.webContents.capturePage()).toPNG());
await click("Switch with Ask");
await until(async () => (await read()).modelSelection.instanceId === "claude");
const after = await read();
assert.equal(after.approvalMode, "ask");
assert.equal(after.tasks.find(task => task.threadId === sibling.threadId).approvalMode, "custom");
assert.equal(calls[1].updateBotDefault, true);
const newThread = (await api(`/api/bots/${bot.id}/tasks`, "POST", { title: "Uses the new default" })).body.task;
assert.equal(newThread.modelSelection.instanceId, "claude");
assert.equal(newThread.approvalMode, "ask");
await api(`/api/bots/${bot.id}/tasks/${selected.threadId}`, "POST", {});
await until(async () => (await read()).threadId === selected.threadId);
// Send a work request through the real composer after both transitions.
await evaluate("document.querySelector('textarea').focus(); true");
window.webContents.insertText("Draft three acceptance criteria for the engineering handoff.");
window.webContents.sendInputEvent({ type: "keyDown", keyCode: "Return" });
window.webContents.sendInputEvent({ type: "keyUp", keyCode: "Return" });
await until(async () => !(await read()).busy && (await text()).includes("hello from fake claude"));
writeFileSync(join(evidence, "after-send.png"), (await window.webContents.capturePage()).toPNG());
const localModel = instances.find(instance => instance.instanceId === "codex").models.options.find(option => option.custom && option.id.includes("fixture-local"));
assert.ok(localModel, "Fixture includes a configured local model alongside Codex cloud models");
assert.equal((await api(`/api/bots/${bot.id}/tasks/${selected.threadId}`, "PATCH", { modelSelection: { instanceId: "codex", model: localModel.id } })).status, 200);
await until(() => evaluate(`document.querySelector('[data-tour=model]').textContent.includes(${JSON.stringify(localModel.label)})`));
await openPicker();
await click("Claude");
window.webContents.sendInputEvent({ type: "keyDown", keyCode: "Escape" });
window.webContents.sendInputEvent({ type: "keyUp", keyCode: "Escape" });
await until(() => evaluate("!document.querySelector('[data-model-picker-content]')"));
await openPicker();
await evaluate("new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))");
await until(() => evaluate(`[...document.querySelectorAll('[data-model-picker-content] button')].some(button => button.textContent.includes(${JSON.stringify(localModel.label)}))`));
writeFileSync(join(evidence, "reopened-local-model.png"), (await window.webContents.capturePage()).toPNG());
console.log(JSON.stringify({ modelSwitch: true, missingProviderHidden: true, signedOutAccountShowsSignIn: true, unconfiguredRetainedInCatalog: true, customHttpRefused: true, cancelPreservedSettings: true,
scopedCustomSwitch: true, defaultMismatchHandled: true, siblingUnchanged: true, newThreadUsesDefault: true,
sentAfterSwitch: true, narrowLayout: true, selectedLocalModelOnReopen: true, providerReplies: "offline fake CLI", evidence }));
} finally {
ipcMain.removeHandler("fixture:thread-approval");
window.destroy();
await preview.close();
}
};