Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0cc1670
feat(console): add cockpit shell, semantic tokens, and primitives
Pigbibi Oct 31, 2026
27e95bd
feat(console): surface decision queue and activity on overview
Pigbibi Oct 31, 2026
ce761f4
feat(console): bind decisions to server tier, expiry, and operation
Pigbibi Oct 31, 2026
f0feb0e
feat(console): add lifecycle, policy readback, and health digest
Pigbibi Oct 31, 2026
5458a2c
test(console): cover cockpit tier, expiry, and policy bindings
Pigbibi Oct 31, 2026
ec88c5f
fix: regenerate cockpit frontend asset map
Pigbibi Nov 1, 2026
18e4418
fix(console): export HTTP errors from display test client
Pigbibi Nov 1, 2026
bbfc242
fix(deps): patch console tooling security advisories
Pigbibi Nov 1, 2026
1970248
fix(console): prevent cockpit toolbar and badge overflow
Pigbibi Nov 1, 2026
a1d9e0c
build(console): regenerate cockpit assets after rebasing
Pigbibi Nov 1, 2026
947d257
fix(console): show unknown decision state and readback capability
Pigbibi Nov 1, 2026
946c138
fix(console): deep-link cockpit queue entries to one decision
Pigbibi Nov 1, 2026
6d905bb
build(console): regenerate cockpit assets
Pigbibi Nov 1, 2026
ba8f3f8
fix(console): map regime effect labels and cover lifecycle stepper axes
Pigbibi Nov 1, 2026
eba70cc
fix(console): read decision envelopes and send server-whitelisted act…
Pigbibi Nov 1, 2026
5ceec70
fix(console): remove machine-value text leaks from reachable UI (M01-…
Pigbibi Nov 1, 2026
14059a1
Merge remote-tracking branch 'origin/main' into feat/cockpit-frontend…
Pigbibi Nov 1, 2026
d907d6f
chore(console): regenerate v2 asset map after merging main
Pigbibi Nov 1, 2026
576044e
fix(console): align cockpit inbox page with server contracts (#1029)
Pigbibi Nov 1, 2026
ddb918c
Merge remote-tracking branch 'origin/main' into feat/cockpit-frontend…
Pigbibi Nov 1, 2026
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
569 changes: 569 additions & 0 deletions tests/console_cockpit_decision_validation.mjs

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions tests/console_display_gaps_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,16 @@ try {
define: { "import.meta.env": "{}" }, plugins: [{ name: "controlled-read", setup(b) {
b.onLoad({ filter: /\/lib\/hooks\.ts$/ }, () => ({ contents:
"export const useApi = () => globalThis.__displayState;", loader: "js" }));
b.onLoad({ filter: /\/api\/client\.ts$/ }, () => ({ contents:
"export async function getJson() { if (globalThis.__displayError) throw Error('synthetic_read_failure'); return globalThis.__displayPayload; } export const readRevision = () => 0; export const postJson = () => { throw Error('writes_forbidden'); };", loader: "js" }));
b.onLoad({ filter: /\/api\/client\.ts$/ }, () => ({ contents: `
export class AccessError extends Error {
constructor(status) { super(status === 401 ? 'session_expired' : 'access_denied'); this.status = status; }
}
export class HttpError extends Error {
constructor(status, payload) { super(payload?.error || 'http_' + status); this.status = status; this.payload = payload; }
}
export async function getJson() { if (globalThis.__displayError) throw Error('synthetic_read_failure'); return globalThis.__displayPayload; }
export const readRevision = () => 0;
export const postJson = () => { throw Error('writes_forbidden'); };`, loader: "js" }));
for (const [file, exports] of [["StrategiesPage", "CatalogTab, ResearchTab, parseResearchLeads, loadResearchLeads"],
["HealthPage", "StatStrip, CredentialsCard"], ["RunGrid", "DayDetail"]]) {
b.onLoad({ filter: new RegExp(`/${file}\\.tsx$`) }, async ({ path }) => ({
Expand Down
128 changes: 128 additions & 0 deletions tests/console_frontend_i18n_validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Frontend text-leak regression: M01/M02/M05/M06 translate fallbacks and P01/P02/P04 rendering.
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { build } from "../web/strategy-switch-console/frontend/node_modules/esbuild/lib/main.js";

const root = fileURLToPath(new URL("../web/strategy-switch-console/frontend/", import.meta.url));
const directory = await mkdtemp(join(tmpdir(), "frontend-i18n-"));
const visible = (html) => html.replace(/ data-[a-z-]+="[^"]*"/g, "");
try {
const outfile = join(directory, "i18n.cjs");
await build({
stdin: {
resolveDir: root,
contents: `
import { createElement as h } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider, translate, useT } from './src/i18n';
import { AuditTimeline, regimeLabel } from './src/components/cockpit';
import { loadAuditEvents } from './src/api/cockpit';
export { translate, loadAuditEvents };
export const tr = (language, key, fallback) => translate(language, key, {}, fallback);
const RegimeText = ({ value }) => { const t = useT(); return h('p', { className: 'regime-text' }, regimeLabel(value, t)); };
export const regime = (language, value) => renderToStaticMarkup(h(I18nProvider, { language }, h(RegimeText, { value })));
const events = [
{ event_id: 'e1', occurred_at: '2026-11-01T10:00:00Z', actor: 'owner', action: 'record_owner_decision',
subject: { kind: 'owner_decision', id: 'demo' }, revision: 7, decision_id: 'd-1', operation_id: null, outcome: 'decided' },
{ event_id: 'e2', occurred_at: '2026-11-01T10:00:01Z', actor: null, action: 'some_new_action',
subject: { kind: 'mystery_kind', id: 'x-1' }, revision: null, decision_id: null, operation_id: null, outcome: 'weird_outcome' },
];
export const audit = (language) => renderToStaticMarkup(h(I18nProvider, { language },
h(AuditTimeline, { events, status: 'ready' })));
`,
},
bundle: true,
platform: "node",
format: "cjs",
outfile,
jsx: "automatic",
loader: { ".css": "empty" },
define: { "import.meta.env": "{}" },
logLevel: "silent",
});
const ui = createRequire(import.meta.url)(outfile);

// M05/M01/M02/M04/M06/M07: dynamic keys fall back to localized copy, never the raw key.
assert.equal(ui.tr("zh", "decisions.action.some_action"), "未知操作");
assert.equal(ui.tr("en", "decisions.action.some_action"), "Unknown action");
assert.equal(ui.tr("zh", "some.label.key"), "未知");
assert.equal(ui.tr("en", "some.label.key"), "Unknown");
assert.equal(ui.tr("zh", "constructor"), "未知", "prototype keys never resolve as functions");
assert.equal(ui.tr("zh", "reason.mystery_code"), "原因未知");
assert.equal(ui.tr("en", "reason.mystery_code"), "Unknown reason");
assert.equal(ui.tr("zh", "state.mystery_status"), "未知状态");
assert.equal(ui.tr("zh", "research.state.mystery"), "未知状态");
assert.equal(ui.tr("zh", "research.source.synthetic-source"), "其他研究来源");
assert.equal(ui.tr("en", "research.source.synthetic-source"), "Other research source");
assert.equal(ui.tr("en", "research.source.research-mirror"), "Research mirror");
assert.equal(ui.tr("zh", "research.source.quant-advisor-research"), "量化研究信号");
// M02/M06: known machine values keep their vetted copy; unknown modes never render the raw enum.
assert.equal(ui.tr("zh", "strategies.execution.paper"), "模拟");
assert.equal(ui.tr("zh", "strategies.execution.live"), "实盘");
assert.equal(ui.tr("zh", "strategies.execution.legacy_live"), "实盘");
assert.equal(ui.tr("en", "strategies.execution.dry_run"), "Dry run");
assert.equal(ui.tr("zh", "strategies.execution.mystery_mode"), "执行模式未知");
assert.equal(ui.tr("en", "strategies.execution.mystery_mode"), "Execution mode unknown");
assert.equal(ui.tr("en", "reason.runtime_control_pending"), "The previous action is awaiting confirmation");
assert.equal(ui.tr("zh", "accounts.field.mystery_field"), "其他参数");
assert.equal(ui.tr("en", "accounts.field.mystery_field"), "Other parameter");
assert.equal(ui.tr("zh", "initial_enrollment.fact.some_field"), "其他字段");
assert.equal(ui.tr("zh", "initial_enrollment.scope_kind.mystery"), "未知");

// P04: regime effects render through copy only.
const regimeZhKnown = visible(ui.regime("zh", "risk_reduction_only"));
assert.match(regimeZhKnown, /仅允许降低风险/);
assert.match(visible(ui.regime("en", "risk_reduction_only")), /Risk reduction only/);
const regimeUnknown = visible(ui.regime("zh", "mystery_effect"));
assert.match(regimeUnknown, /市场状态影响未知/);
assert.match(visible(ui.regime("en", "mystery_effect")), /Regime effect unknown/);
assert.doesNotMatch(regimeUnknown, /mystery effect|mystery_effect/, "unknown regimes are never humanized or raw");

// P01/P02: audit actions, outcomes and structured subjects are labeled, not machine text.
const auditZh = visible(ui.audit("zh"));
assert.match(auditZh, /记录负责人决定/);
assert.match(auditZh, /对象:负责人决定 · demo/);
assert.match(auditZh, /结果 已决定/);
assert.match(auditZh, /记录修订 7/);
assert.match(auditZh, /其他操作/);
assert.match(auditZh, /其他对象/);
assert.match(auditZh, /结果 未知/, "unknown outcomes render the exact localized value");
assert.doesNotMatch(auditZh, /结果未知|weird_outcome/);
const auditEn = visible(ui.audit("en"));
assert.match(auditEn, /Owner decision recorded/);
assert.match(auditEn, /Subject: Owner decision · demo/);
assert.match(auditEn, /Outcome Decided/);
assert.match(auditEn, /Record revision 7/);
assert.match(auditEn, /Other action/);
assert.match(auditEn, /Other subject/);
assert.match(auditEn, /Outcome Unknown/, "unknown outcomes render the exact localized value");
assert.doesNotMatch(auditEn, /Outcome unknown/);
assert.doesNotMatch(auditEn, /record owner decision|owner_decision:|some new action|mystery kind|weird outcome/,
"raw action, kind and outcome codes never reach visible text");

// P02 adapter: subjects stay structured; a legacy "kind: id" string splits instead of concatenating.
const realFetch = globalThis.fetch;
try {
const jsonResponse = (payload) => ({ status: 200, ok: true, json: async () => payload });
globalThis.fetch = async () => jsonResponse({ schema_version: "qsl.audit_events.v1", events: [
{ id: "e1", occurred_at: "2026-11-01T10:00:00Z", action: "approval_recorded", subject: { kind: "strategy_promotion", id: "p-1" }, outcome: "applied", revision: "a".repeat(64), ref: "/api/decisions/p-1" },
{ id: "e2", occurred_at: "2026-11-01T10:00:01Z", action: "approval_recorded", subject: "owner_decision: d-9", outcome: null, revision: null, ref: "/api/operations/d-9" },
{ id: "e3", occurred_at: "2026-11-01T10:00:02Z", action: "approval_recorded", subject: "plain-id", outcome: null, revision: null, ref: null },
] });
const read = await ui.loadAuditEvents();
assert.equal(read.status, "ready");
assert.deepEqual(read.data[0].subject, { kind: "strategy_promotion", id: "p-1" });
assert.deepEqual(read.data[1].subject, { kind: "owner_decision", id: "d-9" });
assert.deepEqual(read.data[2].subject, { kind: "unknown", id: "plain-id" });
assert.equal(read.data[0].action, "approval_recorded");
} finally {
globalThis.fetch = realFetch;
}
} finally {
await rm(directory, { recursive: true, force: true });
}
console.log("console_frontend_i18n_validation: ok");
49 changes: 36 additions & 13 deletions tests/console_notification_receipts_ui_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ import { build } from "../web/strategy-switch-console/frontend/node_modules/esbu
import { createNotificationLogReader } from "../web/strategy-switch-console/application/console_api/notification_receipts.js";

/*
* The health page no longer presents a notification module: there is no
* trustworthy source for "what is the current notification state", and the old
* GitHub receipts are transport diagnostics, not run status. This file therefore
* keeps two jobs:
* The health page presents no legacy notification module: the old GitHub
* receipts are transport diagnostics, not run status, and the page shows a
* labeled digest-delivery projection instead. This file therefore keeps two
* jobs:
*
* 1. Prove the read-only receipt API still exists and still classifies reads.
* Removing the UI is not retiring the API.
* 2. Prove the health page renders no legacy/current notification module and
* issues no notification request, while keeping its other real states.
* 2. Prove the health page renders no legacy/current notification module, reads
* the digest source at most once, and keeps its other real states.
*/

const root = new URL("../web/strategy-switch-console/frontend/", import.meta.url).pathname;
Expand Down Expand Up @@ -76,15 +76,30 @@ try {
globalThis.__calls.push('loadRuntimeDaily'); return globalThis.__fx.daily; });
export const loadNotificationLog = named('loadNotificationLog', () => {
globalThis.__notificationRequests += 1;
throw new Error('the health page must not request the notification log'); });`,
globalThis.__calls.push('loadNotificationLog');
return globalThis.__fx.notificationLog; });`,
loader: "js",
}));
// The digest adapter reads the legacy log but only surfaces a labeled
// projection; these adapters never reach the network during SSR.
builder.onLoad({ filter: /\/api\/client\.ts$/ }, () => ({
contents: `export class AccessError extends Error { constructor(status) { super('access'); this.status = status; } }
export class HttpError extends Error { constructor(status) { super('http'); this.status = status; } }
export const invalidatePrivateSession = () => {};
export const getJson = async path => {
globalThis.__calls.push('getJson:' + path);
if (path.startsWith('/api/audit-events')) return { events: [] };
throw new HttpError(404);
};
export const postJson = async () => { throw new HttpError(404); };`,
loader: "js",
}));
// Call the loader during render so the counters above observe real calls.
builder.onLoad({ filter: /\/lib\/hooks\.ts$/ }, () => ({
contents: `export function useApi(loader) {
try {
const data = loader();
if (data && data.__loading) return { phase: 'loading', data: null, error: null, reload() {} };
if (data && (data.__loading || typeof data.then === 'function')) return { phase: 'loading', data: null, error: null, reload() {} };
return { phase: 'ready', data, error: null, reload() {} };
} catch (error) { return { phase: 'error', data: null, error, reload() {} }; }
}
Expand Down Expand Up @@ -112,6 +127,10 @@ try {
accounts: { accounts: [] },
bindings: [],
daily: null,
notificationLog: {
status: "ok", as_of: "2026-10-30T20:00:00Z", items: [], next_cursor: null,
coverage: "recent_daily_digest_public_receipts_only", unavailable_reason: null,
},
};

const render = (language, override = {}) => {
Expand Down Expand Up @@ -142,7 +161,7 @@ try {
const titles = html => [...html.matchAll(/class="kpi-title">([^<]*)</g)].map(match => decode(match[1]));

for (const language of ["zh", "en"]) {
test(`${language}: health renders no notification module and issues no notification request`, () => {
test(`${language}: health renders no legacy notification module and reads the digest source once`, () => {
const html = render(language);
for (const copy of REMOVED[language]) {
assert.ok(!html.includes(copy), `removed notification copy is still rendered: ${copy}`);
Expand All @@ -158,8 +177,10 @@ try {
assert.ok(!html.includes("notification-reason"), "the legacy reason disclosure is gone");
assert.ok(!html.includes("actions/runs"), "no raw run/attempt link is rendered");
assert.ok(!html.includes("data-state=\"unverified\""), "no faux current-notification badge is rendered");
assert.equal(globalThis.__notificationRequests, 0, "the page must not request the notification log");
assert.ok(!globalThis.__calls.includes("loadNotificationLog"), "no notification loader runs");
// The digest projection reads the same source, but only once, and the
// legacy receipt rows themselves never reach the page.
assert.equal(globalThis.__notificationRequests, 1, "the digest projection reads its source exactly once");
assert.equal(globalThis.__calls.filter(call => call === "loadNotificationLog").length, 1, "no second notification read");
// Only the freshness and credential tables remain.
assert.equal((html.match(/<table/g) || []).length, 2, "only freshness and credential tables remain");
// The page itself is intact.
Expand All @@ -186,16 +207,18 @@ try {
globalThis.__throw = "loadCredentialMaintenance";
globalThis.__fx = { ...fixtures };
globalThis.__calls = [];
globalThis.__notificationRequests = 0;
const errorHtml = ui.render(language);
assert.ok(errorHtml.includes(language === "zh" ? "重试" : "Retry"), "a failed credential read keeps its retry");
assert.equal(globalThis.__notificationRequests, 0);
assert.equal(globalThis.__notificationRequests, 1, "the digest source is still read exactly once per render");

// Loading still shows skeletons rather than an empty page.
globalThis.__fx = { ...fixtures };
globalThis.__loading = ["loadTodayRuns"];
globalThis.__notificationRequests = 0;
const loadingHtml = ui.render(language);
assert.ok(loadingHtml.includes("skeleton"), "the loading state still renders skeletons");
assert.equal(globalThis.__notificationRequests, 0);
assert.equal(globalThis.__notificationRequests, 1, "the digest source is still read exactly once per render");
globalThis.__loading = [];
globalThis.__throw = null;
});
Expand Down
2 changes: 1 addition & 1 deletion tests/console_shell_unavailable_source_ui_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ try {
// --- To-do badge ----------------------------------------------------------
const unknownEn = ui.shell("en", null, "read_failed");
assert.match(unknownEn, /nav-badge-unknown/, "an unreadable inbox marks the to-do item");
assert.match(unknownEn, /aria-label="To-dos · To-do count unavailable"/,
assert.match(unknownEn, /aria-label="Decisions · To-do count unavailable"/,
"the unknown marker is announced, because the button label replaces its contents");
assert.match(ui.shell("zh", null, "read_failed"), /待办数量不可读/, "zh: the unknown marker is localized");
assert.doesNotMatch(ui.shell("en", null, "ok"), /nav-badge/, "a readable inbox with nothing pending stays blank");
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/console-gaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const quotaDirectory: DirectoryStatus = {
};
export const quotaInbox: InboxResponse = {
status: "read_failed", items: [], coverage: "existing_decision_sources",
server_time: observed, next_cursor: null,
sources: ["owner", "promotion", "recovery"].map(source => ({ source, status: "read_failed",
failure_reason: "kv_list_daily_limit_exceeded" })), ...readonly,
};
Expand Down
Loading
Loading