diff --git a/tests/console_desk_delivery_validation.mjs b/tests/console_desk_delivery_validation.mjs new file mode 100644 index 00000000..968e5e42 --- /dev/null +++ b/tests/console_desk_delivery_validation.mjs @@ -0,0 +1,175 @@ +// Regression coverage for the actual embedded Worker and target-specific mock callers. +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import { test } from "node:test"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import worker from "../web/strategy-switch-console/worker.js"; +import { V2_ASSETS, V2_PAGE_HTML } from "../web/strategy-switch-console/v2_asset_map.js"; +import { createDeskApi } from "../web/strategy-switch-console/frontend/src/desk/api/endpoints.ts"; +import { catalogCounts, INITIAL_STRATEGY_FLOW, strategyFlowReducer } from "../web/strategy-switch-console/frontend/src/desk/strategies/model.ts"; + +const origin = "https://console.invalid"; +test("Worker serves every clean-build resource with identical bytes", async () => { + const dist = new URL("../web/strategy-switch-console/frontend/dist/", import.meta.url); + const files = (await readdir(dist, { recursive: true, withFileTypes: true })).filter(f => f.isFile()); + const paths = []; + for (const file of files) { + const path = join(file.parentPath, file.name); + const name = relative(fileURLToPath(dist), path); + if (name === "index.html") continue; + const route = `/v2/assets/${name.replace(/^assets\//, "")}`; + paths.push(route); + const response = await worker.fetch(new Request(origin + route), {}); + assert.equal(response.status, 200, `built resource must be embedded: ${route}`); + assert.deepEqual(Buffer.from(await response.arrayBuffer()), await readFile(path), route); + } + assert.deepEqual(paths.sort(), Object.keys(V2_ASSETS).sort(), "no stale embedded resources"); + const html = await readFile(new URL("index.html", dist), "utf8"); + assert.equal(V2_PAGE_HTML.replace(/\n /, ""), html); +}); + +for (const scenario of ["ok", "degraded"]) { + for (const [target, domain, profile, eligibility, modes, counts] of [ + ["binance/spot", "crypto", "crypto_btc_dca", "no_live", ["paper", "dry_run"], { all: 1, eligible: 0, paper: 1, blocked: 0 }], + ["longbridge/hk", "hk_equity", "hk_global_etf_tactical_rotation", "blocked", ["dry_run"], { all: 1, eligible: 0, paper: 0, blocked: 1 }], + ]) { + test(`${scenario}: ${target} exposes only its domain and preserves research restrictions`, async () => { + const api = createDeskApi({ forcedScenario: scenario, delayMs: 0, + fetchJson: async () => { throw new Error("mock catalog must not fetch"); } }); + const { data } = await api.switchStrategies(target); + assert.equal(data.target_id, target); + assert.equal(data.domain, domain); + assert.ok(data.strategies.length > 0); + for (const entry of data.strategies) assert.equal(entry.domain, domain, "no US catalog inherited"); + const entry = data.strategies.find(entry => entry.profile === profile); + assert.ok(entry, "existing target strategy is represented"); + assert.equal(entry.eligibility, eligibility); + assert.deepEqual(entry.allowed_execution_modes, modes); + assert.deepEqual(catalogCounts(data.strategies), counts); + const state = strategyFlowReducer(INITIAL_STRATEGY_FLOW, { type: "select_target", targetId: target }); + assert.equal(strategyFlowReducer(state, { type: "select_strategy", ...entry }).step, + eligibility === "blocked" ? "strategy" : "preview"); + }); + } +} + +test("offered credential rotation links reach the existing Health route through the Worker", async () => { + const api = createDeskApi({ forcedScenario: "ok", delayMs: 0 }); + const { data } = await api.credentials(); + const supported = data.items.filter(item => item.rotation.supported); + assert.ok(supported.length > 0); + for (const item of supported) { + const url = new URL(item.rotation.route, origin); + const response = await worker.fetch(new Request(url), {}); + assert.equal(response.status, 200, "offered route must be served"); + assert.equal(url.searchParams.get("page"), "health", "select the existing Health page"); + assert.equal(await response.text(), V2_PAGE_HTML); + } +}); + +// Render the real component; hook stubs supply the deliberately stale read window. +const { createRequire } = await import("node:module"); +const require = createRequire(new URL("../web/strategy-switch-console/frontend/package.json", import.meta.url)); +const React = require("react"); +const { renderToStaticMarkup } = require("react-dom/server"); +const { deskTranslator } = await import("../web/strategy-switch-console/frontend/src/desk/i18n/index.ts"); +const overview = await import("../web/strategy-switch-console/frontend/src/desk/api/fixtures/overview.ts"); +const compiled = (await require("esbuild").build({ + stdin: { + resolveDir: fileURLToPath(new URL("../web/strategy-switch-console/frontend/src/", import.meta.url)), + contents: 'export { NeedsYouCard } from "./desk/components/NeedsYouCard"; export { StrategiesPage } from "./desk/pages/StrategiesPage";', + loader: "tsx", + }, + bundle: true, platform: "node", format: "cjs", jsx: "automatic", write: false, logLevel: "silent", + external: ["react", "react/jsx-runtime"], + plugins: [{ name: "controlled-read-window", setup(build) { + build.onLoad({ filter: /useDeskLoad\.ts$/ }, () => ({ contents: "export function useDeskLoad() { return globalThis.reads.shift(); }", loader: "ts" })); + build.onLoad({ filter: /useNarrow\.ts$/ }, () => ({ contents: "export function useNarrow() { return false; }", loader: "ts" })); + } }], +})).outputFiles[0].text; +function components(react = React, globals = {}) { + const module = { exports: {} }; + new Function("require", "module", "exports", "globalThis", compiled)( + name => name === "react" ? react : require(name), module, module.exports, globals, + ); + return module.exports; +} + +for (const [scenario, titles, actions] of [ + ["ok", ["平台回读与保存配置不一致", "Schwab refresh token 将在 1 天内到期", "回撤接近限制"], ["检查差异", "打开凭据", "查看策略"]], + ["degraded", ["平台回读与保存配置不一致", "账户事实已过期 39 小时"], ["检查差异", "打开账户"]], +]) { + test(`${scenario}: Needs-you titles, details and actions render in both languages`, () => { + const { NeedsYouCard } = components(); + const items = overview[scenario].needs_you; + for (const language of ["zh", "en"]) { + for (const [i, item] of items.entries()) { + const html = renderToStaticMarkup(React.createElement(NeedsYouCard, { items: [item], t: deskTranslator(language) })); + if (language === "zh") { + assert.ok(html.includes(titles[i]), "localized title"); + assert.ok(html.includes(actions[i]), "localized action"); + assert.match(html.match(/
]*>(.*?)<\/p>/s)?.[1] ?? "", /[\u4e00-\u9fff]/, "localized detail"); + } else { + assert.match(html, /differs from saved config|expires within 1 day|Drawdown near limit|39 hours old/); + assert.doesNotMatch(html, /needs_you\./, "no locale keys leak into English"); + } + if (item.kind === "config_drift") assert.match(html, /enabled.*reserved_cash_ratio/, "field codes remain exact"); + } + } + const unknown = { ...items[0], id: "fake_future_item", title: "Future backend title", detail: "Future detail", action: { label: "Future action", route: "/accounts" } }; + const html = renderToStaticMarkup(React.createElement(NeedsYouCard, { items: [unknown], t: deskTranslator("zh") })); + for (const value of [unknown.title, unknown.detail, unknown.action.label]) assert.ok(html.includes(value), "unknown backend copy survives"); + }); +} + +test("credential runbooks link to a tracked document on the served repository host", async () => { + const { data } = await createDeskApi({ forcedScenario: "ok", delayMs: 0 }).credentials(); + const links = data.items.map(item => item.runbook).filter(Boolean); + assert.ok(links.length > 0); + for (const link of links) { + const url = new URL(link, origin); + assert.equal(url.protocol, "https:"); + assert.equal(url.hostname, "github.com", "unserved console-relative docs are forbidden"); + const prefix = "/QuantStrategyLab/QuantRuntimeSettings/blob/main/"; + assert.ok(url.pathname.startsWith(prefix)); + assert.ok((await readFile(new URL(`../${decodeURIComponent(url.pathname.slice(prefix.length))}`, import.meta.url))).length > 0); + } +}); + +for (const catalogTarget of ["ibkr/tqqq", "binance/spot"]) { + test(`loaded ${catalogTarget} catalog cannot select an initial profile for a different current target`, async () => { + const currentTarget = catalogTarget === "ibkr/tqqq" ? "binance/spot" : "ibkr/tqqq"; + const api = createDeskApi({ forcedScenario: "ok", delayMs: 0 }); + const catalog = await api.switchStrategies(catalogTarget); + const profile = catalog.data.strategies.find(x => x.eligibility !== "blocked").profile; + const effects = [], actions = []; + const hooks = { ...React, + useState: value => [typeof value === "function" ? value() : value, () => {}], + useReducer: (_reducer, initial) => [{ ...initial, targetId: currentTarget, step: "strategy" }, action => actions.push(action)], + useRef: value => ({ current: value }), + useEffect: effect => effects.push(effect), + }; + const globals = { reads: [ + { load: { state: "ready", result: await api.switchTargets() }, reload() {} }, + { load: { state: "ready", result: catalog }, reload() {} }, + ] }; + const { StrategiesPage } = components(hooks, globals); + const tree = StrategiesPage({ api, t: deskTranslator("en"), language: "en", admin: true, initialTarget: currentTarget, initialProfile: profile }); + for (const effect of effects) effect(); + assert.equal(actions.some(action => action.type === "select_strategy"), false, "healthy prior catalog must not reapply a wrong-target profile"); + const html = renderToStaticMarkup(tree); + assert.doesNotMatch(html, /data-profile=/, "old catalog cards cannot be selected during the read transition"); + // A matching fresh catalog still permits its normal initial selection. + effects.length = 0; + actions.length = 0; + hooks.useReducer = (_reducer, initial) => [{ ...initial, targetId: catalogTarget, step: "strategy" }, action => actions.push(action)]; + globals.reads = [ + { load: { state: "ready", result: await api.switchTargets() }, reload() {} }, + { load: { state: "ready", result: catalog }, reload() {} }, + ]; + components(hooks, globals).StrategiesPage({ api, t: deskTranslator("en"), language: "en", admin: true, initialTarget: catalogTarget, initialProfile: profile }); + for (const effect of effects) effect(); + assert.equal(actions.filter(action => action.type === "select_strategy" && action.profile === profile).length, 1); + }); +} diff --git a/tests/console_desk_loading_retry_validation.mjs b/tests/console_desk_loading_retry_validation.mjs new file mode 100644 index 00000000..62922bcd --- /dev/null +++ b/tests/console_desk_loading_retry_validation.mjs @@ -0,0 +1,54 @@ +// LoadingState retry timeout lifecycle: a pending retry is a new read +// generation, so an expired flag from the previous attempt must not hide the +// loading state. Pure helpers plus source wiring; synthetic timestamps only. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { + isReadExpired, + readGeneration, + readTimedOut, +} from "../web/strategy-switch-console/frontend/src/desk/components/stateModel.ts"; + +const stateBlocks = readFileSync( + new URL("../web/strategy-switch-console/frontend/src/desk/components/StateBlocks.tsx", import.meta.url), + "utf8", +); + +const timeoutMs = 10_000; + +// First attempt times out. +const firstStartedAt = 1_000; +const firstGeneration = readGeneration(firstStartedAt, timeoutMs); +assert.equal(readTimedOut(firstStartedAt, firstStartedAt + timeoutMs, timeoutMs), true); +let expiredGeneration = firstGeneration; +assert.equal(isReadExpired(expiredGeneration, firstGeneration), true); + +// A retry passes a new startedAt and is still pending: it must show loading, +// not the previous read failure. +const retryStartedAt = 25_000; +const retryGeneration = readGeneration(retryStartedAt, timeoutMs); +assert.notEqual(retryGeneration, firstGeneration); +assert.equal(readTimedOut(retryStartedAt, retryStartedAt + 1, timeoutMs), false); +assert.equal( + isReadExpired(expiredGeneration, retryGeneration), + false, + "a pending retry shows loading, not the previous error", +); + +// The retry keeps its own timeout window; the previous marker never matches it. +expiredGeneration = retryGeneration; +assert.equal(isReadExpired(expiredGeneration, retryGeneration), true); +assert.equal(isReadExpired(firstGeneration, retryGeneration), false); +assert.equal(isReadExpired(null, retryGeneration), false); + +// A changed timeout is also a new generation, so a stale expiry cannot leak in. +assert.equal(isReadExpired(firstGeneration, readGeneration(firstStartedAt, timeoutMs * 2)), false); + +// Source wiring: the component keys the expired flag by read generation and no +// longer gates the effect on a mount-only boolean. +assert.match(stateBlocks, /const generation = readGeneration\(startedAt, timeoutMs\)/); +assert.match(stateBlocks, /isReadExpired\(expiredGeneration, generation\)/); +assert.match(stateBlocks, /setExpiredGeneration\(generation\)/); +assert.doesNotMatch(stateBlocks, /setTimedOut/); + +console.log("console_desk_loading_retry_validation: PASS (pending retry clears an expired timeout)"); diff --git a/tests/console_desk_mock_validation.mjs b/tests/console_desk_mock_validation.mjs index d3bb5fba..936b68ab 100644 --- a/tests/console_desk_mock_validation.mjs +++ b/tests/console_desk_mock_validation.mjs @@ -6,8 +6,14 @@ import { createRequire } from "node:module"; import * as session from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/session.ts"; import * as ribbon from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/ribbon.ts"; import * as overview from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/overview.ts"; +import * as accounts from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/accounts.ts"; +import * as credentials from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/credentials.ts"; +import * as switchTargets from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/switchTargets.ts"; +import * as switchStrategies from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/switchStrategies.ts"; import { days } from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/shared.ts"; -import { isOverview, isRead, isRibbon, isSession } from "../web/strategy-switch-console/frontend/src/desk/api/guards.ts"; +import { + isAccounts, isCredentials, isOverview, isRead, isRibbon, isSession, isSwitchStrategies, isSwitchTargets, +} from "../web/strategy-switch-console/frontend/src/desk/api/guards.ts"; import { MOCK_FAILURE, loadMock, resolveMockScenario } from "../web/strategy-switch-console/frontend/src/desk/api/mock.ts"; import { DESK_SOURCES, DeskContractError, createDeskApi } from "../web/strategy-switch-console/frontend/src/desk/api/endpoints.ts"; import { HttpError } from "../web/strategy-switch-console/frontend/src/api/client.ts"; @@ -33,6 +39,14 @@ const fixtures = { session, ribbon, overview }; for (const [name, module] of Object.entries(fixtures)) { for (const scenario of ["ok", "degraded"]) validate(name, module[scenario], `${name}.${scenario}`); } +const schemaFixtures = [ + ["accounts", accounts], + ["switch-targets", switchTargets], + ["switch-strategies", switchStrategies], +]; +for (const [schemaName, module] of schemaFixtures) { + for (const scenario of ["ok", "degraded", "empty"]) validate(schemaName, module[scenario], `${schemaName}.${scenario}`); +} validate("error", MOCK_FAILURE, "fail scenario body"); // 2. Guards accept every fixture and reject shapes the UI must not render. @@ -40,6 +54,16 @@ for (const scenario of ["ok", "degraded"]) { assert.ok(isSession(session[scenario]), `session.${scenario}`); assert.ok(isRibbon(ribbon[scenario]), `ribbon.${scenario}`); assert.ok(isOverview(overview[scenario]), `overview.${scenario}`); + assert.ok(isAccounts(accounts[scenario]), `accounts.${scenario}`); + assert.ok(isCredentials(credentials[scenario]), `credentials.${scenario}`); + assert.ok(isSwitchTargets(switchTargets[scenario]), `switchTargets.${scenario}`); + assert.ok(isSwitchStrategies(switchStrategies[scenario]), `switchStrategies.${scenario}`); +} +for (const scenario of ["empty"]) { + assert.ok(isAccounts(accounts[scenario]), `accounts.${scenario}`); + assert.ok(isCredentials(credentials[scenario]), `credentials.${scenario}`); + assert.ok(isSwitchTargets(switchTargets[scenario]), `switchTargets.${scenario}`); + assert.ok(isSwitchStrategies(switchStrategies[scenario]), `switchStrategies.${scenario}`); } const clone = value => structuredClone(value); assert.equal(isRead({ status: "unknown", reason: "read_failed", value: "0" }), false, "unknown never carries a value"); @@ -124,9 +148,34 @@ for (const scenario of ["ok", "degraded"]) for (const row of overview[scenario]. assert.match(row.account_ref, /^[A-Z]{1,2}•••000[1-6]$/, "fake masked refs only"); assert.equal(row.account_ref.slice(-4), row.account_suffix); } -const text = JSON.stringify(fixtures, (_k, v) => v) + JSON.stringify([session, ribbon, overview]); +for (const scenario of ["ok", "degraded"]) { + for (const broker of accounts[scenario].brokers) { + for (const target of broker.targets) assert.match(target.account_ref, /^[A-Z]{1,2}•••000[1-6]$/, "fake masked refs only"); + } + for (const target of switchTargets[scenario].targets) assert.match(target.account_ref, /^[A-Z]{1,2}•••000[1-6]$/, "fake masked refs only"); + for (const item of credentials[scenario].items) { + assert.notEqual(item.phase, "14", "the desk reminder enum has no 14"); + assert.ok(["unknown", "not_due", "30", "7", "1", "expired"].includes(item.phase), `credential phase ${item.phase}`); + } +} +const allFixtures = { session, ribbon, overview, accounts, credentials, switchTargets, switchStrategies }; +// The offered runbook is public product documentation, never account data. +// Exempt only this exact URL and only in the runbook field; keep all other host checks. +const publicRunbook = "https://github.com/QuantStrategyLab/QuantRuntimeSettings/blob/main/docs/credential-maintenance.zh-CN.md"; +const fixtureText = fixtures => JSON.stringify(fixtures, (key, value) => key === "runbook" && value === publicRunbook ? "repository-runbook" : value); +const text = fixtureText(allFixtures); +for (const fixtures of [ + { runbook: "https://private.example.invalid/runbook" }, + { runbook: `${publicRunbook}?account=fake-account` }, + { credential_host: publicRunbook }, + { login: "fake@example.invalid" }, +]) assert.match(fixtureText(fixtures), /@|https?:\/\/(?!example)/, "all non-runbook hosts and emails remain rejected"); assert.doesNotMatch(text, /@|https?:\/\/(?!example)/, "no emails or real hosts"); -for (const match of text.matchAll(/"(\d{4}-\d{2}-\d{2})T/g)) assert.ok(match[1] >= "2026-09-01" && match[1] <= "2026-11-03", `synthetic time ${match[1]}`); +const coreText = JSON.stringify({ session, ribbon, overview }); +for (const match of coreText.matchAll(/"(\d{4}-\d{2}-\d{2})T/g)) assert.ok(match[1] >= "2026-09-01" && match[1] <= "2026-11-03", `synthetic time ${match[1]}`); +// Expiry fixtures may sit in the future; they are still synthetic. +const extraText = JSON.stringify({ accounts, credentials, switchTargets, switchStrategies }); +for (const match of extraText.matchAll(/"(\d{4}-\d{2}-\d{2})T/g)) assert.ok(match[1] >= "2026-09-01" && match[1] <= "2026-12-31", `synthetic time ${match[1]}`); // 4. Mock switch: ?mock= beats storage; 0/unknown values turn it off. assert.equal(resolveMockScenario("", null), null); @@ -136,8 +185,11 @@ assert.equal(resolveMockScenario("?mock=0", "ok"), null); assert.equal(resolveMockScenario("?page=overview", "fail"), "fail"); assert.equal(resolveMockScenario("?mock=\n \n \n \n
\n \n \n