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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/presentation/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"smoke:task-session-observation": "vite build --ssr smoke/task-session-observation-smoke.ts --outDir node_modules/.cache/loopx-task-session-observation --emptyOutDir && node node_modules/.cache/loopx-task-session-observation/task-session-observation-smoke.js",
"smoke:conversation-history-order": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/conversation-history-order-browser-smoke.mjs",
"smoke:conversation-returns-packaged": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/conversation-return-browser-smoke.mjs",
"smoke:research-evidence-version": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/research-evidence-version-browser-smoke.mjs",
"smoke:workspace-scope-packaged": "LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/workspace-scope-browser-smoke.mjs",
"smoke:recent-completions": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --types node --outDir /tmp/loopx-recent-completions-smoke smoke/recent-completions-smoke.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-recent-completions-smoke/apps/presentation/dashboard/smoke/recent-completions-smoke.js",
"smoke:task-inspector-route": "npm run smoke:goal-work-browse && LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/task-inspector-route-smoke.mjs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {createInterface} from "node:readline";
import {createElement} from "react";
import {renderToStaticMarkup} from "react-dom/server";
import {resolveTestPython} from "../../../../scripts/test-python.mjs";
import {readAnalysisSnapshot, ResearchAnalysisPanel} from "../src/features/personal-workspace/goal-research-analysis.tsx";
import {readAnalysisSnapshot, ResearchAnalysisPanel, ResearchCaseReading, ResearchCitationPanel} from "../src/features/personal-workspace/goal-research-analysis.tsx";
import {ResearchCaseStudy} from "../src/features/personal-workspace/research-case-study.tsx";
import {fetchExploreResults} from "../src/data/chat.ts";
import {GoalResearchWorkbench} from "../src/features/personal-workspace/goal-research-workbench.tsx";
Expand All @@ -26,6 +26,20 @@ assert.ok(renderedStudy.includes('not current results'));
assert.match(renderedStudy, /候选 A<.*?>7<.*?候选 B<.*?>Not recorded<.*?候选 C<.*?>3</,
'Saved chart observations stay in author order, with numbers and the unrecorded value visible together');
for (const section of study.sections) assert.ok(renderedStudy.includes(section.title));

const casePack = {pack_id: "one", title: "First study", scope: caseFixture.analyses[0].input.scope,
case_study: study, case_study_summary: {title: study.title}, applicability: "current"};
const secondCase = {...casePack, pack_id: "two", title: "Second study"};
const renderCase = packs => renderToStaticMarkup(createElement(ResearchCaseReading, {
analysis: {zh: false, pack: casePack, snapshot: {catalog: {packs}}, location: {open: false}},
}));
assert.ok(!renderCase([casePack]).includes("<select"), "A single saved version reads directly");
const alternatives = renderCase([casePack, secondCase]);
assert.ok(alternatives.includes("<select") && alternatives.includes("Second study"), "Multiple saved versions remain selectable");
const sourceFailure = renderToStaticMarkup(createElement(ResearchCitationPanel, {
analysis: {zh: false, citationError: "Source unavailable", retryCitation() {throw Error("Rendering retried the read");}},
}));
assert.ok(sourceFailure.includes('aria-label="Retry reading source"'), "An unavailable citation can be retried in place");
const goals = [
{goalId:'parent',title:'Benchmark'},
{goalId:'child',parentGoalId:'parent',title:'Local case'},
Expand All @@ -39,6 +53,18 @@ assert.ok(navigate('parent').includes('Local case'));
assert.ok(navigate('parent').includes('Foreign case'));
assert.ok(navigate('child').includes('Benchmark'));
assert.ok(navigate('foreign').includes('Benchmark'), 'Explicit directory links retain their local parent');
const savedLocation = globalThis.window;
try {
const renderEntry = search => {
globalThis.window = {location: new URL(search, "http://127.0.0.1/chat/")};
return renderToStaticMarkup(createElement(GoalResearchWorkbench, {goalId: "child", zh: false, active: true, refreshRevision: 0}));
};
assert.ok(renderEntry("?research_goal=child&research_reading=case").includes("research-case-reader"),
"A direct case address retains its reader while the catalogue is pending");
assert.ok(!renderEntry("?research_goal=another&research_reading=case").includes("research-case-reader"),
"Another Goal's case address cannot replace this Goal's history");
} finally {globalThis.window = savedLocation;}


// Transport uses the workspace view; immutable findings still identify the native Goal.
const sourceFetch = globalThis.fetch, sourceWindow = globalThis.window;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Exercise a saved original and its newly available language-specific result
// through the packaged reader. Both snapshots and citation excerpts come from
// the native test backend; interception controls only when the result arrives.
import assert from "node:assert/strict";
import {spawn} from "node:child_process";
import {once} from "node:events";
import {mkdtemp, readFile, rm, writeFile} from "node:fs/promises";
import {tmpdir} from "node:os";
import {resolve} from "node:path";
import {createInterface} from "node:readline";
import {resolveTestPython} from "../../../../scripts/test-python.mjs";
import {launchBrowser, loadPlaywright} from "../../../../examples/dashboard-browser-smoke-support.mjs";

const repo = resolve(import.meta.dirname, "../../../..");
const root = await mkdtemp(resolve(tmpdir(), "loopx-evidence-version-"));
const assets = process.env.LOOPX_RESEARCH_EVIDENCE_ASSETS ?? resolve(repo, "loopx/web/chat");
const servers = [];
let browser;
let releaseOriginal;
try {
const capture = JSON.parse(await readFile(resolve(import.meta.dirname, "../src/data/fixtures/research-native-readback.json"), "utf8"));
const analysis = capture.analyses[0];
analysis.config = {model: "fixture", effort: "max", service_tier: "fast", research_synthesis: false};
const observation = (line, verdict, reason) => ({
assessments: [{episode_id: "search-episode", metric: "benefit_test", verdict, reason,
citations: [{source_id: "facts", first_line: line, last_line: line}]}],
limitations: ["Synthetic reader fixture."],
});
analysis.outputs = {original: observation(1, "no", "The saved original examines the first observation.")};
const initial = await start("original", capture);
analysis.outputs.zh = {...observation(3, "yes", "当前分析判读后续观测。"),
episode_questions: [{episode_id: "search-episode", question: "后续观测是否检验了任务进展?"}]};
const ready = await start("localized", capture);
const endpoints = new URLSearchParams({goal_id: capture.goal_id, output_language: "zh"});
const analysisPath = "/api/chat/research-analysis";
const getPack = async backend => (await (await fetch(new URL(analysisPath + "?" + endpoints, backend))).json()).pack;
const [before, after] = await Promise.all([getPack(initial), getPack(ready)]);
assert.equal(before.pack_id, after.pack_id);
assert.equal(before.analysis_key, after.analysis_key, "Same requested analysis identity");
assert.equal(before.result, null);
assert.equal(after.result.output_language, "zh");

browser = await launchBrowser(loadPlaywright().chromium);
const page = await browser.newPage({locale: "zh-CN", viewport: {width: 1200, height: 900}});
let localizedReady = false;
let holdOriginal = false;
await page.route(/\/api\/chat\/research-analysis(?:\/citation)?\?/, async route => {
const url = new URL(route.request().url());
if (url.searchParams.get("output_language") === "zh" && localizedReady) {
return route.fulfill({response: await route.fetch({url: new URL(url.pathname + url.search, ready).href})});
}
if (url.pathname.endsWith("/citation") && holdOriginal) {
holdOriginal = false;
await new Promise(resolveOriginal => {releaseOriginal = resolveOriginal;});
}
return route.continue();
});
const citation = page.locator(".analysis-source");
const citationButton = page.locator(".analysis-episode[open] .analysis-citations button");
const research = new URL("/chat/", initial);
research.search = new URLSearchParams({goalId: capture.goal_id, view: "research",
research_analysis: capture.goal_id, research_pack: before.pack_id});
const refresh = page.getByRole("button", {name: "刷新分析结果", exact: true});
const showEpisode = () => page.locator(".analysis-episode > summary").click();
async function openOriginal() {
localizedReady = false;
await page.goto(research.href);
await page.getByText("显示已保存的原文分析", {exact: false}).waitFor();
await showEpisode();
}
async function refreshToLocalized() {
localizedReady = true;
await refresh.click();
await page.getByText("显示已保存的原文分析", {exact: false}).waitFor({state: "hidden"});
await page.getByRole("button", {name: /收益检验 · 1/}).first().waitFor();
assert.equal(await citation.count(), 0, "No prior version citation content or loading state in the new reading");
}

await openOriginal();
await citationButton.click();
await citation.getByText(/candidate-one/).waitFor();
await refreshToLocalized();
await showEpisode();
await citationButton.click();
await citation.getByText(/candidate-three/).waitFor();

await openOriginal();
holdOriginal = true;
const requested = page.waitForRequest(request => request.url().includes("/citation?"));
await citationButton.click();
await requested;
await citation.getByText("正在读取引用原文…", {exact: true}).waitFor();
await refreshToLocalized();
const late = page.waitForResponse(response => response.url().includes("/citation?") && response.ok());
assert.equal(typeof releaseOriginal, "function");
releaseOriginal();
await (await late).finished();
await page.evaluate(() => new Promise(resolvePaint => requestAnimationFrame(() => requestAnimationFrame(resolvePaint))));
assert.equal(await citation.count(), 0, "Late original response cannot reopen the previous evidence");
console.log("research evidence version: saved and late original excerpts isolated");
} finally {
releaseOriginal?.();
await browser?.close();
await Promise.all(servers.map(async child => {
if (child.exitCode !== null || child.signalCode !== null) return;
const closed = once(child, "exit");
child.kill("SIGTERM");
await closed;
}));
await rm(root, {recursive: true, force: true});
}

async function start(name, capture) {
const fixture = resolve(root, name + ".json");
await writeFile(fixture, JSON.stringify(capture));
const child = spawn(resolveTestPython({repoRoot: repo}), [
"-u", "examples/personal-workspace-browser/research-history-backend.py",
"--fixture", fixture, "--workspace-directory", "--assets-dir", assets,
], {cwd: repo, env: {...process.env, PYTHONPATH: repo}, stdio: ["ignore", "pipe", "pipe"]});
servers.push(child);
let error = "";
child.stderr.on("data", chunk => {error = (error + chunk).slice(-3000);});
const lines = createInterface({input: child.stdout});
try {
return await new Promise((accept, reject) => {
const timer = setTimeout(() => reject(new Error("Research test server startup timed out: " + error)), 25_000);
child.once("error", failure => {clearTimeout(timer); reject(failure);});
child.once("exit", () => {clearTimeout(timer); reject(new Error("Research test server exited: " + error));});
lines.once("line", line => {
clearTimeout(timer);
try {accept(JSON.parse(line).url);} catch (failure) {reject(failure);}
});
});
} finally {lines.close();}
}
Loading
Loading