Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ jobs:
- run: go test -race ./...
- run: go vet ./...
- run: node --test tests/*.test.cjs
- name: Console sources stay formatted
run: npx --yes prettier@3.7.4 --check internal/bridge/ui/console.js internal/bridge/ui/console.css
- name: Native host integration (CPA v8.0.23)
env:
GH_TOKEN: ${{ github.token }}
Expand Down
10 changes: 10 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf"
}
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Changed

- 插件控制台的 `console.js`、`console.css` 改为 Prettier 3.7.4 格式化的可读源码(原为长行手写压缩风格),按原样内嵌;JS 语法树与原文件一致(仅去掉空语句),CSS 仅引号、属性选择器引号和小数前导零写法变化。控制台页面增大约 17 KB,未加入压缩步骤。CI 检查这两个文件的格式;Node 测试改为按唯一标记切片,标记缺失时直接失败。

## [0.2.13] - 2026-11-07

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ CPAMP 套餐名统一显示 **Go、GOAT、Pro、Max**。月额度与 5h 顶部

原生插件构建需要 **Go ≥1.26、CGO 和目标平台 C 编译器**。发行构建先生成 pinned CPAMP 页面,再内嵌到四个平台的动态库。

控制台脚本与样式(`internal/bridge/ui/console.js`、`console.css`)以格式化后的源码原样内嵌;修改后运行 `npx prettier@3.7.4 --write internal/bridge/ui/console.js internal/bridge/ui/console.css`(配置见 `.prettierrc.json`),CI 会检查格式。

GitHub Actions 运行 Go race/vet、前端测试与类型检查、CPA 原生协议及异常恢复联测、CPAMP 页面安装与热更新联测,并校验四平台插件和独立前端包的来源、校验值及内嵌页面一致性。具体步骤与验证边界见以下文档。

| 文档 | 内容 |
Expand Down
9 changes: 9 additions & 0 deletions docs/VALIDATION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# 开发验证记录

## 控制台源码可读化(2026-11-08,未发布源码)

`internal/bridge/ui/console.js`、`console.css` 原为长行手写压缩风格(JS 266 行、最长 1,709 字符;CSS 16 行、最长 4,024 字符),改为 Prettier 3.7.4 格式化的源码(JS 1,574 行、CSS 1,854 行),配置见 `.prettierrc.json`。

- **等价性**:`prettier --debug-check` 通过;另用 acorn 独立比较语法树,除去空语句与属性键引号写法后 JS **完全一致**;postcss 比较 1,428 个 CSS 节点,仅 11 处为引号、属性选择器引号和小数前导零写法差异。
- **未加压缩步骤**:格式化后两文件共增加 16,842 B,控制台页面约增 17 KB,动态库从 16,232,403 B 增至 16,249,299 B(本机未裁剪符号的构建,约 0.1%)。控制台经 CPA 管理接口在本机或内网提供且不缓存;在四平台构建(含 manylinux 容器)中加入 Node 压缩工具会让发布内容与测试源码不同并增加构建依赖,收益不足以抵消,因此按原样内嵌。
- **测试**:三个 Node 测试文件改为用 `between()` 按唯一标记切片,标记缺失或重复时直接失败(原先 `indexOf` 返回 -1 时会静默截取错误片段)。Node **31 项**、Go `go test ./internal/... ./cmd/...` 通过;CI 测试任务新增 `prettier --check`。
- **宿主与浏览器**:新 Windows 动态库在实际 CPA v8.0.23 与 v8.0.59 上 **18/18 通过**(含以 v0.2.12 正式包为前一版本的热升级)。`console_preview.py` 普通场景中登录并切换五个页面,无全局错误,设置页显示首字节/流空闲超时字段;与格式化前的 v0.2.13 构建逐页截图对比(1265×712),设置页 0 像素差异,其余四页差异仅为夹具时间戳和一次登录提示气泡。

## v0.2.13 发版记录与真实上游验收(2026-11-08)

用户已确认合并 PR #16 并发版,并提供真实凭证用于验收。下方“未发布源码”一节保留为当轮验证时的状态。本次未部署或访问任何服务器。
Expand Down
1,867 changes: 1,853 additions & 14 deletions internal/bridge/ui/console.css

Large diffs are not rendered by default.

1,821 changes: 1,565 additions & 256 deletions internal/bridge/ui/console.js

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions tests/console-diagnostics.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ const path = require('node:path');
const vm = require('node:vm');
const { test } = require('node:test');
const script = fs.readFileSync(path.join(__dirname, '../internal/bridge/ui/console.js'), 'utf8');
// Slice the formatted console source between two unique markers; fail loudly if either moves.
function between(text, start, end) {
const from = text.indexOf(start), to = text.indexOf(end, from);
assert.ok(from >= 0 && to > from && text.indexOf(start, from + 1) < 0, `console.js markers not found: ${start} … ${end}`);
return text.slice(from, to);
}
function fixture(api = async () => ({})) {
const nodes = new Map();
const context = {
Expand All @@ -13,7 +19,7 @@ function fixture(api = async () => ({})) {
$: id => { if (!nodes.has(id)) nodes.set(id, {}); return nodes.get(id); },
};
vm.createContext(context);
vm.runInContext(script.slice(script.indexOf('const diagnosticStatus='), script.indexOf("$('runDiagnostics').onclick")), context);
vm.runInContext(between(script, 'const diagnosticStatus =', "$('runDiagnostics').onclick"), context);
return context;
}
function report(label) {
Expand Down Expand Up @@ -56,7 +62,7 @@ test('rejected management access immediately clears rendered diagnostics', async
fetch: async () => ({ok: false, status: 401, text: async () => '{"error":"unauthorized"}'}),
apiURL: value => value, AbortSignal, openDialog: () => {},
});
vm.runInContext(script.slice(script.indexOf('async function api('), script.indexOf('async function confirmed(')), context);
vm.runInContext(between(script, 'async function api(', 'async function confirmed('), context);
await assert.rejects(context.api('/diagnostics'), /unauthorized/);
assert.equal(context.state.authenticated, false);
assert.equal(context.$('diagnosticResults').textContent, '登录后重新诊断');
Expand Down
10 changes: 8 additions & 2 deletions tests/console-evidence.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ const fs = require('node:fs');
const vm = require('node:vm');
const {test} = require('node:test');
const script = fs.readFileSync(require('node:path').join(__dirname,'../internal/bridge/ui/console.js'),'utf8');
// Slice the formatted console source between two unique markers; fail loudly if either moves.
function between(text, start, end) {
const from = text.indexOf(start), to = text.indexOf(end, from);
assert.ok(from >= 0 && to > from && text.indexOf(start, from + 1) < 0, `console.js markers not found: ${start} … ${end}`);
return text.slice(from, to);
}
function fixture(api) {
const nodes = new Map();
const context = {state:{authenticated:true,key:'fixture',credentials:[{id:'one',label:'one'},{id:'two',label:'two'}]},
Expand All @@ -11,7 +17,7 @@ function fixture(api) {
esc:x=>String(x??'').replace(/</g,'&lt;'),shortWhen:x=>x,when:x=>x,fmt:x=>x,
$:id=>{if(!nodes.has(id))nodes.set(id,{});return nodes.get(id)}};
vm.createContext(context);
vm.runInContext(script.slice(script.indexOf('const modelEvidenceLabels='),script.indexOf('function renderModels()')),context);
vm.runInContext(between(script, 'const modelEvidenceLabels =', 'function renderModels()'),context);
return context;
}
test('evidence selector discards delayed results from another credential',async()=>{
Expand All @@ -37,7 +43,7 @@ test('evidence escapes protocol and distinguishes source, expiry and generic den
test('model permission failures do not log out management access',async()=>{
const c=fixture();Object.assign(c,{AbortSignal,apiURL:x=>x,openDialog:()=>{},resetDiagnosticViews:()=>{throw Error('unexpected logout')},
fetch:async()=>({ok:false,status:403,text:async()=>' {"error":"model permission denied"}'})});
vm.runInContext(script.slice(script.indexOf('async function api('),script.indexOf('async function confirmed(')),c);
vm.runInContext(between(script, 'async function api(', 'async function confirmed('),c);
await assert.rejects(c.api('/models/test'),/model permission denied/);
assert.equal(c.state.authenticated,true);
});
20 changes: 13 additions & 7 deletions tests/console-ui.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ const fs = require('node:fs');
const vm = require('node:vm');
const {test} = require('node:test');
const source = fs.readFileSync(require('node:path').join(__dirname, '../internal/bridge/ui/console.js'), 'utf8');
// Slice the formatted console source between two unique markers; fail loudly if either moves.
function between(text, start, end) {
const from = text.indexOf(start), to = text.indexOf(end, from);
assert.ok(from >= 0 && to > from && text.indexOf(start, from + 1) < 0, `console.js markers not found: ${start} … ${end}`);
return text.slice(from, to);
}
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
function functions(start, end, context) {
vm.createContext(context);
vm.runInContext(source.slice(source.indexOf(start), source.indexOf(end)), context);
vm.runInContext(between(source, start, end), context);
return context;
}
test('model filters combine evidence with case-insensitive aliases and retain original action indices', () => {
Expand Down Expand Up @@ -46,13 +52,13 @@ test('closing a drawer after list refresh restores focus to its replacement trig
let focused='';const old={isConnected:false,dataset:{logDetail:'request'}},replacement={dataset:{logDetail:'request'},focus(){focused='replacement'}};
const dialog={open:false,querySelectorAll:()=>[],showModal(){this.open=true}};
const document={activeElement:old,querySelector:()=>null,querySelectorAll:()=>[replacement]};
const c=functions('const dialogOrigins=', 'function apiURL(',{state:{view:'logs'},document,$:()=>dialog});
const c=functions('const dialogOrigins =', 'function apiURL(',{state:{view:'logs'},document,$:()=>dialog});
c.openDialog('detailDialog');c.restoreDialogFocus('detailDialog');assert.equal(focused,'replacement');
});
test('closing an older dialog never steals focus from the next modal',()=>{
let focused=false;const dialog={open:false,querySelectorAll:()=>[],showModal(){this.open=true}};
const document={activeElement:{isConnected:true,dataset:{},focus(){focused=true}},querySelector:()=>({open:true})};
const c=functions('const dialogOrigins=', 'function apiURL(',{state:{view:'models'},document,$:()=>dialog});
const c=functions('const dialogOrigins =', 'function apiURL(',{state:{view:'models'},document,$:()=>dialog});
c.openDialog('detailDialog');c.restoreDialogFocus('detailDialog');assert.equal(focused,false);
});

Expand All @@ -62,14 +68,14 @@ test('focus restoration skips the hidden desktop trigger after a mobile layout c
const visible={dataset:{modelDetail:'2'},getClientRects:()=>[{}],focus(){focused='visible'}};
const dialog={open:false,querySelectorAll:()=>[],showModal(){this.open=true}};
const document={activeElement:old,querySelector:()=>null,querySelectorAll:()=>[hidden,visible]};
const c=functions('const dialogOrigins=', 'function apiURL(',{state:{view:'models'},document,$:()=>dialog});
const c=functions('const dialogOrigins =', 'function apiURL(',{state:{view:'models'},document,$:()=>dialog});
c.openDialog('detailDialog');c.restoreDialogFocus('detailDialog');assert.equal(focused,'visible');
});

test('pending storage and recovery rows escape filenames and preserve original observation times',()=>{
const nodes=new Map();const c={$:id=>{if(!nodes.has(id))nodes.set(id,{});return nodes.get(id)},esc:escape,fmt:String,when:x=>x||'未知'};
functions('function statusRows(', 'function apiURL(',c);
vm.runInContext(source.slice(source.indexOf('function renderStorage('),source.indexOf('async function loadStorage(')),c);
vm.runInContext(between(source, 'function renderStorage(', 'async function loadStorage('),c);
c.renderStorage({precision:'minute/day',last_success_at:'original time',pending_files:['usage.json'],pending_since:'pending time',statistics_buckets:7,recovery_warnings:[{file:'<file>',message:'<script>history gap</script>',checked_at:'recovery time'}]});
const html=c.$('storageStatus').innerHTML;
for(const value of ['original time','pending time','usage.json','history gap','recovery time','恢复后的历史可能不完整'])assert.ok(html.includes(value));
Expand All @@ -80,14 +86,14 @@ test('catalog and test dialogs cannot reopen after management logout during load
let resolve;const state={authenticated:true,key:'old',catalog:[]};const c={state,diagnosticIdentity:()=>state.key,api:()=>new Promise(done=>resolve=done),loadCredentials:()=>new Promise(done=>resolve=done),openDialog:()=>assert.fail('stale dialog opened'),globalError:()=>assert.fail('stale error rendered')};
functions('async function showCatalog(', 'function catalogFiltered(',c);
const catalog=c.showCatalog();state.authenticated=false;state.key='';resolve({models:[{id:'private'}]});await catalog;assert.equal(state.catalog.length,0);
vm.runInContext(source.slice(source.indexOf('async function showTest('),source.indexOf("document.querySelectorAll('dialog')")),c);
vm.runInContext(between(source, 'async function showTest(', "document.querySelectorAll('dialog')"),c);
state.authenticated=true;state.key='new';const test=c.showTest(0);state.authenticated=false;resolve();await test;
});

test('late direct-test responses never restore output or evidence after logout',async()=>{
let resolve;const nodes=new Map(),state={authenticated:true,key:'one'},button={};
const c={state,diagnosticIdentity:()=>state.key,$:id=>{if(!nodes.has(id))nodes.set(id,{});return nodes.get(id)},api:()=>new Promise(done=>resolve=done),FormData:class{*[Symbol.iterator](){yield ['model','mock']}},toast:()=>assert.fail('stale success'),loadModelEvidence:()=>assert.fail('stale refresh'),dialogError:()=>assert.fail('stale error')};
vm.createContext(c);vm.runInContext(source.slice(source.indexOf("$('testForm').onsubmit="),source.indexOf("$('settingsForm').onsubmit=")),c);
vm.createContext(c);vm.runInContext(between(source, "$('testForm').onsubmit =", "$('settingsForm').onsubmit ="),c);
const request=c.$('testForm').onsubmit({preventDefault(){},currentTarget:{querySelector:()=>button}});
state.authenticated=false;state.key='two';c.$('testResult').textContent='';resolve({response:'private'});await request;
assert.equal(c.$('testResult').textContent,'');assert.equal(button.disabled,false);
Expand Down
Loading