252 lines
15 KiB
JavaScript
252 lines
15 KiB
JavaScript
/** Focused synthetic live-UI check. No model/provider calls: /api/chat is
|
|
* intercepted, catalog is a fixture, app HOME/data/auth are throwaway, and
|
|
* the browser uses a fresh profile. Creates only synthetic session records.
|
|
* Run: node scripts/smoke-opening-prompt.mjs
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
import { once } from "node:events";
|
|
import fs from "node:fs";
|
|
import net from "node:net";
|
|
import { createRequire } from "node:module";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import puppeteer from "puppeteer-core";
|
|
|
|
const root = path.resolve(import.meta.dirname, "..");
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh154-ui-"));
|
|
fs.mkdirSync(path.join(home, "data"));
|
|
fs.writeFileSync(path.join(home, "auth.json"), "{}\n");
|
|
// Next's supported font fixture avoids external font downloads in this check.
|
|
const require = createRequire(import.meta.url);
|
|
const { getFontAxes } = require("next/dist/compiled/@next/font/dist/google/get-font-axes.js");
|
|
const { getGoogleFontsUrl } = require("next/dist/compiled/@next/font/dist/google/get-google-fonts-url.js");
|
|
const fonts = Object.fromEntries(["JetBrains Mono", "Source Sans 3"].map(family => [
|
|
getGoogleFontsUrl(family, getFontAxes(family, ["variable"], ["normal"]), "swap"),
|
|
`@font-face { font-family: '${family}'; src: local('Arial'); font-weight: 100 900; }`,
|
|
]));
|
|
fs.writeFileSync(path.join(home, "fonts.cjs"), `module.exports = ${JSON.stringify(fonts)};\n`);
|
|
const listener = net.createServer();
|
|
listener.listen(0, "127.0.0.1");
|
|
await once(listener, "listening");
|
|
const port = listener.address().port;
|
|
await new Promise(resolve => listener.close(resolve));
|
|
const origin = `http://127.0.0.1:${port}`;
|
|
const env = {
|
|
PATH: process.env.PATH, HOME: home, TMPDIR: home, NODE_ENV: "development",
|
|
LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"),
|
|
LH_IGNORE_ENV_KEYS: "1", NEXT_TELEMETRY_DISABLED: "1",
|
|
NEXT_FONT_GOOGLE_MOCKED_RESPONSES: path.join(home, "fonts.cjs"),
|
|
};
|
|
const server = spawn(process.execPath, ["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)], {
|
|
cwd: root, env, stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let logs = "";
|
|
const capture = data => { logs = (logs + data).slice(-24000); };
|
|
server.stdout.on("data", capture); server.stderr.on("data", capture);
|
|
let browser;
|
|
let page;
|
|
const errors = [];
|
|
const checks = [];
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
const deadline = setTimeout(() => reject(new Error("app startup timed out")), 60000);
|
|
const ready = () => { if (logs.includes("Ready in")) { clearTimeout(deadline); server.stdout.off("data", ready); resolve(); } };
|
|
server.stdout.on("data", ready);
|
|
server.once("exit", code => { clearTimeout(deadline); reject(new Error(`app exited ${code}`)); });
|
|
ready();
|
|
});
|
|
browser = await puppeteer.launch({
|
|
executablePath: process.env.LH_TEST_CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
headless: true, userDataDir: path.join(home, "browser"), env,
|
|
args: ["--disable-background-networking", "--disable-sync", "--no-first-run", "--disable-default-apps"],
|
|
});
|
|
page = await browser.newPage();
|
|
page.setDefaultTimeout(45000);
|
|
page.on("pageerror", err => errors.push(err.message));
|
|
const model = { id: "synthetic", name: "Synthetic (no calls)", provider: "fixture", input: ["text"], reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 };
|
|
const slot = { model: { provider: model.provider, id: model.id }, thinkingLevel: "off" };
|
|
const catalog = { models: [model], providers: [{ id: "fixture", label: "Fixture", keyPresent: true }], defaults: null,
|
|
slots: { strong: slot, cheap: slot, vision: slot }, rolePins: {} };
|
|
const posts = [];
|
|
const creates = [];
|
|
const accepted = new Map();
|
|
let mode = "accepted";
|
|
let held = [];
|
|
await page.setRequestInterception(true);
|
|
page.on("request", async request => {
|
|
try {
|
|
const url = new URL(request.url());
|
|
if (url.origin !== origin) { await request.abort(); return; }
|
|
const pathname = url.pathname;
|
|
const respond = body => request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
|
|
if (pathname === "/api/models") return await respond(catalog);
|
|
// Notifications are outside this handover check. 204 tells native
|
|
// EventSource not to reconnect or hold a test connection open.
|
|
if (pathname === "/api/events") return await request.respond({ status: 204 });
|
|
if (pathname === "/api/chat") {
|
|
const body = JSON.parse(request.postData()); posts.push(body);
|
|
if (mode === "held-post") { held.push(request); return; }
|
|
if (mode === "lost-ack") return await request.abort("failed");
|
|
if (mode === "refused") return await request.respond({ status: 503, contentType: "application/json", body: JSON.stringify({ code: "models_not_configured", missing: ["strong"] }) });
|
|
accepted.set(body.chatId, [{ id: "saved_user", role: "user", content: body.input.text, timestamp: Date.now() }]);
|
|
return await respond({ ok: true });
|
|
}
|
|
if (/^\/api\/chats\/[^/]+\/stream$/.test(pathname)) {
|
|
// Keep teacher delivery unfinished: cleanup must NOT wait for it.
|
|
held.push(request); return;
|
|
}
|
|
const match = pathname.match(/^\/api\/chats\/([^/]+)$/);
|
|
if (match && request.method() === "GET" && accepted.has(match[1])) {
|
|
return await respond({ messages: accepted.get(match[1]), turns: [], turnInFlight: false });
|
|
}
|
|
if (pathname === "/api/chats" && request.method() === "POST") creates.push(JSON.parse(request.postData()));
|
|
await request.continue();
|
|
} catch (err) { if (!/Target closed|Session closed|Invalid InterceptionId/.test(String(err))) errors.push(String(err)); }
|
|
});
|
|
async function open(route) {
|
|
// CDP-intercepted requests do not settle themselves on navigation.
|
|
// Release synthetic streams from prior cases rather than pin slots.
|
|
await Promise.all(held.splice(0).map(request => request.abort("aborted").catch(() => {})));
|
|
await page.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 });
|
|
await page.waitForSelector(route === "/" ? "textarea" : '[data-testid="composer"] textarea');
|
|
await page.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"]') && !document.querySelector('[data-testid="setup-cta-keys"]'));
|
|
}
|
|
async function fill(text) {
|
|
await page.evaluate(value => {
|
|
const textarea = document.querySelector('[data-testid="composer"] textarea') || document.querySelector("textarea");
|
|
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set.call(textarea, value);
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}, text);
|
|
}
|
|
async function submit() {
|
|
await page.waitForFunction(() => {
|
|
const button = document.querySelector('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]');
|
|
return button && !button.disabled;
|
|
});
|
|
await page.locator('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]').click();
|
|
}
|
|
async function stash(id) { return page.evaluate(chat => sessionStorage.getItem(`lh.openingPrompt.${chat}`), id); }
|
|
async function assertDraft(text) { await page.waitForFunction(value => document.querySelector('[data-testid="composer"] textarea')?.value === value, {}, text); }
|
|
const long = "A full synthetic syllabus — 世界 & ? #. ".repeat(1200).trim();
|
|
for (const route of ["/", "/chat/new"]) {
|
|
for (const text of ["a short synthetic topic", long]) {
|
|
await open(route);
|
|
const count = posts.length;
|
|
await fill(text); await submit();
|
|
await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname) && !location.search);
|
|
await page.waitForFunction(() => document.querySelector('[data-testid="composer"]'));
|
|
await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt.")));
|
|
assert.equal(posts.length, count + 1);
|
|
assert.equal(posts.at(-1).input.text, text);
|
|
assert.equal(new URL(page.url()).search, "");
|
|
assert.equal(await stash(posts.at(-1).chatId), null);
|
|
checks.push(`${route}: ${text.length > 1000 ? "long" : "short"} input delivered intact through short URL; stash removed while stream held`);
|
|
}
|
|
}
|
|
// The old long-query mechanism really is rejected by this isolated Node server.
|
|
const oldResponse = await fetch(`${origin}${new URL(page.url()).pathname}?prompt=${encodeURIComponent(long)}`);
|
|
assert.equal(oldResponse.status, 431); await oldResponse.text();
|
|
checks.push("old long-query request returned431 (browser-shell blank appearance not asserted)");
|
|
|
|
for (const route of ["/", "/chat/new"]) {
|
|
await open(route);
|
|
await page.evaluate(() => {
|
|
const setItem = Storage.prototype.setItem;
|
|
Storage.prototype.setItem = function(key, value) {
|
|
if (window.failOpeningStorage && key.startsWith("lh.openingPrompt.")) throw new DOMException("synthetic quota", "QuotaExceededError");
|
|
return setItem.call(this, key, value);
|
|
};
|
|
window.failOpeningStorage = true;
|
|
});
|
|
const createCount = creates.length; const sendCount = posts.length;
|
|
await fill(long); await submit();
|
|
await page.waitForSelector('[role="alert"]');
|
|
assert.equal(new URL(page.url()).pathname, route);
|
|
assert.equal(await page.$eval("textarea", element => element.value), long);
|
|
assert.equal(posts.length, sendCount);
|
|
assert.equal(creates.length, createCount + 1);
|
|
await page.evaluate(() => { window.failOpeningStorage = false; });
|
|
await submit();
|
|
await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname));
|
|
await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt.")));
|
|
assert.equal(creates.length, createCount + 1, "unchanged retry reuses creation");
|
|
assert.equal(posts.at(-1).input.text, long);
|
|
checks.push(`${route}: quota failure preserved visible draft/no navigation; retry reused creation`);
|
|
}
|
|
|
|
for (const failure of ["lost-ack", "refused", "held-post"]) {
|
|
mode = failure;
|
|
await open("/chat/new");
|
|
const text = `recover interrupted ${failure}`;
|
|
await fill(text); await submit();
|
|
await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname));
|
|
await page.waitForFunction(() => Object.values(sessionStorage).some(v => v.includes('"attempted":true')));
|
|
if (failure !== "held-post") await assertDraft(text);
|
|
const count = posts.length;
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await assertDraft(text);
|
|
assert.equal(posts.length, count, "interrupted start must not automatically repost");
|
|
checks.push(`${failure}: reload restored full draft, no automatic repost`);
|
|
if (failure === "lost-ack") {
|
|
mode = "accepted";
|
|
await submit();
|
|
const id = new URL(page.url()).pathname.split("/").at(-1);
|
|
await page.waitForFunction(chat => !sessionStorage.getItem(`lh.openingPrompt.${chat}`), {}, id);
|
|
assert.equal(posts.length, count + 1);
|
|
assert.equal(posts.at(-1).input.text, text);
|
|
checks.push("restored draft sent only after explicit click and cleaned up on acceptance");
|
|
}
|
|
}
|
|
mode = "accepted";
|
|
// Marker-only session must still automatically deliver a fresh opening.
|
|
await open("/chat/new");
|
|
const markerId = await page.evaluate(async () => {
|
|
const res = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "marker fixture", graphContext: { propositions: [{ id: "synthetic_node", name: "Fixture", statement: "Synthetic" }] } }) });
|
|
return (await res.json()).id;
|
|
});
|
|
await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "marker opening", attempted: false })), markerId);
|
|
const markerCount = posts.length;
|
|
await page.goto(`${origin}/chat/${markerId}`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId);
|
|
assert.equal(posts.length, markerCount + 1);
|
|
assert.equal(posts.at(-1).input.text, "marker opening");
|
|
checks.push("graph-marker-only record still receives opening text");
|
|
// A real persisted user prevents delivery and cleans a stale copy on mount.
|
|
const chatFile = path.join(home, "data", "chats", `${markerId}.json`);
|
|
const record = JSON.parse(fs.readFileSync(chatFile, "utf8"));
|
|
record.messages.push({ id: "confirmed_fixture", role: "user", content: "already received", timestamp: Date.now() });
|
|
fs.writeFileSync(chatFile, JSON.stringify(record));
|
|
await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "stale opening", attempted: true })), markerId);
|
|
const completedCount = posts.length;
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId);
|
|
assert.equal(posts.length, completedCount);
|
|
checks.push("persisted real conversation removed stale stash without reposting");
|
|
await open("/chat/new");
|
|
const legacyId = await page.evaluate(async () => {
|
|
const response = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "legacy fixture" }) });
|
|
return (await response.json()).id;
|
|
});
|
|
const legacyCount = posts.length;
|
|
await page.goto(`${origin}/chat/${legacyId}?prompt=${encodeURIComponent("legacy opening")}`, { waitUntil: "domcontentloaded" });
|
|
await page.waitForFunction(() => document.body.innerText.includes("legacy opening"));
|
|
await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, legacyId);
|
|
assert.equal(posts.length, legacyCount + 1);
|
|
assert.equal(posts.at(-1).input.text, "legacy opening");
|
|
checks.push("legacy short ?prompt link still sends its complete opening message");
|
|
assert.deepEqual(errors, []);
|
|
console.log(JSON.stringify({ checks, pageErrors: errors, isolation: "synthetic catalog/POST+streams; empty auth; no providers", origin }, null, 2));
|
|
} catch (err) {
|
|
console.error(err);
|
|
console.error(JSON.stringify({ checks, errors, url: page?.url(), dom: await page?.evaluate(() => ({ textareas: [...document.querySelectorAll('textarea')].map(e => ({ length: e.value.length, placeholder: e.placeholder })), buttons: [...document.querySelectorAll('button[aria-label="Send"], button[aria-label="Start session"]')].map(e => ({ disabled: e.disabled, label: e.getAttribute('aria-label') })), alerts: [...document.querySelectorAll('[role="alert"]')].map(e => e.textContent), failStorage: window.failOpeningStorage, text: document.body.innerText.slice(-1200) })).catch(() => null) }, null, 2));
|
|
console.error(logs); process.exitCode = 1;
|
|
} finally {
|
|
await browser?.close();
|
|
const exited = once(server, "exit");
|
|
server.kill("SIGTERM");
|
|
const killTimer = setTimeout(() => server.kill("SIGKILL"), 5000); killTimer.unref();
|
|
await exited; clearTimeout(killTimer);
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|