Merge upstream v0.4.0 into Gitea fork
This commit is contained in:
@@ -52,7 +52,8 @@ if (exists(leakedData)) {
|
||||
// (the DEV BYOK key store, 0600) byte-identical into the DMG (caught in
|
||||
// the 2026-10-08 payload audit; excluded in next.config.ts — this guard
|
||||
// makes a regression loud instead of silent).
|
||||
for (const secret of ["auth.json", ".env", ".env.local"]) {
|
||||
const subscriptionSecrets = fs.readdirSync(STANDALONE).filter((name) => /^(chatgpt|github-copilot|xai)-credentials\.json/.test(name));
|
||||
for (const secret of ["auth.json", ...subscriptionSecrets, ".env", ".env.local"]) {
|
||||
if (exists(path.join(STANDALONE, secret))) {
|
||||
console.error(`[build-server] REFUSING: standalone contains ${secret} — dev secrets would ship in the app.`);
|
||||
console.error(" Check outputFileTracingExcludes in next.config.ts.");
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/** Fake-input regression for Next's real compression/backpressure path.
|
||||
* Runs in a disposable child so zlib instrumentation cannot affect the app.
|
||||
* node scripts/smoke-compression-runtime.mjs [--standalone <dir>] [--runtime <Node/Electron>]
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import * as fs from "node:fs";
|
||||
import * as http from "node:http";
|
||||
import { createRequire } from "node:module";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export function verifyCompressionRuntime(root, runtime = process.execPath) {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-compression-runtime-"));
|
||||
try {
|
||||
for (const dir of ["data", "tmp"]) fs.mkdirSync(path.join(home, dir));
|
||||
fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 });
|
||||
// Allowlist rather than inherit credentials, Node preloads, or app settings.
|
||||
const env = Object.fromEntries(["PATH", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"].flatMap(key =>
|
||||
process.env[key] === undefined ? [] : [[key, process.env[key]]]));
|
||||
const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(root)], {
|
||||
cwd: home,
|
||||
env: { ...env, HOME: home, USERPROFILE: home, TMPDIR: path.join(home, "tmp"), TEMP: path.join(home, "tmp"), TMP: path.join(home, "tmp"),
|
||||
LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), NODE_ENV: "production", ELECTRON_RUN_AS_NODE: "1", NEXT_TELEMETRY_DISABLED: "1" },
|
||||
encoding: "utf8", timeout: 30_000, maxBuffer: 128 * 1024,
|
||||
});
|
||||
assert.equal(result.error, undefined, `compression probe failed: ${result.error?.message}`);
|
||||
assert.equal(result.status, 0, `compression probe failed:\n${result.stdout}${result.stderr}`);
|
||||
const report = JSON.parse(result.stdout);
|
||||
assert.equal(report.passed, true);
|
||||
console.log(`[compression-runtime] passed (${report.node}): gzip Node/Web, identity, no-transform, disconnect`);
|
||||
return report;
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function probe(root) {
|
||||
const require = createRequire(path.join(root, "package.json"));
|
||||
const inside = (specifier) => {
|
||||
const resolved = fs.realpathSync(require.resolve(specifier));
|
||||
const relative = path.relative(fs.realpathSync(root), resolved);
|
||||
assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), `dependency escaped payload: ${resolved}`);
|
||||
return require(resolved);
|
||||
};
|
||||
const zlib = require("node:zlib");
|
||||
const descriptor = Object.getOwnPropertyDescriptor(zlib, "createGzip");
|
||||
const records = [];
|
||||
const warnings = [];
|
||||
const onWarning = warning => {
|
||||
if (warning.name === "MaxListenersExceededWarning" && warning.type === "drain" && warning.emitter?.constructor.name === "Gzip") warnings.push(warning.message);
|
||||
};
|
||||
process.on("warning", onWarning);
|
||||
let active;
|
||||
Object.defineProperty(zlib, "createGzip", { ...descriptor, value: (...args) => {
|
||||
const stream = descriptor.value(...args);
|
||||
const record = active;
|
||||
record.gzip = stream;
|
||||
record.registrations = 0;
|
||||
record.drains = 0;
|
||||
record.maxListeners = stream.getMaxListeners();
|
||||
stream.on("newListener", event => { if (event === "drain") record.registrations++; });
|
||||
const emit = stream.emit;
|
||||
stream.emit = function(event, ...args) {
|
||||
if (event === "drain") record.drains++;
|
||||
return emit.call(this, event, ...args);
|
||||
};
|
||||
return stream;
|
||||
} });
|
||||
const compression = inside("next/dist/compiled/compression");
|
||||
const { pipeNodeReadableToNodeResponse, pipeToNodeResponse } = inside("next/dist/server/pipe-readable");
|
||||
const { releaseCompressionStream } = inside("next/dist/server/lib/release-compression-stream");
|
||||
const middleware = compression();
|
||||
const chunk = randomBytes(128 * 1024);
|
||||
const pieces = 24;
|
||||
const server = http.createServer((req, res) => {
|
||||
const record = { mode: req.url, backpressure: 0, lateResumes: 0 };
|
||||
active = record;
|
||||
records.push(record);
|
||||
middleware(req, res, () => {});
|
||||
// Match router-server's abort teardown; do not compensate for drain ownership.
|
||||
record.closed = new Promise(resolve => res.once("close", () => {
|
||||
if (!res.writableFinished) releaseCompressionStream(res);
|
||||
record.finished = res.writableFinished;
|
||||
resolve();
|
||||
}));
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
if (req.url === "/no-transform") res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
const write = res.write;
|
||||
res.write = function(...args) {
|
||||
const ok = write.apply(this, args);
|
||||
if (!ok) record.backpressure++;
|
||||
return ok;
|
||||
};
|
||||
const source = Readable.from(Array.from({ length: pieces }, () => chunk));
|
||||
record.source = source;
|
||||
const resume = source.resume;
|
||||
source.resume = function() {
|
||||
if (this.destroyed) record.lateResumes++;
|
||||
return resume.call(this);
|
||||
};
|
||||
record.pipe = req.url === "/web" ? pipeToNodeResponse(Readable.toWeb(source), res) : pipeNodeReadableToNodeResponse(source, res);
|
||||
// Observe failures immediately, including client abort, without unhandled rejections.
|
||||
record.pipe.catch(error => { record.error = error; res.destroy(); });
|
||||
});
|
||||
async function request(mode) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const req = http.get({ host: "127.0.0.1", port: server.address().port, path: mode, agent: false,
|
||||
headers: { "Accept-Encoding": mode === "/identity" ? "identity" : "gzip" } }, res => {
|
||||
const chunks = [];
|
||||
res.on("data", data => {
|
||||
if (mode === "/abort") { res.destroy(); resolve(); return; }
|
||||
chunks.push(data);
|
||||
// A slow reader exercises both zlib and socket backpressure.
|
||||
res.pause();
|
||||
setTimeout(() => res.resume(), 1);
|
||||
});
|
||||
res.on("error", reject);
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const record = records.at(-1);
|
||||
record.encoding = res.headers["content-encoding"] || "identity";
|
||||
const body = Buffer.concat(chunks);
|
||||
const output = record.encoding === "gzip" ? zlib.gunzipSync(body) : body;
|
||||
assert.equal(output.length, pieces * chunk.length);
|
||||
for (let i = 0; i < pieces; i++) assert.deepEqual(output.subarray(i * chunk.length, (i + 1) * chunk.length), chunk);
|
||||
record.payloadVerified = true;
|
||||
resolve();
|
||||
} catch (error) { reject(error); }
|
||||
});
|
||||
});
|
||||
req.setTimeout(5_000, () => req.destroy(new Error(`compression request stalled: ${mode}`)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
const record = records.at(-1);
|
||||
await record.closed;
|
||||
await record.pipe;
|
||||
assert.equal(record.error, undefined);
|
||||
assert.ok(record.backpressure > 0, `${mode} did not exercise backpressure`);
|
||||
if (mode === "/abort") {
|
||||
assert.equal(record.finished, false);
|
||||
if (!record.gzip.closed) await once(record.gzip, "close");
|
||||
assert.equal(record.gzip.destroyed, true);
|
||||
assert.equal(record.source.destroyed, true);
|
||||
} else {
|
||||
assert.equal(record.finished, true);
|
||||
assert.equal(record.payloadVerified, true);
|
||||
}
|
||||
if (mode === "/node" || mode === "/web") {
|
||||
assert.equal(record.encoding, "gzip");
|
||||
assert.ok(record.backpressure > 10, "must exceed the original warning threshold");
|
||||
assert.ok(record.drains > 10, "must repeat real drain cycles");
|
||||
assert.equal(record.registrations, 1, "drain registrations must not scale with writes");
|
||||
assert.equal(record.gzip.listenerCount("drain"), 1, "upstream retains one response-lifetime listener");
|
||||
}
|
||||
if (mode === "/identity" || mode === "/no-transform") {
|
||||
assert.equal(record.gzip, undefined);
|
||||
assert.equal(record.encoding, "identity");
|
||||
} else {
|
||||
assert.equal(record.maxListeners, 10, "do not raise or silence the listener limit");
|
||||
if (mode !== "/web") {
|
||||
// The gzip listener survives off() but must ignore post-teardown drain.
|
||||
record.gzip.emit("drain");
|
||||
assert.equal(record.lateResumes, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
for (const mode of ["/node", "/web", "/identity", "/no-transform", "/abort"]) await request(mode);
|
||||
await new Promise(resolve => setImmediate(resolve)); // Node delivers warnings asynchronously.
|
||||
assert.deepEqual(warnings, []);
|
||||
return { passed: true, node: process.version, cases: records.map(({ mode, backpressure, registrations, payloadVerified, finished }) => ({ mode, backpressure, registrations, payloadVerified, finished })) };
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
process.off("warning", onWarning);
|
||||
Object.defineProperty(zlib, "createGzip", descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const value = (flag, fallback) => args.includes(flag) ? args[args.indexOf(flag) + 1] : fallback;
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
if (args[0] === "--probe") {
|
||||
probe(path.resolve(args[1])).then(report => console.log(JSON.stringify(report))).catch(error => { console.error(error); process.exitCode = 1; });
|
||||
} else {
|
||||
verifyCompressionRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
/** Offline synthetic UI + actual Electron preload smoke. No providers/accounts.
|
||||
* Run: node scripts/smoke-file-paths.mjs
|
||||
* App/profile/auth/OS-backed fixture files are temporary; only this script's
|
||||
* processes are stopped. Browser requests outside the owned origin are blocked.
|
||||
*/
|
||||
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(), "lh-file-paths-"));
|
||||
const require = createRequire(import.meta.url);
|
||||
const checks = [];
|
||||
const errors = [];
|
||||
const visualFailures = [];
|
||||
const coverageFailures = [];
|
||||
const posts = [];
|
||||
const blocked = [];
|
||||
const networkFailures = [];
|
||||
const consoleErrors = [];
|
||||
const children = [];
|
||||
const browsers = [];
|
||||
const screenshots = [];
|
||||
let serverLogs = "";
|
||||
let electronLogs = "";
|
||||
let page;
|
||||
let origin;
|
||||
let shuttingDown = false;
|
||||
const fixtureCatalog = (() => {
|
||||
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" };
|
||||
return { models: [model], providers: [{ id: "fixture", label: "Fixture", keyPresent: true }], defaults: null, slots: { strong: slot, cheap: slot, vision: slot }, rolePins: {} };
|
||||
})();
|
||||
async function freePort() {
|
||||
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));
|
||||
return port;
|
||||
}
|
||||
async function stop(child) {
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
||||
const exited = once(child, "exit");
|
||||
child.kill("SIGTERM");
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), 5000); timer.unref();
|
||||
await exited; clearTimeout(timer);
|
||||
}
|
||||
async function startup(child, getLogs, marker, timeout = 90000) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => finish(new Error(`Startup timed out: ${getLogs()}`)), timeout);
|
||||
const inspect = () => { if (marker.test(getLogs())) finish(); };
|
||||
const exited = code => finish(new Error(`Process exited ${code}: ${getLogs()}`));
|
||||
function finish(error) {
|
||||
clearTimeout(timer); child.stdout.off("data", inspect); child.stderr.off("data", inspect); child.off("exit", exited);
|
||||
if (error) reject(error); else resolve();
|
||||
}
|
||||
child.stdout.on("data", inspect); child.stderr.on("data", inspect); child.once("exit", exited); inspect();
|
||||
});
|
||||
}
|
||||
async function instrument(target) {
|
||||
target.setDefaultTimeout(20000);
|
||||
target.on("pageerror", error => { if (!shuttingDown) errors.push(error.message); });
|
||||
target.on("requestfailed", request => { if (!shuttingDown) networkFailures.push({ url: request.url(), error: request.failure()?.errorText }); });
|
||||
target.on("console", message => { if (message.type() === "error") consoleErrors.push(message.text()); });
|
||||
await target.setRequestInterception(true);
|
||||
target.on("request", async request => {
|
||||
try {
|
||||
const url = new URL(request.url());
|
||||
if (url.protocol === "data:" || url.protocol === "about:") return await request.continue();
|
||||
if (url.origin !== origin) { blocked.push(request.url()); return await request.abort(); }
|
||||
const respond = body => request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
|
||||
if (url.pathname === "/api/models") return await respond(fixtureCatalog);
|
||||
if (url.pathname === "/api/events") return await request.respond({ status: 204 });
|
||||
// Never forward agent, answer or import requests, even if a UI bug
|
||||
// submits while dropping. Record their complete synthetic bodies.
|
||||
if (request.method() === "POST" && (/^\/api\/(chat|answer)(\/|$)/.test(url.pathname) || /\/(answer|import|turn|retry)(\/|$)/.test(url.pathname))) {
|
||||
posts.push({ pathname: url.pathname, body: JSON.parse(request.postData() || "{}") });
|
||||
return await request.respond({ status: 503, contentType: "application/json", body: JSON.stringify({ code: "models_not_configured", missing: ["strong"] }) });
|
||||
}
|
||||
if (/^\/api\/chats\/[^/]+\/stream$/.test(url.pathname)) return await request.respond({ status: 204 });
|
||||
return await request.continue();
|
||||
} catch (error) {
|
||||
if (!shuttingDown && !/Target closed|Session closed|Invalid InterceptionId|already handled/.test(String(error))) errors.push(String(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
const inputSelector = '[data-file-path-input]';
|
||||
async function open(target, route) {
|
||||
await target.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 });
|
||||
await target.waitForSelector(inputSelector);
|
||||
await target.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"], [data-testid="setup-cta-keys"]'));
|
||||
await target.waitForFunction(() => document.querySelector('[data-testid="file-path-drop-zone"]'));
|
||||
}
|
||||
async function read(target, selector = inputSelector) {
|
||||
return target.$eval(selector, element => [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join(""));
|
||||
}
|
||||
async function expectValue(target, expected, selector = inputSelector) {
|
||||
await target.waitForFunction((selector, expected) => {
|
||||
const element = document.querySelector(selector);
|
||||
return element && [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("") === expected;
|
||||
}, {}, selector, expected);
|
||||
}
|
||||
async function fill(target, value, selector = inputSelector) {
|
||||
await target.$eval(selector, (element, value) => {
|
||||
element.focus(); element.textContent = value;
|
||||
const selection = document.getSelection(); selection.collapse(element, element.childNodes.length);
|
||||
element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" }));
|
||||
}, value);
|
||||
await expectValue(target, value, selector);
|
||||
}
|
||||
async function caret(target, offset, selector = inputSelector) {
|
||||
await target.$eval(selector, (element, offset) => {
|
||||
element.focus(); let rest = offset;
|
||||
for (const node of element.childNodes) {
|
||||
const source = node.nodeType === 1 ? node.getAttribute("data-file-path-source") : null;
|
||||
const length = source?.length ?? node.textContent.length;
|
||||
if (rest <= length) {
|
||||
if (source !== null) {
|
||||
const index = [...element.childNodes].indexOf(node);
|
||||
document.getSelection().collapse(element, index + (rest > 0 ? 1 : 0));
|
||||
} else document.getSelection().collapse(node, rest);
|
||||
document.dispatchEvent(new Event("selectionchange")); return;
|
||||
}
|
||||
rest -= length;
|
||||
}
|
||||
document.getSelection().collapse(element, element.childNodes.length);
|
||||
document.dispatchEvent(new Event("selectionchange"));
|
||||
}, offset);
|
||||
}
|
||||
async function shortcut(target, key, shift = false) {
|
||||
await target.keyboard.down("Meta"); if (shift) await target.keyboard.down("Shift");
|
||||
await target.keyboard.press(key);
|
||||
if (shift) await target.keyboard.up("Shift"); await target.keyboard.up("Meta");
|
||||
}
|
||||
async function clipboardCommand(target, command, key, modifiers = 4) {
|
||||
// Headless Chromium does not run the macOS menu accelerator for Meta+C/V.
|
||||
// CDP's native edit command triggers the real browser clipboard event.
|
||||
const session = await target.createCDPSession();
|
||||
try { await session.send("Input.dispatchKeyEvent", { type: "rawKeyDown", key: key ?? (command === "copy" ? "c" : "v"), modifiers, commands: [command] }); }
|
||||
finally { await session.detach(); }
|
||||
}
|
||||
async function syntheticDrop(target, paths, selector = '[data-testid="file-path-drop-zone"]') {
|
||||
return target.$eval(selector, (zone, paths) => {
|
||||
const transfer = new DataTransfer();
|
||||
window.__syntheticPaths = new WeakMap();
|
||||
window.lh = { platform: "darwin", getPathForFile(file) { return window.__syntheticPaths.get(file) ?? ""; } };
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const file = new File(["never read"], `synthetic-display-${index}.pdf`, { type: "application/pdf" });
|
||||
window.__syntheticPaths.set(file, path);
|
||||
for (const method of ["text", "arrayBuffer", "stream"]) Object.defineProperty(file, method, { value() { throw new Error("File bytes must not be read"); } });
|
||||
transfer.items.add(file);
|
||||
}
|
||||
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer });
|
||||
zone.dispatchEvent(event); return event.defaultPrevented;
|
||||
}, paths);
|
||||
}
|
||||
async function blur(target) { await target.evaluate(() => { document.activeElement?.blur(); }); }
|
||||
function seedChatFixtures() {
|
||||
const usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
|
||||
const transcript = Array.from({ length: 100 }, (_, index) => `Synthetic lesson paragraph ${index}: one and one make two. These are offline fixture notes.`).join("\n\n");
|
||||
const assistant = (id, content) => ({ id, role: "assistant", api: "anthropic-messages", provider: "anthropic", model: "synthetic", timestamp: 1, stopReason: "toolUse", usage, content });
|
||||
const call = (id, name, args) => ({ type: "toolCall", id, name, arguments: args });
|
||||
const result = (id, toolCallId, toolName, details) => ({ id, role: "toolResult", toolCallId, toolName, content: [{ type: "text", text: "Synthetic tool completed." }], ...(details ? { details } : {}), isError: false, timestamp: 2 });
|
||||
const quiz = { type: "check", nodeId: "addition", question: "What is 1 + 1?", options: [{ value: "two", label: "2" }, { value: "three", label: "3" }], explanation: "Adding one to one gives two.", correctAnswer: "two" };
|
||||
const records = [
|
||||
{ id: "path_teach", messages: [assistant("m_probe", [{ type: "text", text: transcript }, call("call_probe", "probe_done", { assessment: { strands: [{ strand: "Addition", assessment: "Ready for a check." }] } })]), result("m_probe_result", "call_probe", "probe_done", { ok: true }), assistant("m_quiz", [call("call_quiz", "quiz", quiz)])] },
|
||||
{ id: "path_probe", messages: [assistant("m_probe_question", [{ type: "text", text: transcript }, call("call_probe_question", "probe", quiz)])] },
|
||||
{ id: "path_practice", kind: "practice", messages: [assistant("m_problem", [{ type: "text", text: transcript }, call("call_problem", "problem", { statement: "Compute 1 + 1. Explain your reasoning.", mode: "recall", modelSolution: "1 + 1 = 2." })]), result("m_problem_result", "call_problem", "problem")] },
|
||||
];
|
||||
for (const record of records) {
|
||||
record.messages.unshift({ id: "sent_user", role: "user", content: "Attached `/synthetic/transcript notes.pdf`", timestamp: 0 });
|
||||
fs.writeFileSync(path.join(home, "data", "chats", `${record.id}.json`), JSON.stringify({ version: 1, title: `Synthetic ${record.id}`, createdAt: "2026-10-28T00:00:00.000Z", updatedAt: "2026-10-28T00:00:00.000Z", ...record }));
|
||||
}
|
||||
}
|
||||
async function foldDock(target, barSelector) {
|
||||
// Let growth/geometry muting settle, then use a real wheel gesture so
|
||||
// stick-to-bottom records user intent instead of a programmatic clamp.
|
||||
// The explicit reveal owns a bounded 2s fold-suppression window.
|
||||
await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 2250)));
|
||||
const log = await target.$('[role="log"]'); const box = await log.boundingBox();
|
||||
await target.mouse.move(box.x + box.width / 2, box.y + 100);
|
||||
// Multiple real wheel frames let stick-to-bottom publish isAtBottom
|
||||
// before DockOverlay evaluates the next away-from-bottom scroll event.
|
||||
for (const deltaY of [-420, -420, -840]) {
|
||||
await target.mouse.wheel({ deltaY });
|
||||
await target.evaluate(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
}
|
||||
try { await target.waitForSelector(barSelector, { visible: true, timeout: 5000 }); return true; }
|
||||
catch {
|
||||
coverageFailures.push({ url: target.url(), case: barSelector, message: "Native wheel did not expose collapsed bar", scrollers: await target.evaluate(() => [...document.querySelectorAll('[role="log"], [role="log"] *')].filter(element => element.scrollHeight > element.clientHeight + 100 && getComputedStyle(element).overflowY !== "visible").map(element => ({ tag: element.tagName, className: element.className, scrollTop: element.scrollTop, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight }))) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(home, "data", "chats"), { recursive: true });
|
||||
fs.writeFileSync(path.join(home, "auth.json"), "{}\n");
|
||||
seedChatFixtures();
|
||||
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 port = await freePort(); 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"] }); children.push(server);
|
||||
const captureServer = data => { serverLogs = (serverLogs + data).slice(-28000); };
|
||||
server.stdout.on("data", captureServer); server.stderr.on("data", captureServer);
|
||||
await startup(server, () => serverLogs, /Ready in/);
|
||||
if (!process.argv.includes("--desktop-only")) {
|
||||
const chrome = await puppeteer.launch({ executablePath: process.env.LH_TEST_CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", headless: true, userDataDir: path.join(home, "chrome-profile"), env, args: ["--disable-background-networking", "--disable-sync", "--no-first-run", "--disable-default-apps"] }); browsers.push(chrome);
|
||||
page = await chrome.newPage(); await page.setViewport({ width: 1280, height: 900 }); await instrument(page);
|
||||
await chrome.defaultBrowserContext().overridePermissions(origin, ["clipboard-read", "clipboard-write", "clipboard-sanitized-write"]);
|
||||
const token = "`/synthetic/course notes.pdf`";
|
||||
for (const route of ["/", "/chat/new"]) {
|
||||
await open(page, route);
|
||||
// Sidebar drops prevent browser navigation but must not stage paths or
|
||||
// activate the right-content attachment indicator.
|
||||
assert.equal(await page.$eval("aside", sidebar => {
|
||||
const transfer = new DataTransfer(); transfer.items.add(new File(["not uploaded"], "sidebar.pdf"));
|
||||
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }); sidebar.dispatchEvent(event); return event.defaultPrevented;
|
||||
}), true);
|
||||
assert.equal(await page.$('[data-testid="file-path-drop-indicator"]'), null);
|
||||
await expectValue(page, "");
|
||||
// Browser mode has no bridge: prevent file navigation with a clear notice.
|
||||
const prevented = await page.$eval('[data-testid="file-path-drop-zone"]', zone => {
|
||||
const transfer = new DataTransfer(); transfer.items.add(new File(["not uploaded"], "notes.pdf"));
|
||||
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }); zone.dispatchEvent(event); return event.defaultPrevented;
|
||||
});
|
||||
assert.equal(prevented, true);
|
||||
await page.waitForFunction(() => document.querySelector('[role="status"]')?.textContent.includes("desktop app"));
|
||||
await fill(page, "read "); await page.keyboard.type(token + " now");
|
||||
await expectValue(page, `read ${token} now`);
|
||||
assert.equal(await page.$$eval('[data-file-path-input] [role="img"]', elements => elements.length), 1);
|
||||
// Native browser arrows cross the atomic host without entering its label.
|
||||
await caret(page, 5); await page.keyboard.press("ArrowRight");
|
||||
assert.equal(await page.evaluate(() => !!document.getSelection().anchorNode?.parentElement?.closest('[role="img"]')), false);
|
||||
await page.keyboard.press("Backspace"); await expectValue(page, "read now");
|
||||
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
|
||||
await shortcut(page, "KeyZ", true); await expectValue(page, "read now");
|
||||
await shortcut(page, "KeyZ"); await caret(page, 5); await page.keyboard.press("Delete"); await expectValue(page, "read now");
|
||||
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
|
||||
// Select the actual rendered label; copy must emit the full source token.
|
||||
await page.$eval('[data-file-path-input] [role="img"] span', label => {
|
||||
const range = document.createRange(); range.selectNodeContents(label); const selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); document.dispatchEvent(new Event("selectionchange"));
|
||||
});
|
||||
await clipboardCommand(page, "copy");
|
||||
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), token);
|
||||
await clipboardCommand(page, "cut", "Delete", 8);
|
||||
await expectValue(page, "read now");
|
||||
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), token, "Shift+Delete is native cut, not editor-only deletion");
|
||||
await shortcut(page, "KeyZ"); await expectValue(page, `read ${token} now`);
|
||||
// Clipboard image-only input must not erase the current selected path.
|
||||
await page.$eval('[data-file-path-input]', editor => {
|
||||
const host = editor.querySelector('[data-file-path-source]'); const range = document.createRange(); range.selectNode(host); const selection = document.getSelection(); selection.removeAllRanges(); selection.addRange(range); document.dispatchEvent(new Event("selectionchange"));
|
||||
const data = new DataTransfer(); data.items.add(new File(["image fixture"], "image.png", { type: "image/png" }));
|
||||
editor.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: data }));
|
||||
});
|
||||
await expectValue(page, `read ${token} now`);
|
||||
// Pasting HTML-bearing clipboard content is plain text, at the range.
|
||||
await page.evaluate(() => navigator.clipboard.writeText('<img src=x onerror="alert(1)">\n中'));
|
||||
await clipboardCommand(page, "paste");
|
||||
await expectValue(page, 'read <img src=x onerror="alert(1)">\n中 now');
|
||||
assert.equal(await page.$('[data-file-path-input] img'), null);
|
||||
await fill(page, "readthis"); await caret(page, 4);
|
||||
assert.equal(await syntheticDrop(page, ["/synthetic/one.pdf", "~/two`final.pdf"]), true);
|
||||
await expectValue(page, "read `/synthetic/one.pdf` ``~/two`final.pdf`` this");
|
||||
assert.equal(posts.length, 0, "typing/paste/drop must never submit");
|
||||
await fill(page, "one visible line"); await caret(page, "one visible line".length);
|
||||
const beforeNewline = await page.$eval(inputSelector, element => element.getBoundingClientRect().height);
|
||||
await page.keyboard.down("Shift"); await page.keyboard.press("Enter"); await page.keyboard.up("Shift");
|
||||
await expectValue(page, "one visible line\n");
|
||||
const afterNewline = await page.$eval(inputSelector, element => {
|
||||
const range = document.getSelection().getRangeAt(0); const rect = range.getBoundingClientRect();
|
||||
return { height: element.getBoundingClientRect().height, caret: { top: rect.top, height: rect.height }, html: element.innerHTML };
|
||||
});
|
||||
// Record this independently so a visual editor regression does not hide
|
||||
// the higher-priority actual Electron/target-routing evidence below.
|
||||
try { assert.ok(afterNewline.height > beforeNewline, `Trailing newline must occupy a visible blank line: ${JSON.stringify({ beforeNewline, afterNewline })}`); }
|
||||
catch (error) { visualFailures.push({ route, message: error.message }); }
|
||||
await page.keyboard.type("second line");
|
||||
const afterTyping = await read(page);
|
||||
try { assert.equal(afterTyping, "one visible line\nsecond line", "Typing after Shift+Enter must stay after the newline"); }
|
||||
catch (error) { visualFailures.push({ route, message: error.message }); }
|
||||
assert.equal(posts.length, 0, "Shift+Enter is a newline, never submit");
|
||||
await fill(page, "composer fallback"); await blur(page);
|
||||
await syntheticDrop(page, ["/synthetic/default.pdf"]);
|
||||
await expectValue(page, "composer fallback `/synthetic/default.pdf`");
|
||||
// Small viewport: wrapped multiline content stays scrollable inside its editor.
|
||||
await page.setViewport({ width: 900, height: 520 });
|
||||
await fill(page, (`${token} a long synthetic line wrapping at narrow widths.\n`).repeat(24));
|
||||
const sizing = await page.$eval(inputSelector, element => ({ height: element.clientHeight, scrollHeight: element.scrollHeight, overflowY: getComputedStyle(element).overflowY, bodyWidth: document.body.scrollWidth, viewport: innerWidth }));
|
||||
assert.ok(sizing.scrollHeight > sizing.height && sizing.height > 0, JSON.stringify(sizing));
|
||||
assert.ok(["auto", "scroll"].includes(sizing.overflowY));
|
||||
assert.ok(sizing.bodyWidth <= sizing.viewport + 1, JSON.stringify(sizing));
|
||||
const screenshot = path.join(home, `chrome-${route === "/" ? "home" : "new"}.png`); await page.screenshot({ path: screenshot }); screenshots.push({ surface: route, bytes: fs.statSync(screenshot).size, sizing });
|
||||
await page.setViewport({ width: 1280, height: 900 });
|
||||
checks.push(`${route}: native typing/chips/arrows/deletion/undo-redo/full-source clipboard/plaintext paste/caret drop/newlines/wrap-scroll`);
|
||||
}
|
||||
// Only an explicit Enter may initiate a send; agent endpoint is refused.
|
||||
await fill(page, "explicit synthetic send `/synthetic/one.pdf`");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname));
|
||||
await new Promise((resolve, reject) => {
|
||||
const deadline = Date.now() + 15000;
|
||||
const inspect = () => { if (posts.length) resolve(); else if (Date.now() > deadline) reject(new Error("Explicit Enter did not send")); else setTimeout(inspect, 50); }; inspect();
|
||||
});
|
||||
assert.equal(posts.length, 1); assert.equal(posts[0].body.input.text, "explicit synthetic send `/synthetic/one.pdf`");
|
||||
checks.push("Explicit Enter submitted exactly one full-source prompt; endpoint intercepted/refused");
|
||||
|
||||
// These raw JSON fixtures are synthetic native records, never store imports.
|
||||
const reading = '[data-testid="reading-thinking-field"] [data-file-path-input]';
|
||||
const question = '[data-testid="thinking-field"] [data-file-path-input]';
|
||||
const practice = '[data-testid="practice-thinking-field"] [data-file-path-input]';
|
||||
await open(page, "/chat/path_teach");
|
||||
await page.waitForSelector('[data-testid="teach-check-reveal"]', { visible: true });
|
||||
assert.equal(await page.$eval('[data-testid="user-message"] [role="img"]', chip => chip.title), "/synthetic/transcript notes.pdf");
|
||||
assert.equal(await page.$eval('[data-testid="user-message"] [role="img"]', chip => chip.textContent), "transcript notes.pdf");
|
||||
checks.push("Seeded sent learner message renders a filename chip with full-path title; raw record retains quoted full path");
|
||||
await blur(page); await syntheticDrop(page, ["/synthetic/reading.pdf"]);
|
||||
await expectValue(page, "`/synthetic/reading.pdf`", reading);
|
||||
assert.equal(await read(page, question), "");
|
||||
checks.push("Unrevealed teaching check: unfocused drop lands in reading-thinking via dictation registry");
|
||||
await page.locator('[data-testid="teach-check-reveal"]').click();
|
||||
await page.waitForSelector(question, { visible: true });
|
||||
await fill(page, "AB", reading); await caret(page, 1, reading);
|
||||
await syntheticDrop(page, ["/synthetic/focused-reading.pdf"]);
|
||||
await expectValue(page, "A `/synthetic/focused-reading.pdf` B", reading);
|
||||
assert.equal(await read(page, question), "", "focused reading field overrides opened question default");
|
||||
await blur(page); await syntheticDrop(page, ["/synthetic/question.pdf"]);
|
||||
await expectValue(page, "`/synthetic/question.pdf`", question);
|
||||
checks.push("Opened teaching check: current reading focus wins; otherwise default routes to question-thinking");
|
||||
if (await foldDock(page, '[data-testid="question-dock-bar"]')) {
|
||||
await blur(page); await syntheticDrop(page, ["/synthetic/collapsed-question.pdf"]);
|
||||
await expectValue(page, "`/synthetic/question.pdf` `/synthetic/collapsed-question.pdf`", question);
|
||||
await page.locator('[data-testid="question-dock-bar"]').click();
|
||||
await page.waitForSelector(question, { visible: true });
|
||||
assert.equal(await read(page, question), "`/synthetic/question.pdf` `/synthetic/collapsed-question.pdf`");
|
||||
checks.push("Collapsed revealed teaching dock keeps question-thinking claim/state through expansion");
|
||||
}
|
||||
await open(page, "/chat/path_probe"); await page.waitForSelector(question, { visible: true });
|
||||
await blur(page); await syntheticDrop(page, ["/synthetic/probe.pdf"]);
|
||||
await expectValue(page, "`/synthetic/probe.pdf`", question);
|
||||
checks.push("Probe-phase pending question default routes to question-thinking");
|
||||
await open(page, "/chat/path_practice"); await page.waitForSelector(practice, { visible: true });
|
||||
await blur(page); await syntheticDrop(page, ["/synthetic/practice.pdf"]);
|
||||
await expectValue(page, "`/synthetic/practice.pdf`", practice);
|
||||
checks.push("Expanded practice thinking is the unfocused drop default");
|
||||
if (await foldDock(page, '[data-testid="practice-dock-bar"]')) {
|
||||
await blur(page); await syntheticDrop(page, ["/synthetic/collapsed-practice.pdf"]);
|
||||
// Practice keeps the owning hook/state mounted, but hides its field
|
||||
// by returning the bar. Inspect the controlled text after expansion.
|
||||
assert.equal(await page.$('[role="status"]'), null, "successful attachment has no confirmation bubble");
|
||||
await page.locator('[data-testid="practice-dock-bar"]').click();
|
||||
await page.waitForSelector(practice, { visible: true });
|
||||
await expectValue(page, "`/synthetic/practice.pdf` `/synthetic/collapsed-practice.pdf`", practice);
|
||||
checks.push("Collapsed practice thinking keeps its claim/state through expansion without submission");
|
||||
}
|
||||
assert.equal(posts.length, 1, "all claim/drop cases stage text only");
|
||||
|
||||
}
|
||||
// Actual Electron main + actual sandboxed preload. Temporary launcher sets
|
||||
// userData BEFORE production main appends its -dev suffix or takes a lock.
|
||||
const debugPort = await freePort();
|
||||
const launcher = path.join(home, "electron-launcher.cjs");
|
||||
fs.mkdirSync(path.join(home, "desktop-profile"), { recursive: true });
|
||||
fs.mkdirSync(path.join(home, "desktop-profile-dev"), { recursive: true });
|
||||
fs.writeFileSync(launcher, `const {app,session}=require('electron');\napp.setPath('home',${JSON.stringify(home)});\napp.setPath('userData',${JSON.stringify(path.join(home, "desktop-profile"))});\napp.commandLine.appendSwitch('remote-debugging-port',${JSON.stringify(String(debugPort))});\napp.commandLine.appendSwitch('disable-background-networking');\napp.commandLine.appendSwitch('disable-sync');\napp.whenReady().then(()=>session.defaultSession.webRequest.onBeforeRequest((details,cb)=>{let u;try{u=new URL(details.url)}catch{return cb({cancel:true})}cb({cancel:!(${JSON.stringify(origin)}===u.origin||(u.protocol==='ws:'&&u.host===${JSON.stringify(new URL(origin).host)})||['about:','data:','devtools:'].includes(u.protocol))});}));\nrequire(${JSON.stringify(path.join(root, "electron", "main.cjs"))});\nconsole.log('LH_FILE_PATH_PROFILE '+JSON.stringify({home:app.getPath('home'),userData:app.getPath('userData')}));\n`);
|
||||
const electron = spawn(require("electron"), [launcher], { cwd: root, env: { ...env, LH_DEV_URL: origin }, stdio: ["ignore", "pipe", "pipe"] }); children.push(electron);
|
||||
const captureElectron = data => { electronLogs = (electronLogs + data).slice(-24000); }; electron.stdout.on("data", captureElectron); electron.stderr.on("data", captureElectron);
|
||||
await startup(electron, () => electronLogs, /DevTools listening on (ws:\/\/[^\s]+)/, 45000);
|
||||
const profile = JSON.parse(electronLogs.match(/LH_FILE_PATH_PROFILE ([^\r\n]+)/)[1]);
|
||||
assert.equal(profile.home, home); assert.equal(profile.userData, path.join(home, "desktop-profile-dev"));
|
||||
const desktopLaunch = { executable: require("electron"), args: [launcher], LH_DEV_URL: origin, HOME: home, userDataBeforeMain: path.join(home, "desktop-profile"), actualProfile: profile, cleanup: "all temporary profiles/data/fixtures removed on exit" };
|
||||
const endpoint = electronLogs.match(/DevTools listening on (ws:\/\/[^\s]+)/)[1];
|
||||
const desktop = await puppeteer.connect({ browserWSEndpoint: endpoint }); browsers.push(desktop);
|
||||
const desktopPage = await desktop.waitForTarget(target => target.type() === "page").then(target => target.page());
|
||||
await instrument(desktopPage); page = desktopPage;
|
||||
await open(desktopPage, "/chat/new");
|
||||
assert.equal(await desktopPage.evaluate(() => typeof window.lh?.getPathForFile), "function");
|
||||
const fixturePath = path.join(home, "actual OS-backed α `notes`.pdf"); fs.writeFileSync(fixturePath, "%PDF-1.7\nsynthetic offline fixture\n");
|
||||
await desktopPage.evaluate(() => { const input = document.createElement("input"); input.type = "file"; input.id = "os-backed-fixture"; document.body.append(input); });
|
||||
await (await desktopPage.$("#os-backed-fixture")).uploadFile(fixturePath);
|
||||
const resolved = await desktopPage.evaluate(() => {
|
||||
const input = document.querySelector("#os-backed-fixture");
|
||||
return { count: input.files.length, actual: window.lh.getPathForFile(input.files[0]), virtual: window.lh.getPathForFile(new File(["virtual"], "/not/a/backing/path.pdf")) };
|
||||
});
|
||||
assert.equal(resolved.count, 1); assert.equal(resolved.actual, fixturePath); assert.equal(resolved.virtual, "");
|
||||
await fill(desktopPage, "readthis"); await caret(desktopPage, 4);
|
||||
const beforeDesktopDrop = posts.length;
|
||||
assert.equal(await desktopPage.evaluate(() => {
|
||||
const file = document.querySelector("#os-backed-fixture").files[0];
|
||||
for (const method of ["text", "arrayBuffer", "stream"]) Object.defineProperty(file, method, { value() { throw new Error("No file content read"); } });
|
||||
const transfer = new DataTransfer(); transfer.items.add(file);
|
||||
const event = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer });
|
||||
document.querySelector('[data-testid="file-path-drop-zone"]').dispatchEvent(event); return event.defaultPrevented;
|
||||
}), true);
|
||||
const expectedQuoted = "``" + fixturePath + "``";
|
||||
await expectValue(desktopPage, `read ${expectedQuoted} this`);
|
||||
assert.equal(await desktopPage.$eval('[data-file-path-input] [role="img"]', chip => chip.title), fixturePath);
|
||||
assert.equal(posts.length, beforeDesktopDrop, "actual OS-backed desktop drop must stage only");
|
||||
assert.equal(await desktopPage.$('[role="status"]'), null, "successful desktop attachment has no confirmation bubble");
|
||||
assert.ok(fs.existsSync(fixturePath));
|
||||
assert.deepEqual(fs.readdirSync(path.join(home, "data")).filter(name => /upload|attachment/i.test(name)), []);
|
||||
checks.push("Actual Electron production preload: Puppeteer-selected OS-backed File resolves exact Unicode/backtick full path; virtual File returns empty; real DataTransfer drop inserts at caret without submit or file reads");
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ checks, visualFailures, coverageFailures, screenshots, desktopLaunch, postsIntercepted: posts.length, blockedExternalRequests: blocked, isolation: "temporary HOME/data/auth/browser+Electron profiles; synthetic catalog; agent POSTs refused; no providers", origin }, null, 2));
|
||||
assert.deepEqual(visualFailures, [], "Visible trailing-newline/caret projection must pass");
|
||||
assert.deepEqual(coverageFailures, [], "Required collapsed target coverage must run");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(JSON.stringify({ checks, visualFailures, coverageFailures, errors, consoleErrors, networkFailures: networkFailures.slice(-30), posts, origin, url: page?.url(), dom: await page?.evaluate(() => ({ inputs: [...document.querySelectorAll('[data-file-path-input]')].map(element => ({ placeholder: element.getAttribute("data-placeholder"), text: element.textContent, html: element.innerHTML.slice(0, 1200) })), text: document.body.innerText.slice(-2600) })).catch(() => null), serverLogs, electronLogs }, null, 2));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
shuttingDown = true;
|
||||
for (const browser of browsers.reverse()) { try { await browser.close(); } catch {} }
|
||||
for (const child of children.reverse()) await stop(child);
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* OFFLINE image runtime gate: exercise the shipped Photon/WASM resize core and
|
||||
* actual worker, with file modules confined to the standalone payload. No app,
|
||||
* provider, canvas, or network needed; a tiny synthetic PNG is generated here.
|
||||
*
|
||||
* node scripts/smoke-image-runtime.mjs [--standalone <dir>] [--runtime <Node/Electron>]
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { formatProbeError, probeEnv } from "./smoke-demo-runtime.mjs";
|
||||
|
||||
export function verifyImageRuntime(standalone, runtime = process.execPath) {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-image-runtime-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 });
|
||||
const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone)], {
|
||||
cwd: home,
|
||||
env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" },
|
||||
encoding: "utf8", timeout: 30_000, killSignal: "SIGKILL", maxBuffer: 128 * 1024,
|
||||
});
|
||||
if (result.status !== 0 || result.error) {
|
||||
throw new Error(`image runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`);
|
||||
}
|
||||
assert.match(result.stdout, /Photon in-process and worker resizing passed/, "probe exited without success marker");
|
||||
process.stdout.write(result.stdout);
|
||||
} finally { fs.rmSync(home, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
// Self-contained so the eval worker can install the SAME guards using only
|
||||
// builtins, before importing any payload module. Module hooks do not otherwise
|
||||
// propagate from the dedicated probe process into Worker threads.
|
||||
function installGuards(require, standalone) {
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { fileURLToPath } = require("node:url");
|
||||
const { registerHooks, syncBuiltinESMExports } = require("node:module");
|
||||
const root = fs.realpathSync(standalone);
|
||||
const inside = (file) => {
|
||||
const real = fs.realpathSync(file);
|
||||
const relative = path.relative(root, real);
|
||||
assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative),
|
||||
`dependency escaped standalone payload: ${file}`);
|
||||
return real;
|
||||
};
|
||||
const photonRoot = path.join(root, "node_modules/@silvia-odwyer/photon-node");
|
||||
const manifest = JSON.parse(fs.readFileSync(inside(path.join(photonRoot, "package.json")), "utf8"));
|
||||
assert.equal(manifest.version, "0.3.4", "unexpected Photon engine version");
|
||||
const photonEntry = inside(path.join(photonRoot, "photon_rs.js"));
|
||||
const photonWasm = inside(path.join(photonRoot, "photon_rs_bg.wasm"));
|
||||
assert.ok(fs.statSync(photonWasm).size > 0, "empty Photon WASM asset");
|
||||
let attempts = 0;
|
||||
const deny = () => { attempts++; throw new Error("OFFLINE image gate: network forbidden"); };
|
||||
globalThis.fetch = deny;
|
||||
globalThis.WebSocket = class { constructor() { deny(); } };
|
||||
for (const [name, methods] of [
|
||||
["http", ["request", "get"]], ["https", ["request", "get"]],
|
||||
["net", ["connect", "createConnection"]], ["tls", ["connect"]],
|
||||
["dgram", ["createSocket"]],
|
||||
["dns", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]],
|
||||
["dns/promises", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]],
|
||||
]) {
|
||||
const module = require(`node:${name}`);
|
||||
for (const method of methods) module[method] = deny;
|
||||
}
|
||||
require("node:net").Socket.prototype.connect = deny;
|
||||
const loaded = new Set();
|
||||
const wasmReads = new Set();
|
||||
const originalRead = fs.readFileSync;
|
||||
fs.readFileSync = function (file, ...args) {
|
||||
const filename = file instanceof URL ? fileURLToPath(file) : file;
|
||||
const wasmFile = typeof filename === "string" && filename.endsWith(".wasm") ? inside(filename) : null;
|
||||
const bytes = originalRead.call(this, file, ...args);
|
||||
if (wasmFile) wasmReads.add(wasmFile);
|
||||
return bytes;
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
const hooks = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
const result = nextResolve(specifier, context);
|
||||
if (result.url.startsWith("file:")) loaded.add(inside(fileURLToPath(result.url)));
|
||||
else assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
return {
|
||||
report() {
|
||||
assert.equal(attempts, 0, "image runtime attempted network access");
|
||||
assert.ok(loaded.has(photonEntry), "Photon JS was not loaded from the payload");
|
||||
assert.ok(wasmReads.has(photonWasm), "Photon WASM was not loaded from the payload");
|
||||
return { modules: loaded.size, wasmFiles: [...wasmReads], networkAttempts: attempts };
|
||||
},
|
||||
cleanup() {
|
||||
hooks.deregister();
|
||||
fs.readFileSync = originalRead;
|
||||
syncBuiltinESMExports();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Pure Node 2×2 RGBA PNG, with valid PNG CRCs and zlib image data. */
|
||||
function fixturePng() {
|
||||
const chunk = (type, data) => {
|
||||
const buffer = Buffer.alloc(data.length + 12);
|
||||
buffer.writeUInt32BE(data.length);
|
||||
buffer.write(type, 4, "ascii");
|
||||
data.copy(buffer, 8);
|
||||
let crc = ~0;
|
||||
for (const byte of buffer.subarray(4, -4)) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
||||
}
|
||||
buffer.writeUInt32BE(~crc >>> 0, buffer.length - 4);
|
||||
return buffer;
|
||||
};
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(2, 0);
|
||||
header.writeUInt32BE(2, 4);
|
||||
header[8] = 8; // bit depth
|
||||
header[9] = 6; // RGBA
|
||||
const pixels = Buffer.from([
|
||||
0, 255, 0, 0, 255, 0, 255, 0, 255,
|
||||
0, 0, 0, 255, 255, 255, 255, 255, 255,
|
||||
]);
|
||||
return Buffer.concat([Buffer.from("89504e470d0a1a0a", "hex"), chunk("IHDR", header), chunk("IDAT", deflateSync(pixels)), chunk("IEND", Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
function assertResized(result) {
|
||||
assert.ok(result, "resize returned null (missing/broken Photon must not silently pass)");
|
||||
assert.equal(result.originalWidth, 2);
|
||||
assert.equal(result.originalHeight, 2);
|
||||
assert.equal(result.width, 1);
|
||||
assert.equal(result.height, 1);
|
||||
assert.equal(result.wasResized, true);
|
||||
assert.equal(result.mimeType, "image/png");
|
||||
const png = Buffer.from(result.data, "base64");
|
||||
assert.equal(png.subarray(0, 8).toString("hex"), "89504e470d0a1a0a");
|
||||
assert.equal(png.readUInt32BE(16), 1);
|
||||
assert.equal(png.readUInt32BE(20), 1);
|
||||
}
|
||||
|
||||
async function probeWorker(root, inputBytes, options, responseType) {
|
||||
const readyType = "lh:image-runtime-ready";
|
||||
const worker = new Worker(`
|
||||
const { parentPort, workerData } = require("node:worker_threads");
|
||||
const guards = (${installGuards.toString()})(require, workerData.root);
|
||||
const post = parentPort.postMessage.bind(parentPort);
|
||||
parentPort.postMessage = (message, ...args) => {
|
||||
if (message?.type === workerData.responseType) {
|
||||
post({ ...message, runtimeProof: guards.report() }, ...args);
|
||||
} else post(message, ...args);
|
||||
};
|
||||
void import(workerData.entry).then(() => post({ type: workerData.readyType }));
|
||||
`, {
|
||||
eval: true,
|
||||
workerData: { root, responseType, readyType, entry: pathToFileURL(path.join(root, "src/agent/image-processing/resize-worker.mjs")).href },
|
||||
});
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("image resize worker timed out")), 10_000);
|
||||
const fail = (error) => { clearTimeout(timer); reject(error); };
|
||||
let ready = false;
|
||||
worker.once("error", fail);
|
||||
worker.once("exit", (code) => fail(new Error(`image resize worker exited before response: ${code}`)));
|
||||
worker.on("message", (message) => {
|
||||
try {
|
||||
if (message?.type === readyType) {
|
||||
assert.equal(ready, false, "duplicate worker READY");
|
||||
ready = true;
|
||||
worker.postMessage({ inputBytes, mimeType: "image/png", options });
|
||||
} else if (message?.type === responseType) {
|
||||
assert.equal(ready, true, "worker replied before its import completed");
|
||||
assert.equal(message.error, undefined, `worker resize failed: ${message.error}`);
|
||||
assertResized(message.result);
|
||||
assert.ok(message.runtimeProof?.modules > 0, "missing worker module-confinement proof");
|
||||
assert.equal(message.runtimeProof.networkAttempts, 0);
|
||||
assert.equal(message.runtimeProof.wasmFiles.length, 1);
|
||||
clearTimeout(timer);
|
||||
resolve(message.runtimeProof);
|
||||
}
|
||||
} catch (error) { fail(error); }
|
||||
});
|
||||
});
|
||||
} finally { await worker.terminate(); }
|
||||
}
|
||||
|
||||
async function probe(standalone) {
|
||||
const root = fs.realpathSync(standalone);
|
||||
const guards = installGuards(createRequire(import.meta.url), root);
|
||||
try {
|
||||
const entry = pathToFileURL(path.join(root, "src/agent/image-processing/resize-core.mjs"));
|
||||
const { resizeImageInProcess, IMAGE_RESIZE_WORKER_RESPONSE_TYPE } = await import(entry.href);
|
||||
assert.equal(typeof resizeImageInProcess, "function");
|
||||
assert.equal(IMAGE_RESIZE_WORKER_RESPONSE_TYPE, "pi:image-resize-response");
|
||||
const input = fixturePng();
|
||||
const options = { maxWidth: 1, maxHeight: 1, maxBytes: 1024 * 1024 };
|
||||
assertResized(await resizeImageInProcess(input, "image/png", options));
|
||||
const inProcess = guards.report();
|
||||
console.log(`[smoke-image-runtime] shipped resize core: PNG 2×2 → 1×1; ${inProcess.modules} modules and Photon WASM confined to ${root}`);
|
||||
const worker = await probeWorker(root, input, options, IMAGE_RESIZE_WORKER_RESPONSE_TYPE);
|
||||
guards.report();
|
||||
console.log(`[smoke-image-runtime] shipped resize worker: READY then tagged PNG 2×2 → 1×1; ${worker.modules} modules and Photon WASM confined to ${root}`);
|
||||
console.log("[smoke-image-runtime] Photon in-process and worker resizing passed; zero network attempts");
|
||||
} finally { guards.cleanup(); }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === "--probe") return probe(args[1]);
|
||||
const value = (name, fallback) => {
|
||||
const index = args.indexOf(name);
|
||||
if (index < 0) return fallback;
|
||||
assert.ok(args[index + 1] && !args[index + 1].startsWith("--"), `${name} requires a path`);
|
||||
return path.resolve(args[index + 1]);
|
||||
};
|
||||
verifyImageRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath));
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => { console.error(`[smoke-image-runtime] FAIL: ${formatProbeError(error)}`); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/** 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 === "/" ? "[data-file-path-input]" : '[data-testid="composer"] [data-file-path-input]');
|
||||
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 input = document.querySelector('[data-testid="composer"] [data-file-path-input]') || document.querySelector("[data-file-path-input]");
|
||||
input.textContent = value;
|
||||
input.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 => {
|
||||
const input = document.querySelector('[data-testid="composer"] [data-file-path-input]');
|
||||
return input && [...input.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("") === 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("[data-file-path-input]", element => [...element.childNodes].map(node => node.nodeType === 1 ? node.getAttribute("data-file-path-source") ?? node.textContent : node.textContent).join("")), 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(() => ({ inputs: [...document.querySelectorAll('[data-file-path-input]')].map(e => ({ length: [...e.childNodes].map(n => n.nodeType === 1 ? n.getAttribute('data-file-path-source') ?? n.textContent : n.textContent).join('').length, placeholder: e.getAttribute('data-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 });
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* OFFLINE native Pi Google initialization gate (no generation or provider calls).
|
||||
* Unlike /api/models, this traverses Pi's lazy adapter and constructs GoogleGenAI.
|
||||
* The real onPayload hook throws a unique sentinel BEFORE generateContentStream.
|
||||
* OFFLINE native Pi Google/Bedrock initialization gate (no generation or provider calls).
|
||||
* Unlike /api/models, this traverses Pi's lazy adapters and constructs real SDK clients.
|
||||
* The real onPayload hook throws a unique sentinel BEFORE SDK request dispatch.
|
||||
* All file modules must resolve inside the payload, including nested dependencies.
|
||||
*
|
||||
* node scripts/smoke-provider-runtime.mjs [--standalone <dir>] [--runtime <Node/Electron>]
|
||||
@@ -80,18 +80,31 @@ async function probe(standalone) {
|
||||
// factories, never the checkout's module or a direct/fake Google adapter.
|
||||
const entry = pathToFileURL(path.join(root, "node_modules/@earendil-works/pi-ai/dist/index.js"));
|
||||
const { createModels } = await import(entry.href);
|
||||
// Keep every native chat implementation evaluable in the standalone
|
||||
// payload even though the custom-provider UI intentionally exposes one API.
|
||||
for (const api of ["anthropic-messages", "openai-completions", "openai-responses", "openai-codex-responses", "azure-openai-responses", "google-generative-ai", "google-vertex", "bedrock-converse-stream", "mistral-conversations", "pi-messages"]) {
|
||||
const implementation = await import(new URL(`api/${api}.js`, entry).href);
|
||||
assert.equal(typeof implementation.streamSimple, "function", `${api}: native implementation missing`);
|
||||
}
|
||||
noNetwork();
|
||||
console.log("[smoke-provider-runtime] all 10 native chat implementations evaluated offline");
|
||||
const models = createModels();
|
||||
for (const [name, factory] of [["google", "googleProvider"], ["opencode", "opencodeProvider"], ["opencode-go", "opencodeGoProvider"]]) {
|
||||
for (const [name, factory, api, label] of [
|
||||
["google", "googleProvider", "google-generative-ai", "Google"],
|
||||
["opencode", "opencodeProvider", "google-generative-ai", "Google"],
|
||||
["opencode-go", "opencodeGoProvider", "google-generative-ai", "Google"],
|
||||
["amazon-bedrock", "amazonBedrockProvider", "bedrock-converse-stream", "Bedrock"],
|
||||
]) {
|
||||
const provider = (await import(new URL(`providers/${name}.js`, entry).href))[factory]();
|
||||
models.setProvider(provider);
|
||||
const model = provider.getModels().find((candidate) => candidate.api === "google-generative-ai");
|
||||
assert.ok(model, `${name}: missing Google-backed catalog model`);
|
||||
const model = provider.getModels().find((candidate) => candidate.api === api);
|
||||
assert.ok(model, `${name}: missing ${api} catalog model`);
|
||||
const sentinel = `LH_OFFLINE_ON_PAYLOAD_${randomUUID()}`;
|
||||
let reached = 0;
|
||||
const result = await models.streamSimple(model, { messages: [{ role: "user", content: "Offline initialization only", timestamp: 0 }] }, {
|
||||
apiKey: "dummy-offline-provider-gate-not-a-key",
|
||||
onPayload(payload, selected) {
|
||||
assert.equal(selected.api, "google-generative-ai");
|
||||
assert.equal(selected.api, api);
|
||||
assert.ok(payload && typeof payload === "object");
|
||||
reached++;
|
||||
throw new Error(sentinel);
|
||||
@@ -101,7 +114,7 @@ async function probe(standalone) {
|
||||
assert.equal(result.stopReason, "error");
|
||||
assert.equal(result.errorMessage, sentinel, `${name}: unexpected initialization error`);
|
||||
noNetwork();
|
||||
console.log(`[smoke-provider-runtime] ${name}: native lazy Google adapter reached onPayload; generation stopped`);
|
||||
console.log(`[smoke-provider-runtime] ${name}: native lazy ${label} adapter reached onPayload; generation stopped`);
|
||||
}
|
||||
// Exercise the public lazy OAuth surface offline. toAuth evaluates the
|
||||
// real flow module and its imports but neither logs in nor refreshes.
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs";
|
||||
import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs";
|
||||
import { verifyCompressionRuntime } from "./smoke-compression-runtime.mjs";
|
||||
import { createRequire } from "node:module";
|
||||
import * as fs from "node:fs";
|
||||
import * as http from "node:http";
|
||||
@@ -83,6 +84,8 @@ async function main() {
|
||||
verifyDemoRuntime(STANDALONE, RUNTIME);
|
||||
// /api/models only reads catalogs; initialize the real lazy SDK offline too.
|
||||
verifyProviderRuntime(STANDALONE, RUNTIME);
|
||||
// A plain GET without Accept-Encoding never exercises gzip backpressure.
|
||||
verifyCompressionRuntime(STANDALONE, RUNTIME);
|
||||
const port = await freePort();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/** Offline Next runtime gate (run after next build, with no dev server running).
|
||||
* Proves cold-start registration, cross-route owner/events and dev reloads using
|
||||
* synthetic credentials in a fresh profile. No OAuth, provider traffic or real
|
||||
* auth/data. The dev reload changes only a source mtime, restoring it afterward.
|
||||
* This is a test, not an alternative app launcher. */
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, "..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const { SCOPES } = require("../electron/chatgpt.cjs");
|
||||
const sdk = (file) => import(pathToFileURL(path.join(ROOT, "node_modules/@earendil-works/pi-ai/dist", file)));
|
||||
const { createModels } = await sdk("index.js");
|
||||
const { builtinProviders } = await sdk("providers/all.js");
|
||||
const { githubCopilotProvider } = await sdk("providers/github-copilot.js");
|
||||
const models = createModels();
|
||||
for (const provider of builtinProviders()) models.setProvider(provider);
|
||||
const model = models.getModels("openai").find((entry) => entry.api === "openai-responses");
|
||||
const copilot = githubCopilotProvider().getModels()[0];
|
||||
assert(model && copilot, "Installed SDK catalogs required.");
|
||||
|
||||
const profile = await fs.mkdtemp(path.join(os.tmpdir(), "lh-web-runtime-"));
|
||||
await fs.chmod(profile, 0o700);
|
||||
for (const name of ["home", "config", "cache", "xdg-data", "data", "pi", "claude", "codex", "tmp"]) await fs.mkdir(path.join(profile, name), { mode: 0o700 });
|
||||
await fs.writeFile(path.join(profile, "auth.json"), "{}\n", { mode: 0o600 });
|
||||
const preload = path.join(profile, "offline-fetch.mjs");
|
||||
await fs.writeFile(preload, `const original = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
|
||||
if (url.href === 'https://api.openai.com/v1/models') return Response.json({ models: [{ slug: ${JSON.stringify(model.id)}, display_name: 'Offline fixture ChatGPT', visibility: 'list' }] });
|
||||
if (['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) return original(input, init);
|
||||
throw new Error('Offline subscription gate forbids external network: ' + url.origin + url.pathname);
|
||||
};\n`, { mode: 0o600 });
|
||||
// Intentionally an allowlist, not a copy of the calling shell's environment.
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: path.join(profile, "home"),
|
||||
XDG_CONFIG_HOME: path.join(profile, "config"), XDG_CACHE_HOME: path.join(profile, "cache"), XDG_DATA_HOME: path.join(profile, "xdg-data"),
|
||||
PI_CODING_AGENT_DIR: path.join(profile, "pi"), PI_AGENT_DIR: path.join(profile, "pi"), CLAUDE_CONFIG_DIR: path.join(profile, "claude"), CODEX_HOME: path.join(profile, "codex"),
|
||||
TMPDIR: path.join(profile, "tmp"), LH_AUTH_FILE: path.join(profile, "auth.json"), LH_DATA_DIR: path.join(profile, "data"),
|
||||
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, NEXT_TELEMETRY_DISABLED: "1",
|
||||
};
|
||||
const saved = {
|
||||
version: 1, hostId: "offline-host", allowBackgroundUse: true, activeId: "offline-account",
|
||||
registrations: [{ id: "offline-account", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "offline-subject", email: "fixture@example.invalid", tokens: { accessToken: "fixture-chatgpt-access", refreshToken: "fixture-chatgpt-refresh", idToken: "fixture-id-token", scopes: SCOPES, expiresAt: Date.now() + 3600000 } }],
|
||||
};
|
||||
function timeout(promise, milliseconds, message) {
|
||||
let timer;
|
||||
return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(message)), milliseconds); })]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
async function freePort() {
|
||||
const socket = createServer();
|
||||
await new Promise((resolve, reject) => { socket.once("error", reject); socket.listen(0, "127.0.0.1", resolve); });
|
||||
const { port } = socket.address(); await new Promise((resolve) => socket.close(resolve)); return port;
|
||||
}
|
||||
async function run(mode) {
|
||||
await fs.writeFile(path.join(profile, "chatgpt-credentials.json"), JSON.stringify(saved), { mode: 0o600 });
|
||||
for (const id of ["github-copilot", "xai"]) await fs.writeFile(path.join(profile, `${id}-credentials.json`), JSON.stringify({
|
||||
version: 1, allowBackgroundUse: true, connectionId: "00000000-0000-4000-8000-000000000001",
|
||||
credential: { type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires: Date.now() + 3600000, ...(id === "github-copilot" ? { availableModelIds: [copilot.id] } : {}) },
|
||||
}), { mode: 0o600 });
|
||||
const port = await freePort(), origin = `http://localhost:${port}`;
|
||||
let logs = "", exited = false;
|
||||
const child = spawn(process.execPath, [path.join(ROOT, "node_modules/next/dist/bin/next"), mode, "-p", String(port)], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
const exit = new Promise((resolve) => child.once("exit", () => { exited = true; resolve(); }));
|
||||
try {
|
||||
await timeout(new Promise((resolve, reject) => {
|
||||
const receive = (data) => { logs = (logs + data).slice(-16000); if (logs.includes("Ready in")) resolve(); };
|
||||
child.stdout.on("data", receive); child.stderr.on("data", receive);
|
||||
child.once("error", reject); child.once("exit", (code) => reject(new Error(`Next ${mode} exited ${code}.`)));
|
||||
}), 60000, `Next ${mode} readiness timeout.`);
|
||||
const request = (url, command) => fetch(origin + url, {
|
||||
...(command ? { method: "POST", body: JSON.stringify(command) } : {}),
|
||||
headers: { origin, "sec-fetch-site": "same-origin", "x-learnharness-subscriptions": "1", "content-type": "application/json" },
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
// Controls have not been imported through an HTTP request yet.
|
||||
const catalog = (await (await request("/api/models")).json()).providers;
|
||||
for (const id of ["chatgpt", "github-copilot-subscription", "xai-subscription"]) assert(catalog.find((provider) => provider.id === id)?.keyPresent, `${mode}: cold ${id} unavailable.`);
|
||||
let state = await (await request("/api/subscriptions")).json();
|
||||
assert.equal(state.chatgpt.accounts[0].email, "fixture@example.invalid"); assert.equal(state.native.length, 2);
|
||||
for (const secret of ["fixture-chatgpt-access", "fixture-chatgpt-refresh", "fixture-id-token", "fixture-xai-access", "fixture-refresh"]) assert(!JSON.stringify(state).includes(secret));
|
||||
const stream = await fetch(origin + "/api/events", { signal: AbortSignal.timeout(60000) });
|
||||
const reader = stream.body.getReader();
|
||||
try {
|
||||
const first = await reader.read(); assert(first.value);
|
||||
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "consent", enabled: false })).status, 200);
|
||||
let events = new TextDecoder().decode(first.value);
|
||||
while (!events.includes("subscriptions-changed")) {
|
||||
const frame = await timeout(reader.read(), 5000, "Cross-route subscription SSE timeout.");
|
||||
assert(!frame.done, "Subscription event stream ended early."); events += new TextDecoder().decode(frame.value);
|
||||
}
|
||||
} finally { await reader.cancel(); }
|
||||
state = await (await request("/api/subscriptions")).json(); assert.equal(state.chatgpt.allowBackgroundUse, false);
|
||||
const login = await (await request("/api/subscriptions", { providerId: "chatgpt", op: "login" })).json();
|
||||
assert(login.browserAction); assert(!JSON.stringify(login).includes("auth.openai.com"));
|
||||
if (mode === "dev") {
|
||||
const file = path.join(ROOT, "src/server/web-subscriptions.ts"), times = await fs.stat(file);
|
||||
await fs.utimes(file, new Date(), new Date());
|
||||
try { assert.equal((await request("/api/models")).status, 200); assert.equal((await request("/api/subscriptions")).status, 200); }
|
||||
finally { await fs.utimes(file, times.atime, times.mtime); }
|
||||
}
|
||||
const navigate = () => fetch(origin + login.browserAction, { redirect: "manual", headers: { "sec-fetch-site": "same-origin" }, signal: AbortSignal.timeout(30000) });
|
||||
const browser = await navigate(); assert.equal(browser.status, 303, `${mode}: owner/action lost across routes or reload.`);
|
||||
const location = new URL(browser.headers.get("location")); assert.equal(location.origin, "https://auth.openai.com"); assert.equal(new URL(location.searchParams.get("redirect_uri")).hostname, "127.0.0.1");
|
||||
assert.equal((await navigate()).status, 410);
|
||||
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "cancel" })).status, 200);
|
||||
assert.equal((await fetch(origin + "/api/subscriptions", { method: "POST", headers: { origin: "https://unrelated.invalid", "content-type": "application/json", "x-learnharness-subscriptions": "1" }, body: JSON.stringify({ providerId: "chatgpt", op: "disconnect", id: "offline-account" }) })).status, 403);
|
||||
assert.equal((await request("/api/subscriptions", { providerId: "chatgpt", op: "disconnect", id: "offline-account" })).status, 200);
|
||||
assert.equal((await (await request("/api/models")).json()).providers.find((provider) => provider.id === "chatgpt").keyPresent, false);
|
||||
console.log(`[smoke-web-subscriptions] ${mode}: cold catalogs, safe status, cross-route SSE/consent/disconnect, opaque redirect/replay, CSRF${mode === "dev" ? ", owner retained across source reload" : ""} passed`);
|
||||
} catch (error) { console.error(logs); throw error; }
|
||||
finally {
|
||||
if (!exited) child.kill("SIGTERM");
|
||||
try { await timeout(exit, 5000, "Next shutdown timeout."); }
|
||||
catch { child.kill("SIGKILL"); await exit; }
|
||||
}
|
||||
}
|
||||
try { await run("start"); await run("dev"); }
|
||||
finally { await fs.rm(profile, { recursive: true, force: true }); }
|
||||
Reference in New Issue
Block a user