@finebi/finebi-cli 1.6.1 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/package.json +1 -1
- package/skills/finebi-cli-agent/SKILL.md +43 -123
- package/skills/finebi-cli-agent/agents/openai.yaml +1 -1
- package/skills/finebi-cli-agent/references/artifact-ordering.md +3 -3
- package/skills/finebi-cli-agent/references/command-workflows.md +30 -1
- package/skills/finebi-cli-agent/references/dashboard-data-result.md +191 -191
- package/skills/finebi-cli-agent/references/dashboard-dsl-api.md +44 -44
- package/skills/finebi-cli-agent/references/dsl-handoff-contract.md +1 -1
- package/skills/finebi-cli-agent/references/handoff-contract.md +6 -4
- package/skills/finebi-cli-agent/references/mutation-confirmation-contract.md +2 -0
- package/skills/finebi-cli-agent/references/scenario-workflows.md +10 -8
- package/skills/finebi-cli-agent/references/web-app.md +4 -2
- package/skills/finebi-cli-agent/references/widget-dsl-core.md +6 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +157 -148
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/browser-auth.js +3 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +28 -28
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/error-catalog.js +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +63 -60
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/install-skill.js +89 -89
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/profiles.js +158 -158
- package/skills/finebi-cli-agent/scripts/finebi-cli/src/skill-files.js +94 -94
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +18 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +12 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +80 -67
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +17 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +66 -38
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/cockpit.config.json +22 -22
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/examples/retail.config.json +185 -185
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/index.html +2 -2
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/public/app-config.json +11 -11
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/browser-session.mjs +59 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +25 -24
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-session.mjs +57 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/session-file.mjs +45 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +63 -56
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/cockpit.css +3 -3
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Chart.tsx +40 -40
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Insights.tsx +17 -17
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/MonthPicker.tsx +32 -32
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/OptionFilter.tsx +7 -7
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/RangeFilter.tsx +9 -9
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +40 -39
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar-locales.ts +3 -3
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/calendar.ts +14 -14
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Bar.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Line.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/charts/Pie.tsx +6 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/month-picker.css +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +20 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +3 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +58 -44
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.d.ts +1 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/date.js +5 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.d.ts +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/filters.js +106 -106
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +235 -77
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +20 -18
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/main.tsx +5 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +12 -12
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/presentation.ts +36 -36
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/table-data.ts +47 -47
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/view-data.ts +18 -18
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/services/widget-queries.ts +18 -18
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/fixtures.mjs +28 -28
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/index.html +16 -16
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/main.tsx +149 -149
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/test-page.css +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/browser/vite.config.mjs +13 -13
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/bundle.test.mjs +19 -19
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/cockpit.test.mjs +73 -73
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +20 -5
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/retail.test.mjs +31 -31
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +7 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/session.test.mjs +91 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/vite-auth.test.mjs +41 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tsconfig.json +4 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +9 -4
- package/skills/finebi-cli-agent/scripts/finebi-cli-runner.mjs +4 -4
- package/src/app.js +157 -148
- package/src/browser-auth.js +3 -4
- package/src/config.js +28 -28
- package/src/error-catalog.js +2 -2
- package/src/help.js +63 -60
- package/src/install-skill.js +89 -89
- package/src/profiles.js +158 -158
- package/src/skill-files.js +94 -94
- package/templates/web-app/AGENTS.md +18 -5
- package/templates/web-app/README.md +12 -5
- package/templates/web-app/docs/acceptance.md +80 -67
- package/templates/web-app/docs/auth-and-deploy.md +17 -2
- package/templates/web-app/docs/data-contract.md +66 -38
- package/templates/web-app/docs/filters.md +2 -2
- package/templates/web-app/examples/cockpit.config.json +22 -22
- package/templates/web-app/examples/retail.config.json +185 -185
- package/templates/web-app/index.html +2 -2
- package/templates/web-app/public/app-config.json +11 -11
- package/templates/web-app/scripts/browser-session.mjs +59 -0
- package/templates/web-app/scripts/dev-proxy.mjs +25 -24
- package/templates/web-app/scripts/dev-session.mjs +57 -0
- package/templates/web-app/scripts/session-file.mjs +45 -0
- package/templates/web-app/src/App.tsx +63 -56
- package/templates/web-app/src/cockpit.css +3 -3
- package/templates/web-app/src/components/Chart.tsx +40 -40
- package/templates/web-app/src/components/DataTable.tsx +1 -1
- package/templates/web-app/src/components/Insights.tsx +17 -17
- package/templates/web-app/src/components/MonthPicker.tsx +32 -32
- package/templates/web-app/src/components/OptionFilter.tsx +7 -7
- package/templates/web-app/src/components/RangeFilter.tsx +9 -9
- package/templates/web-app/src/components/Widget.tsx +40 -39
- package/templates/web-app/src/components/calendar-locales.ts +3 -3
- package/templates/web-app/src/components/calendar.ts +14 -14
- package/templates/web-app/src/components/charts/Bar.tsx +6 -6
- package/templates/web-app/src/components/charts/Line.tsx +6 -6
- package/templates/web-app/src/components/charts/Pie.tsx +6 -6
- package/templates/web-app/src/components/month-picker.css +1 -1
- package/templates/web-app/src/finebi/client.ts +20 -4
- package/templates/web-app/src/finebi/config.d.ts +3 -1
- package/templates/web-app/src/finebi/config.js +58 -44
- package/templates/web-app/src/finebi/date.d.ts +1 -1
- package/templates/web-app/src/finebi/date.js +5 -5
- package/templates/web-app/src/finebi/filters.d.ts +4 -4
- package/templates/web-app/src/finebi/filters.js +106 -106
- package/templates/web-app/src/finebi/normalize.ts +235 -77
- package/templates/web-app/src/finebi/types.ts +20 -18
- package/templates/web-app/src/main.tsx +5 -5
- package/templates/web-app/src/retail.css +12 -12
- package/templates/web-app/src/services/presentation.ts +36 -36
- package/templates/web-app/src/services/table-data.ts +47 -47
- package/templates/web-app/src/services/view-data.ts +18 -18
- package/templates/web-app/src/services/widget-queries.ts +18 -18
- package/templates/web-app/tests/browser/fixtures.mjs +28 -28
- package/templates/web-app/tests/browser/index.html +16 -16
- package/templates/web-app/tests/browser/main.tsx +149 -149
- package/templates/web-app/tests/browser/test-page.css +4 -4
- package/templates/web-app/tests/browser/vite.config.mjs +13 -13
- package/templates/web-app/tests/bundle.test.mjs +19 -19
- package/templates/web-app/tests/cockpit.test.mjs +73 -73
- package/templates/web-app/tests/field-selection.test.mjs +70 -0
- package/templates/web-app/tests/proxy.test.mjs +20 -5
- package/templates/web-app/tests/retail.test.mjs +31 -31
- package/templates/web-app/tests/runtime.test.mjs +7 -0
- package/templates/web-app/tests/session.test.mjs +91 -0
- package/templates/web-app/tests/table-normalize.test.mjs +189 -0
- package/templates/web-app/tests/vite-auth.test.mjs +41 -0
- package/templates/web-app/tsconfig.json +4 -4
- package/templates/web-app/vite.config.ts +9 -4
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import {test} from 'node:test';
|
|
3
|
-
import {build} from 'vite';
|
|
4
|
-
|
|
5
|
-
test('production entry defers charts and optional controls and only bundles supported date locales',async()=>{
|
|
6
|
-
const result=await build({logLevel:'silent',build:{write:false}});
|
|
7
|
-
const chunks=result.output.filter(item=>item.type==='chunk');
|
|
8
|
-
const initial=new Set();
|
|
9
|
-
const visit=chunk=>{if(initial.has(chunk))return;initial.add(chunk);for(const name of chunk.imports){const imported=chunks.find(c=>c.fileName===name);if(imported)visit(imported);}};
|
|
10
|
-
chunks.filter(c=>c.isEntry).forEach(visit);
|
|
11
|
-
const initialModules=[...initial].flatMap(c=>Object.keys(c.modules));
|
|
12
|
-
assert.ok(!initialModules.some(id=>/node_modules\/(echarts|zrender)\//.test(id)),'ECharts must not be an entry dependency');
|
|
13
|
-
assert.ok(!initialModules.some(id=>/node_modules\/(antd|@rc-component\/picker)\//.test(id)),'optional filter UI must not be an entry dependency');
|
|
14
|
-
const modules=chunks.flatMap(c=>Object.keys(c.modules));
|
|
15
|
-
assert.ok(!modules.some(id=>id.includes('/tests/browser/')),'browser fixtures and their fetch replacement must not enter production');
|
|
16
|
-
const locales=new Set(modules.flatMap(id=>{const match=id.match(/date-fns\/locale\/([^/]+)/);return match?[match[1]]:[];}));
|
|
17
|
-
assert.deepEqual([...locales].filter(name=>!['en-US','zh-CN','_lib','en-US.js','zh-CN.js'].includes(name)),[],'do not bundle unused calendar languages');
|
|
18
|
-
for(const type of ['Bar','Line','Pie'])assert.ok(chunks.some(c=>c.isDynamicEntry&&Object.keys(c.modules).some(id=>id.endsWith(`/charts/${type}.tsx`))),`${type} has an independent lazy entry`);
|
|
19
|
-
});
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import {build} from 'vite';
|
|
4
|
+
|
|
5
|
+
test('production entry defers charts and optional controls and only bundles supported date locales',async()=>{
|
|
6
|
+
const result=await build({logLevel:'silent',build:{write:false}});
|
|
7
|
+
const chunks=result.output.filter(item=>item.type==='chunk');
|
|
8
|
+
const initial=new Set();
|
|
9
|
+
const visit=chunk=>{if(initial.has(chunk))return;initial.add(chunk);for(const name of chunk.imports){const imported=chunks.find(c=>c.fileName===name);if(imported)visit(imported);}};
|
|
10
|
+
chunks.filter(c=>c.isEntry).forEach(visit);
|
|
11
|
+
const initialModules=[...initial].flatMap(c=>Object.keys(c.modules));
|
|
12
|
+
assert.ok(!initialModules.some(id=>/node_modules\/(echarts|zrender)\//.test(id)),'ECharts must not be an entry dependency');
|
|
13
|
+
assert.ok(!initialModules.some(id=>/node_modules\/(antd|@rc-component\/picker)\//.test(id)),'optional filter UI must not be an entry dependency');
|
|
14
|
+
const modules=chunks.flatMap(c=>Object.keys(c.modules));
|
|
15
|
+
assert.ok(!modules.some(id=>id.includes('/tests/browser/')),'browser fixtures and their fetch replacement must not enter production');
|
|
16
|
+
const locales=new Set(modules.flatMap(id=>{const match=id.match(/date-fns\/locale\/([^/]+)/);return match?[match[1]]:[];}));
|
|
17
|
+
assert.deepEqual([...locales].filter(name=>!['en-US','zh-CN','_lib','en-US.js','zh-CN.js'].includes(name)),[],'do not bundle unused calendar languages');
|
|
18
|
+
for(const type of ['Bar','Line','Pie'])assert.ok(chunks.some(c=>c.isDynamicEntry&&Object.keys(c.modules).some(id=>id.endsWith(`/charts/${type}.tsx`))),`${type} has an independent lazy entry`);
|
|
19
|
+
});
|
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import {after,test} from 'node:test';
|
|
3
|
-
import {createServer} from 'vite';
|
|
4
|
-
import {createElement} from 'react';
|
|
5
|
-
import {renderToStaticMarkup} from 'react-dom/server';
|
|
6
|
-
const server=await createServer({configFile:false,cacheDir:'temp/vite-cockpit-tests',server:{middlewareMode:true,hmr:false}});
|
|
7
|
-
after(()=>server.close());
|
|
8
|
-
const client=await server.ssrLoadModule('/src/finebi/client.ts');
|
|
9
|
-
const data=await server.ssrLoadModule('/src/services/table-data.ts');
|
|
10
|
-
const queries=await server.ssrLoadModule('/src/services/widget-queries.ts');
|
|
11
|
-
const {Widget}=await server.ssrLoadModule('/src/components/Widget.tsx');
|
|
12
|
-
const config={schemaVersion:'1',title:'Test',basePath:'/bi',layout:'cockpit',dashboardId:'r',directoryEntryId:'directory',maxRows:1000,pageSize:8,
|
|
13
|
-
widgets:[{entryId:'a',name:'A',type:'table',filterNames:['month']},{entryId:'b',name:'B',type:'table',filterNames:[]}],filters:[{name:'month',label:'月份',type:'month'}]};
|
|
14
|
-
const native={previewResult:{resultType:'DETAIL',header:[],items:[],size:10,row:0,hasNext:false}};
|
|
15
|
-
test('retry is only exposed after a settled failure, never while an initial request is pending',()=>{
|
|
16
|
-
const props={binding:config.widgets[0],pageSize:8,onRetry:()=>{}};
|
|
17
|
-
assert.doesNotMatch(renderToStaticMarkup(createElement(Widget,{...props,state:{loading:true}})),/重试组件/);
|
|
18
|
-
assert.match(renderToStaticMarkup(createElement(Widget,{...props,state:{loading:false,error:new Error('fixture')}})),/重试组件/);
|
|
19
|
-
});
|
|
20
|
-
test('directory source and verified per-widget scope survive individual queries, never falling back',async()=>{
|
|
21
|
-
const sent=[];
|
|
22
|
-
const fetcher=async(url,init)=>{sent.push({url,...JSON.parse(init.body)});return new Response(JSON.stringify({success:true,data:{a:native,b:native}}));};
|
|
23
|
-
await client.queryWidget(config,config.widgets[0],{month:'2024-02'},undefined,fetcher);
|
|
24
|
-
await client.queryWidget(config,config.widgets[1],{month:'2024-02'},undefined,fetcher);
|
|
25
|
-
assert.deepEqual(sent.map(s=>s.reportEntryId),['directory','directory']);
|
|
26
|
-
assert.deepEqual(sent.map(s=>s.widgetIds),[['a'],['b']]);
|
|
27
|
-
assert.deepEqual(sent.map(s=>s.refer),[{month:['2024-02']},{}]);
|
|
28
|
-
let calls=0;
|
|
29
|
-
await assert.rejects(client.queryWidget(config,config.widgets[0],{},undefined,async()=>{calls++;return new Response('{}',{status:403});}),e=>e.code==='PERMISSION_DENIED');
|
|
30
|
-
assert.equal(calls,1);
|
|
31
|
-
});
|
|
32
|
-
test('component loader limits concurrency, publishes independent failures and respects cancellation',async()=>{
|
|
33
|
-
const updates=[];let active=0,peak=0;
|
|
34
|
-
const many={...config,widgets:[...config.widgets,...Array.from({length:6},(_,i)=>({entryId:'extra-'+i,name:'Extra',type:'table'}))]};
|
|
35
|
-
await queries.loadWidgets(many,{},new AbortController().signal,(id,state)=>updates.push([id,state]),async(_url,init)=>{
|
|
36
|
-
active++;peak=Math.max(peak,active);await new Promise(r=>setTimeout(r,5));active--;
|
|
37
|
-
const id=JSON.parse(init.body).widgetIds[0];return id==='a'?new Response('{}',{status:500}):new Response(JSON.stringify({success:true,data:{[id]:native}}));
|
|
38
|
-
});
|
|
39
|
-
assert.equal(peak,3);assert.ok(updates.find(([id,s])=>id==='0:a'&&s.error));assert.ok(updates.find(([id,s])=>id==='1:b'&&s.result));
|
|
40
|
-
const controller=new AbortController();controller.abort();let calls=0;
|
|
41
|
-
await queries.loadWidgets(config,{},controller.signal,()=>assert.fail('no stale update'),async()=>{calls++;});assert.equal(calls,0);
|
|
42
|
-
});
|
|
43
|
-
test('same native widget can have separate display scopes; aborted old responses publish nothing',async()=>{
|
|
44
|
-
const duplicate={...config,widgets:[config.widgets[0],{...config.widgets[0],filterNames:[]}]};
|
|
45
|
-
const complete=[];
|
|
46
|
-
await queries.loadWidgets(duplicate,{month:'2024-02'},new AbortController().signal,(key,state)=>{if(state.result)complete.push(key);},async()=>new Response(JSON.stringify({success:true,data:{a:native}})));
|
|
47
|
-
assert.deepEqual(complete.sort(),['0:a','1:a']);
|
|
48
|
-
const controller=new AbortController(),updates=[];
|
|
49
|
-
await queries.loadWidgets(config,{},controller.signal,(_key,state)=>updates.push(state),async()=>{controller.abort();return new Response(JSON.stringify({success:true,data:{a:native,b:native}}));});
|
|
50
|
-
assert.ok(updates.every(s=>s.loading));
|
|
51
|
-
});
|
|
52
|
-
const cell=(value,state='AVAILABLE',display=value)=>({value,display,state,reason:null});
|
|
53
|
-
const dataset={id:'main',returnedRowCount:4,columns:[{fieldId:'x',key:'c0',name:'名字',type:'STRING'},{fieldId:'v',key:'c1',name:'金额',type:'DECIMAL'}],rows:[
|
|
54
|
-
{kind:'DATA',cells:[cell('=HYPERLINK("x")'),cell('9007199254740993.01')]},
|
|
55
|
-
{kind:'DATA',cells:[cell('B'),cell('9007199254740993.02','AVAILABLE','9,007,199,254,740,993.02元')]},
|
|
56
|
-
{kind:'UNKNOWN',cells:[cell('C'),cell(null,'RESTRICTED','***')]},
|
|
57
|
-
{kind:'DATA',cells:[cell('D'),cell(null,'NULL',null)]},
|
|
58
|
-
]};
|
|
59
|
-
test('loaded table sorts decimals exactly, filters and exports matching rows with safe CSV and explicit states',()=>{
|
|
60
|
-
assert.equal(data.compareDecimal('9007199254740993.01','9007199254740993.02'),-1);
|
|
61
|
-
assert.equal(data.compareDecimal('-1.001e3','-1001'),0);
|
|
62
|
-
assert.equal(data.compareDecimal('1e-10','0.00000000009'),1);
|
|
63
|
-
assert.equal(data.compareDecimal('1.20','1.2'),0);
|
|
64
|
-
assert.equal(data.compareDecimal('1200','1.2e3'),0);
|
|
65
|
-
assert.equal(data.compareDecimal('-0.001200','-1.2e-3'),0);
|
|
66
|
-
const rows=data.tableRows(dataset,{search:'',sort:{fieldId:'v',direction:'desc'},filters:{}});
|
|
67
|
-
assert.equal(rows[0].cells[0].value,'B');assert.equal(rows[1].cells[0].value,'=HYPERLINK("x")');
|
|
68
|
-
const filtered=data.tableRows(dataset,{search:'',filters:{x:'B'}});assert.equal(filtered.length,1);
|
|
69
|
-
const csv=data.tableCsv(dataset,rows);
|
|
70
|
-
assert.match(csv,/'=HYPERLINK/);assert.match(csv,/受限/);assert.doesNotMatch(csv,/\*\*\*/);assert.match(csv,/UNKNOWN/);
|
|
71
|
-
assert.match(data.tableCsv(dataset,filtered),/9,007,199/);
|
|
72
|
-
assert.throws(()=>data.tableRows(dataset,{search:'',filters:{missing:'x'}}),/字段/);
|
|
73
|
-
});
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {after,test} from 'node:test';
|
|
3
|
+
import {createServer} from 'vite';
|
|
4
|
+
import {createElement} from 'react';
|
|
5
|
+
import {renderToStaticMarkup} from 'react-dom/server';
|
|
6
|
+
const server=await createServer({configFile:false,cacheDir:'temp/vite-cockpit-tests',server:{middlewareMode:true,hmr:false}});
|
|
7
|
+
after(()=>server.close());
|
|
8
|
+
const client=await server.ssrLoadModule('/src/finebi/client.ts');
|
|
9
|
+
const data=await server.ssrLoadModule('/src/services/table-data.ts');
|
|
10
|
+
const queries=await server.ssrLoadModule('/src/services/widget-queries.ts');
|
|
11
|
+
const {Widget}=await server.ssrLoadModule('/src/components/Widget.tsx');
|
|
12
|
+
const config={schemaVersion:'1',title:'Test',basePath:'/bi',layout:'cockpit',dashboardId:'r',directoryEntryId:'directory',maxRows:1000,pageSize:8,
|
|
13
|
+
widgets:[{entryId:'a',name:'A',type:'table',filterNames:['month']},{entryId:'b',name:'B',type:'table',filterNames:[]}],filters:[{name:'month',label:'月份',type:'month'}]};
|
|
14
|
+
const native={previewResult:{resultType:'DETAIL',header:[],items:[],size:10,row:0,hasNext:false}};
|
|
15
|
+
test('retry is only exposed after a settled failure, never while an initial request is pending',()=>{
|
|
16
|
+
const props={binding:config.widgets[0],pageSize:8,onRetry:()=>{}};
|
|
17
|
+
assert.doesNotMatch(renderToStaticMarkup(createElement(Widget,{...props,state:{loading:true}})),/重试组件/);
|
|
18
|
+
assert.match(renderToStaticMarkup(createElement(Widget,{...props,state:{loading:false,error:new Error('fixture')}})),/重试组件/);
|
|
19
|
+
});
|
|
20
|
+
test('directory source and verified per-widget scope survive individual queries, never falling back',async()=>{
|
|
21
|
+
const sent=[];
|
|
22
|
+
const fetcher=async(url,init)=>{sent.push({url,...JSON.parse(init.body)});return new Response(JSON.stringify({success:true,data:{a:native,b:native}}));};
|
|
23
|
+
await client.queryWidget(config,config.widgets[0],{month:'2024-02'},undefined,fetcher);
|
|
24
|
+
await client.queryWidget(config,config.widgets[1],{month:'2024-02'},undefined,fetcher);
|
|
25
|
+
assert.deepEqual(sent.map(s=>s.reportEntryId),['directory','directory']);
|
|
26
|
+
assert.deepEqual(sent.map(s=>s.widgetIds),[['a'],['b']]);
|
|
27
|
+
assert.deepEqual(sent.map(s=>s.refer),[{month:['2024-02']},{}]);
|
|
28
|
+
let calls=0;
|
|
29
|
+
await assert.rejects(client.queryWidget(config,config.widgets[0],{},undefined,async()=>{calls++;return new Response('{}',{status:403});}),e=>e.code==='PERMISSION_DENIED');
|
|
30
|
+
assert.equal(calls,1);
|
|
31
|
+
});
|
|
32
|
+
test('component loader limits concurrency, publishes independent failures and respects cancellation',async()=>{
|
|
33
|
+
const updates=[];let active=0,peak=0;
|
|
34
|
+
const many={...config,widgets:[...config.widgets,...Array.from({length:6},(_,i)=>({entryId:'extra-'+i,name:'Extra',type:'table'}))]};
|
|
35
|
+
await queries.loadWidgets(many,{},new AbortController().signal,(id,state)=>updates.push([id,state]),async(_url,init)=>{
|
|
36
|
+
active++;peak=Math.max(peak,active);await new Promise(r=>setTimeout(r,5));active--;
|
|
37
|
+
const id=JSON.parse(init.body).widgetIds[0];return id==='a'?new Response('{}',{status:500}):new Response(JSON.stringify({success:true,data:{[id]:native}}));
|
|
38
|
+
});
|
|
39
|
+
assert.equal(peak,3);assert.ok(updates.find(([id,s])=>id==='0:a'&&s.error));assert.ok(updates.find(([id,s])=>id==='1:b'&&s.result));
|
|
40
|
+
const controller=new AbortController();controller.abort();let calls=0;
|
|
41
|
+
await queries.loadWidgets(config,{},controller.signal,()=>assert.fail('no stale update'),async()=>{calls++;});assert.equal(calls,0);
|
|
42
|
+
});
|
|
43
|
+
test('same native widget can have separate display scopes; aborted old responses publish nothing',async()=>{
|
|
44
|
+
const duplicate={...config,widgets:[config.widgets[0],{...config.widgets[0],filterNames:[]}]};
|
|
45
|
+
const complete=[];
|
|
46
|
+
await queries.loadWidgets(duplicate,{month:'2024-02'},new AbortController().signal,(key,state)=>{if(state.result)complete.push(key);},async()=>new Response(JSON.stringify({success:true,data:{a:native}})));
|
|
47
|
+
assert.deepEqual(complete.sort(),['0:a','1:a']);
|
|
48
|
+
const controller=new AbortController(),updates=[];
|
|
49
|
+
await queries.loadWidgets(config,{},controller.signal,(_key,state)=>updates.push(state),async()=>{controller.abort();return new Response(JSON.stringify({success:true,data:{a:native,b:native}}));});
|
|
50
|
+
assert.ok(updates.every(s=>s.loading));
|
|
51
|
+
});
|
|
52
|
+
const cell=(value,state='AVAILABLE',display=value)=>({value,display,state,reason:null});
|
|
53
|
+
const dataset={id:'main',returnedRowCount:4,columns:[{fieldId:'x',key:'c0',name:'名字',type:'STRING'},{fieldId:'v',key:'c1',name:'金额',type:'DECIMAL'}],rows:[
|
|
54
|
+
{kind:'DATA',cells:[cell('=HYPERLINK("x")'),cell('9007199254740993.01')]},
|
|
55
|
+
{kind:'DATA',cells:[cell('B'),cell('9007199254740993.02','AVAILABLE','9,007,199,254,740,993.02元')]},
|
|
56
|
+
{kind:'UNKNOWN',cells:[cell('C'),cell(null,'RESTRICTED','***')]},
|
|
57
|
+
{kind:'DATA',cells:[cell('D'),cell(null,'NULL',null)]},
|
|
58
|
+
]};
|
|
59
|
+
test('loaded table sorts decimals exactly, filters and exports matching rows with safe CSV and explicit states',()=>{
|
|
60
|
+
assert.equal(data.compareDecimal('9007199254740993.01','9007199254740993.02'),-1);
|
|
61
|
+
assert.equal(data.compareDecimal('-1.001e3','-1001'),0);
|
|
62
|
+
assert.equal(data.compareDecimal('1e-10','0.00000000009'),1);
|
|
63
|
+
assert.equal(data.compareDecimal('1.20','1.2'),0);
|
|
64
|
+
assert.equal(data.compareDecimal('1200','1.2e3'),0);
|
|
65
|
+
assert.equal(data.compareDecimal('-0.001200','-1.2e-3'),0);
|
|
66
|
+
const rows=data.tableRows(dataset,{search:'',sort:{fieldId:'v',direction:'desc'},filters:{}});
|
|
67
|
+
assert.equal(rows[0].cells[0].value,'B');assert.equal(rows[1].cells[0].value,'=HYPERLINK("x")');
|
|
68
|
+
const filtered=data.tableRows(dataset,{search:'',filters:{x:'B'}});assert.equal(filtered.length,1);
|
|
69
|
+
const csv=data.tableCsv(dataset,rows);
|
|
70
|
+
assert.match(csv,/'=HYPERLINK/);assert.match(csv,/受限/);assert.doesNotMatch(csv,/\*\*\*/);assert.match(csv,/UNKNOWN/);
|
|
71
|
+
assert.match(data.tableCsv(dataset,filtered),/9,007,199/);
|
|
72
|
+
assert.throws(()=>data.tableRows(dataset,{search:'',filters:{missing:'x'}}),/字段/);
|
|
73
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {after,test} from 'node:test';
|
|
3
|
+
import {createServer as createHttpServer} from 'node:http';
|
|
4
|
+
import {createServer} from 'vite';
|
|
5
|
+
import {configErrors} from '../src/finebi/config.js';
|
|
6
|
+
import {createDevMiddleware} from '../scripts/dev-proxy.mjs';
|
|
7
|
+
const server=await createServer({configFile:false,cacheDir:'temp/vite-field-selection-tests',server:{middlewareMode:true,hmr:false}});
|
|
8
|
+
after(()=>server.close());
|
|
9
|
+
const {queryWidget,queryWidgets}=await server.ssrLoadModule('/src/finebi/client.ts');
|
|
10
|
+
const binding={entryId:'w',name:'Table',type:'table'};
|
|
11
|
+
const config={schemaVersion:'1',title:'Test',basePath:'/bi',layout:'overview',dashboardId:'r',maxRows:10,pageSize:5,widgets:[binding],filters:[]};
|
|
12
|
+
const response=()=>new Response(JSON.stringify({success:true,data:{w:{previewResult:{resultType:'DETAIL',header:[],items:[],row:0}}}}));
|
|
13
|
+
|
|
14
|
+
test('optional dimensionIds validates lists without confusing omission and empty selection',()=>{
|
|
15
|
+
for(const ids of [undefined,[],['city'],['sales'],['city','sales']]) {
|
|
16
|
+
const widget=ids===undefined?binding:{...binding,dimensionIds:ids};
|
|
17
|
+
assert.deepEqual(configErrors({...config,widgets:[widget]}),[]);
|
|
18
|
+
}
|
|
19
|
+
for(const ids of [null,'sales',[1],[''],[' '],[null],['sales','sales']]) {
|
|
20
|
+
assert.ok(configErrors({...config,widgets:[{...binding,dimensionIds:ids}]}).some(e=>e.includes('dimensionIds')));
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('binding selection reaches ordinary and published queries unchanged, including omission',async()=>{
|
|
25
|
+
for(const directoryEntryId of [undefined,'directory']) {
|
|
26
|
+
const widgets=[binding,...[[],['city'],['sales'],['city','sales']].map(dimensionIds=>({...binding,dimensionIds}))];
|
|
27
|
+
const app={...config,...(directoryEntryId?{directoryEntryId}:{}),widgets};
|
|
28
|
+
for(const widget of widgets) {
|
|
29
|
+
await queryWidget(app,widget,{},undefined,async(_url,init)=>{
|
|
30
|
+
const body=JSON.parse(init.body);
|
|
31
|
+
assert.deepEqual(body.dimensionSelections,widget.dimensionIds===undefined?undefined:{w:widget.dimensionIds});
|
|
32
|
+
assert.equal(Object.hasOwn(body,'dimensionSelections'),widget.dimensionIds!==undefined);
|
|
33
|
+
assert.equal(body.reportEntryId,directoryEntryId);
|
|
34
|
+
return response();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
await assert.rejects(queryWidgets(app,{},undefined,()=>assert.fail('conflicting bindings must not fetch')),/selection|选择/);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('invalid selection fails before browser HTTP and matching batch selections can share an instance',async()=>{
|
|
42
|
+
await assert.rejects(queryWidgets({...config,widgets:[{...binding,dimensionIds:null}]},{},undefined,()=>assert.fail('invalid config must not fetch')),/dimensionIds/);
|
|
43
|
+
const widgets=[{...binding,dimensionIds:['city','sales']},{...binding,dimensionIds:['sales','city']}];
|
|
44
|
+
await queryWidgets({...config,widgets},{},undefined,async(_url,init)=>{
|
|
45
|
+
const body=JSON.parse(init.body);assert.deepEqual(body.widgetIds,['w']);
|
|
46
|
+
assert.deepEqual(body.dimensionSelections,{w:['city','sales']});return response();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('proxy preserves configured choices and rejects malformed or unconfigured selections before upstream',async()=>{
|
|
51
|
+
const sent=[];
|
|
52
|
+
const widgets=[binding,...[[],['sales'],['city','sales']].map(dimensionIds=>({...binding,dimensionIds}))];
|
|
53
|
+
const middleware=createDevMiddleware({...config,directoryEntryId:'directory',widgets},
|
|
54
|
+
{FINEBI_DEV_BASE_URL:'http://bi.example/bi',FINEBI_DEV_TOKEN:'secret'},async(_url,init)=>{sent.push(JSON.parse(init.body));return response();});
|
|
55
|
+
const http=createHttpServer((req,res)=>middleware(req,res,()=>{}));
|
|
56
|
+
await new Promise(resolve=>http.listen(0,'127.0.0.1',resolve));
|
|
57
|
+
const send=extra=>fetch(`http://127.0.0.1:${http.address().port}/bi/v5/api/dashboard/report/r/widgets/query`,{
|
|
58
|
+
method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:10,reportEntryId:'directory',...extra})});
|
|
59
|
+
try {
|
|
60
|
+
for(const selections of [null,[],{other:['sales']},{w:null},{w:['']},{w:['sales','sales']},{w:['unknown']},{w:['city']}]) {
|
|
61
|
+
assert.equal((await send({dimensionSelections:selections})).status,400);
|
|
62
|
+
}
|
|
63
|
+
assert.equal(sent.length,0);
|
|
64
|
+
for(const selections of [undefined,{w:[]},{w:['sales']},{w:['city','sales']}]) {
|
|
65
|
+
assert.equal((await send(selections===undefined?{}:{dimensionSelections:selections})).status,200);
|
|
66
|
+
assert.deepEqual(sent.at(-1).dimensionSelections,selections);
|
|
67
|
+
assert.equal(sent.at(-1).reportEntryId,'directory');
|
|
68
|
+
}
|
|
69
|
+
} finally {middleware.dispose?.();await new Promise(resolve=>http.close(resolve));}
|
|
70
|
+
});
|
|
@@ -12,7 +12,7 @@ test('directory proxy pins the published entry and never forwards another source
|
|
|
12
12
|
try {
|
|
13
13
|
for(const body of [{},{reportEntryId:'other'},{directoryEntryId:'entry'}])assert.equal((await send(body)).status,400);
|
|
14
14
|
assert.equal(sent.length,0);assert.equal((await send({reportEntryId:'entry'})).status,200);assert.equal(sent[0].reportEntryId,'entry');
|
|
15
|
-
}finally{await new Promise(r=>server.close(r));}
|
|
15
|
+
}finally{middleware.dispose();await new Promise(r=>server.close(r));}
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
test('development identity stays on server and cross-site or out-of-scope requests are rejected', async () => {
|
|
@@ -20,7 +20,7 @@ test('development identity stays on server and cross-site or out-of-scope reques
|
|
|
20
20
|
const middleware = createDevMiddleware({basePath:'/webroot/decision',dashboardId:'r',widgets:[{entryId:'w'}],maxRows:1000}, {FINEBI_DEV_BASE_URL:'http://bi.example/webroot/decision',FINEBI_DEV_USERNAME:'tester',FINEBI_DEV_PASSWORD:'dev-secret'}, async (url,init) => {
|
|
21
21
|
upstream.push({url,init});
|
|
22
22
|
return new Response(JSON.stringify(url.endsWith('/login') ? {data:{accessToken:'session-secret'}} : {success:true,data:{w:{previewResult:{resultType:'DETAIL'}}}}));
|
|
23
|
-
});
|
|
23
|
+
}, {save:async()=>{},notify:()=>{}});
|
|
24
24
|
const server=createServer((req,res)=>middleware(req,res,()=>{res.writeHead(404);res.end();}));
|
|
25
25
|
await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
|
26
26
|
const base=`http://127.0.0.1:${server.address().port}`;
|
|
@@ -44,16 +44,31 @@ test('development identity stays on server and cross-site or out-of-scope reques
|
|
|
44
44
|
assert.equal(upstream.length,3,'reuse only the in-memory development session');
|
|
45
45
|
} finally {await new Promise(r=>server.close(r));}
|
|
46
46
|
});
|
|
47
|
-
test('HTML login response
|
|
47
|
+
test('HTML login response renews the session and retries native query once', async()=>{
|
|
48
48
|
let logins=0, queries=0;
|
|
49
49
|
const middleware=createDevMiddleware({basePath:'/webroot/decision',dashboardId:'r',widgets:[{entryId:'w'}],filters:[],maxRows:1000},{FINEBI_DEV_BASE_URL:'http://bi.example/webroot/decision',FINEBI_DEV_USERNAME:'tester',FINEBI_DEV_PASSWORD:'secret'},async url=>{
|
|
50
50
|
if(url.endsWith('/login')){logins++;return new Response(JSON.stringify({data:{accessToken:'token-'+logins}}));}
|
|
51
51
|
if(++queries===1)return new Response('<html>登录</html>',{headers:{'content-type':'text/html'}});
|
|
52
52
|
return new Response(JSON.stringify({success:true,data:{w:{previewResult:{resultType:'DETAIL'}}}}));
|
|
53
|
-
});
|
|
53
|
+
}, {save:async()=>{},notify:()=>{}});
|
|
54
54
|
const server=createServer((req,res)=>middleware(req,res,()=>{}));await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
|
55
55
|
const send=()=>fetch(`http://127.0.0.1:${server.address().port}/webroot/decision/v5/api/dashboard/report/r/widgets/query`,{method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:10})});
|
|
56
|
-
try{
|
|
56
|
+
try{assert.equal((await send()).status,200);assert.equal(logins,2);assert.equal(queries,2);}finally{await new Promise(r=>server.close(r));}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('permission errors never renew and repeated expiry stops after one retry',async()=>{
|
|
60
|
+
for(const status of [403,401]) {
|
|
61
|
+
let queries=0,logins=0;
|
|
62
|
+
const middleware=createDevMiddleware({basePath:'/bi',dashboardId:'r',widgets:[{entryId:'w'}],maxRows:1000},
|
|
63
|
+
{FINEBI_DEV_BASE_URL:'http://localhost/bi',FINEBI_DEV_TOKEN:'cached',FINEBI_DEV_USERNAME:'alice',FINEBI_DEV_PASSWORD:'secret'},
|
|
64
|
+
async url=>{if(url.endsWith('/login')){logins++;return new Response(JSON.stringify({data:{accessToken:'new'}}));}
|
|
65
|
+
queries++;return new Response('{}',{status});},{save:async()=>{},notify:()=>{}});
|
|
66
|
+
const server=createServer((req,res)=>middleware(req,res,()=>{}));await new Promise(r=>server.listen(0,'127.0.0.1',r));
|
|
67
|
+
try {
|
|
68
|
+
const response=await fetch(`http://127.0.0.1:${server.address().port}/bi/v5/api/dashboard/report/r/widgets/query`,{method:'POST',headers:{'X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:10})});
|
|
69
|
+
assert.equal(response.status,status);assert.equal(queries,status===403?1:2);assert.equal(logins,status===403?0:1);
|
|
70
|
+
} finally {middleware.dispose?.();await new Promise(r=>server.close(r));}
|
|
71
|
+
}
|
|
57
72
|
});
|
|
58
73
|
test('missing credentials gives an explicit auth error without upstream access',async()=>{
|
|
59
74
|
const middleware=createDevMiddleware({basePath:'/webroot/decision',dashboardId:'r',widgets:[{entryId:'w'}],maxRows:1000},{},()=>{throw Error('must not fetch');});
|
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import {after,test} from 'node:test';
|
|
3
|
-
import {createServer} from 'vite';
|
|
4
|
-
const server=await createServer({configFile:false,cacheDir:'temp/vite-retail-tests',server:{middlewareMode:true,hmr:false}});
|
|
5
|
-
after(()=>server.close());
|
|
6
|
-
const {formatMetric,formatCategory}=await server.ssrLoadModule('/src/services/presentation.ts');
|
|
7
|
-
const {configErrors}=await import('../src/finebi/config.js');
|
|
8
|
-
const cell=(value,state='AVAILABLE')=>({value,display:null,state,reason:null});
|
|
9
|
-
test('KPI display preserves decimal precision and formats ratios only by explicit configuration',()=>{
|
|
10
|
-
assert.equal(formatMetric(cell('9007199254740993.01'),'number'),'9,007,199,254,740,993.01');
|
|
11
|
-
assert.equal(formatMetric(cell('1.2345678e7'),'number'),'12,345,678');
|
|
12
|
-
assert.equal(formatMetric(cell('0.123456789'),'percent'),'12.35%');
|
|
13
|
-
assert.equal(formatMetric(cell('-0.00005'),'percent'),'-0.01%');
|
|
14
|
-
assert.equal(formatMetric(cell('42.0'),'number'),'42');
|
|
15
|
-
assert.equal(formatMetric(cell('100','RESTRICTED'),'number'),'受限');
|
|
16
|
-
assert.equal(formatMetric(cell(null,'NULL'),'percent'),'—');
|
|
17
|
-
assert.equal(formatMetric(cell('0.5')),'0.5');
|
|
18
|
-
});
|
|
19
|
-
test('month formatting is explicit, timezone-aware and preserves invalid or restricted values',()=>{
|
|
20
|
-
const binding={categoryFormat:'epoch-month',categoryTimeZone:'Asia/Shanghai'};
|
|
21
|
-
const timestamp=String(Date.UTC(2024,1,29,16));
|
|
22
|
-
assert.equal(formatCategory(cell(timestamp),binding),'2024-03');
|
|
23
|
-
assert.equal(formatCategory(cell(timestamp),{}),timestamp);
|
|
24
|
-
assert.equal(formatCategory(cell('not-a-date'),binding),'not-a-date');
|
|
25
|
-
assert.equal(formatCategory(cell(timestamp,'RESTRICTED'),binding),'受限');
|
|
26
|
-
});
|
|
27
|
-
test('display configuration rejects implicit date timezone and invalid formats',()=>{
|
|
28
|
-
const config={schemaVersion:'1',title:'Test',basePath:'/bi',layout:'retail',dashboardId:'r',maxRows:1000,pageSize:8,filters:[],widgets:[{entryId:'w',name:'Trend',type:'line',categoryFieldId:'x',valueFieldId:'y',categoryFormat:'epoch-month',categoryTimeZone:'Asia/Shanghai'}]};
|
|
29
|
-
assert.deepEqual(configErrors(config),[]);
|
|
30
|
-
for(const patch of [{categoryTimeZone:undefined},{categoryTimeZone:'Bad/Timezone'},{categoryFormat:'guess'},{valueFormat:'guess'}])assert.ok(configErrors({...config,widgets:[{...config.widgets[0],...patch}]}).length);
|
|
31
|
-
});
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {after,test} from 'node:test';
|
|
3
|
+
import {createServer} from 'vite';
|
|
4
|
+
const server=await createServer({configFile:false,cacheDir:'temp/vite-retail-tests',server:{middlewareMode:true,hmr:false}});
|
|
5
|
+
after(()=>server.close());
|
|
6
|
+
const {formatMetric,formatCategory}=await server.ssrLoadModule('/src/services/presentation.ts');
|
|
7
|
+
const {configErrors}=await import('../src/finebi/config.js');
|
|
8
|
+
const cell=(value,state='AVAILABLE')=>({value,display:null,state,reason:null});
|
|
9
|
+
test('KPI display preserves decimal precision and formats ratios only by explicit configuration',()=>{
|
|
10
|
+
assert.equal(formatMetric(cell('9007199254740993.01'),'number'),'9,007,199,254,740,993.01');
|
|
11
|
+
assert.equal(formatMetric(cell('1.2345678e7'),'number'),'12,345,678');
|
|
12
|
+
assert.equal(formatMetric(cell('0.123456789'),'percent'),'12.35%');
|
|
13
|
+
assert.equal(formatMetric(cell('-0.00005'),'percent'),'-0.01%');
|
|
14
|
+
assert.equal(formatMetric(cell('42.0'),'number'),'42');
|
|
15
|
+
assert.equal(formatMetric(cell('100','RESTRICTED'),'number'),'受限');
|
|
16
|
+
assert.equal(formatMetric(cell(null,'NULL'),'percent'),'—');
|
|
17
|
+
assert.equal(formatMetric(cell('0.5')),'0.5');
|
|
18
|
+
});
|
|
19
|
+
test('month formatting is explicit, timezone-aware and preserves invalid or restricted values',()=>{
|
|
20
|
+
const binding={categoryFormat:'epoch-month',categoryTimeZone:'Asia/Shanghai'};
|
|
21
|
+
const timestamp=String(Date.UTC(2024,1,29,16));
|
|
22
|
+
assert.equal(formatCategory(cell(timestamp),binding),'2024-03');
|
|
23
|
+
assert.equal(formatCategory(cell(timestamp),{}),timestamp);
|
|
24
|
+
assert.equal(formatCategory(cell('not-a-date'),binding),'not-a-date');
|
|
25
|
+
assert.equal(formatCategory(cell(timestamp,'RESTRICTED'),binding),'受限');
|
|
26
|
+
});
|
|
27
|
+
test('display configuration rejects implicit date timezone and invalid formats',()=>{
|
|
28
|
+
const config={schemaVersion:'1',title:'Test',basePath:'/bi',layout:'retail',dashboardId:'r',maxRows:1000,pageSize:8,filters:[],widgets:[{entryId:'w',name:'Trend',type:'line',categoryFieldId:'x',valueFieldId:'y',categoryFormat:'epoch-month',categoryTimeZone:'Asia/Shanghai'}]};
|
|
29
|
+
assert.deepEqual(configErrors(config),[]);
|
|
30
|
+
for(const patch of [{categoryTimeZone:undefined},{categoryTimeZone:'Bad/Timezone'},{categoryFormat:'guess'},{valueFormat:'guess'}])assert.ok(configErrors({...config,widgets:[{...config.widgets[0],...patch}]}).length);
|
|
31
|
+
});
|
|
@@ -73,6 +73,13 @@ test('auth expiry, permission denial, login HTML and missing widget stay distinc
|
|
|
73
73
|
await assert.rejects(queryWidgets(config,{},undefined,async()=>response), e=>e instanceof FineBIError && e.code===code);
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
|
+
|
|
77
|
+
test('development authorization failures explain the next action without revealing credentials',async()=>{
|
|
78
|
+
for(const code of ['AUTH_PLUGIN_REQUIRED','AUTH_HTTPS_REQUIRED','AUTH_PROTOCOL_ERROR','AUTH_UNAVAILABLE','AUTH_STORAGE_FAILED']) {
|
|
79
|
+
await assert.rejects(queryWidgets(config,{},undefined,async()=>new Response(JSON.stringify({success:false,errorCode:code}),{status:502})),
|
|
80
|
+
error=>error.code===code && /开发终端/.test(error.message));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
76
83
|
test('view binding requires exact fields and an explicit model for multi-model results', () => {
|
|
77
84
|
const source = {previewResult:{resultType:'CHART',geoms:[{dataModel:{fields:[{id:'x',name:'地区',fieldType:'DIMENSION'},{id:'y',name:'金额',fieldType:'MEASURE'}],colData:[['East','West'],[12,5]],rowCount:2}}]}};
|
|
78
85
|
const r=normalize(source);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {test} from 'node:test';
|
|
3
|
+
import {mkdtemp, mkdir, readFile, writeFile, stat, symlink} from 'node:fs/promises';
|
|
4
|
+
import {resolve} from 'node:path';
|
|
5
|
+
import {createDevSession} from '../scripts/dev-session.mjs';
|
|
6
|
+
import {saveSession} from '../scripts/session-file.mjs';
|
|
7
|
+
import {browserSession} from '../scripts/browser-session.mjs';
|
|
8
|
+
|
|
9
|
+
const baseUrl='https://bi.example/decision';
|
|
10
|
+
const env={FINEBI_DEV_BASE_URL:baseUrl,FINEBI_DEV_USERNAME:'alice',FINEBI_DEV_PASSWORD:'password'};
|
|
11
|
+
const json=(body,status=200)=>new Response(JSON.stringify(body),{status,headers:{'content-type':'application/json'}});
|
|
12
|
+
const no=()=>{throw Error('unexpected call');};
|
|
13
|
+
|
|
14
|
+
test('query arriving during invalidation waits for file clearing then acquires a session',async()=>{
|
|
15
|
+
let release;
|
|
16
|
+
const blocked=new Promise(r=>{release=r;});
|
|
17
|
+
const session=createDevSession({...env,FINEBI_DEV_TOKEN:'old'}, {fetchImpl:async()=>json({data:{accessToken:'new'}}),
|
|
18
|
+
browser:no,save:async(_url,token)=>{if(!token)await blocked;}});
|
|
19
|
+
const clearing=session.invalidate('old');const next=session.get();release();await clearing;
|
|
20
|
+
assert.equal(await next,'new');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('valid cached session wins; concurrent acquisition prefers password and persists once',async()=>{
|
|
24
|
+
const cached=createDevSession({...env,FINEBI_DEV_TOKEN:'cached'},{fetchImpl:no,browser:no,save:no});
|
|
25
|
+
assert.equal(await cached.get(),'cached');
|
|
26
|
+
const saves=[];
|
|
27
|
+
const session=createDevSession(env,{fetchImpl:async(url,init)=>{
|
|
28
|
+
assert.equal(url,baseUrl+'/login');assert.equal(JSON.parse(init.body).username,'alice');
|
|
29
|
+
return json({data:{accessToken:'password-session'}});
|
|
30
|
+
},browser:no,save:async(...args)=>saves.push(args)});
|
|
31
|
+
assert.deepEqual(await Promise.all([session.get(),session.get()]),['password-session','password-session']);
|
|
32
|
+
assert.equal(saves.length,1);assert.equal(saves[0][1],'password-session');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('password rejection uses browser once, persists identity and does not retry rejected password',async()=>{
|
|
36
|
+
let passwords=0,browsers=0;
|
|
37
|
+
const saves=[];
|
|
38
|
+
const session=createDevSession(env,{fetchImpl:async()=>{passwords++;return json({errorCode:'PASSWORD_ERROR'},401);},
|
|
39
|
+
browser:async()=>({token:'sso-'+(++browsers),username:'Bob'}),save:async(...args)=>saves.push(args)});
|
|
40
|
+
assert.equal(await session.get(),'sso-1');await session.invalidate('sso-1');
|
|
41
|
+
assert.equal(await session.get(),'sso-2');assert.equal(passwords,1);assert.equal(browsers,2);
|
|
42
|
+
assert.equal(saves.at(-1)[2],'Bob');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('network and upstream server errors never trigger browser authorization',async()=>{
|
|
46
|
+
for(const fetchImpl of [async()=>{throw Error('socket');},async()=>json({},503)]) {
|
|
47
|
+
const s=createDevSession(env,{fetchImpl,browser:no,save:no});
|
|
48
|
+
await assert.rejects(s.get(),/UPSTREAM_UNAVAILABLE/);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('stale failures do not clear fresh token; persisted token is bound to server',async()=>{
|
|
53
|
+
const saves=[];
|
|
54
|
+
const s=createDevSession({...env,FINEBI_DEV_TOKEN:'old'},{fetchImpl:async()=>json({data:{accessToken:'new'}}),browser:no,save:async(...v)=>saves.push(v)});
|
|
55
|
+
await s.invalidate('old');assert.equal(await s.get(),'new');await s.invalidate('old');assert.equal(await s.get(),'new');
|
|
56
|
+
const changed=createDevSession({...env,FINEBI_DEV_TOKEN:'wrong-server',FINEBI_DEV_TOKEN_BASE_URL:'https://other.example/decision'},
|
|
57
|
+
{fetchImpl:async()=>json({data:{accessToken:'right-server'}}),browser:no,save:async()=>{}});
|
|
58
|
+
assert.equal(await changed.get(),'right-server');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('session file preserves other configuration, replaces duplicate tokens and restricts access',async()=>{
|
|
62
|
+
const root=resolve('temp');await mkdir(root,{recursive:true});const dir=await mkdtemp(resolve(root,'auth-file-'));
|
|
63
|
+
const path=resolve(dir,'.env.local');
|
|
64
|
+
await writeFile(path,'# own config\nCUSTOM="keep"\nFINEBI_DEV_PASSWORD="keep-password"\nFINEBI_DEV_TOKEN=old\nexport FINEBI_DEV_TOKEN=duplicate\n');
|
|
65
|
+
await saveSession(path,baseUrl,'new-session','Alice');
|
|
66
|
+
const content=await readFile(path,'utf8');
|
|
67
|
+
assert.match(content,/CUSTOM="keep"/);assert.match(content,/PASSWORD="keep-password"/);
|
|
68
|
+
assert.equal((content.match(/^FINEBI_DEV_TOKEN=/gm)||[]).length,1);assert.doesNotMatch(content,/duplicate|=old/);
|
|
69
|
+
assert.match(content,/FINEBI_DEV_TOKEN_BASE_URL="https:\/\/bi.example\/decision"/);
|
|
70
|
+
if(process.platform!=='win32')assert.equal((await stat(path)).mode & 0o777,0o600);
|
|
71
|
+
await saveSession(path,baseUrl,'','');assert.match(await readFile(path,'utf8'),/FINEBI_DEV_TOKEN=""/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('session persistence refuses symlinks', {skip:process.platform==='win32'}, async()=>{
|
|
75
|
+
await mkdir('temp',{recursive:true});const dir=await mkdtemp(resolve('temp','auth-link-'));
|
|
76
|
+
const target=resolve(dir,'other');await writeFile(target,'unchanged');await symlink(target,resolve(dir,'.env.local'));
|
|
77
|
+
await assert.rejects(saveSession(resolve(dir,'.env.local'),baseUrl,'secret','Alice'));
|
|
78
|
+
assert.equal(await readFile(target,'utf8'),'unchanged');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('browser handoff pins verification URL and keeps retrieval secret out of notices',async()=>{
|
|
82
|
+
const notices=[],opened=[],calls=[];
|
|
83
|
+
const result=await browserSession(baseUrl,{notify:text=>notices.push(text),openBrowser:url=>opened.push(url),sleep:async()=>{},
|
|
84
|
+
fetchImpl:async(url,init)=>{calls.push({url,init});return url.endsWith('/start')
|
|
85
|
+
?json({data:{deviceCode:'d'.repeat(43),userCode:'ABCD-EFGH',verificationPath:'/api/bi-cli/dev-session-auth/authorize?code=ABCD-EFGH',expiresIn:600,interval:5}})
|
|
86
|
+
:json({data:{status:'approved',token:'session-secret',username:'Alice'}});}});
|
|
87
|
+
assert.equal(result.token,'session-secret');assert.equal(opened[0],baseUrl+'/api/bi-cli/dev-session-auth/authorize?code=ABCD-EFGH');
|
|
88
|
+
assert.doesNotMatch(notices.join(' '),/session-secret|dddddddd/);
|
|
89
|
+
assert.equal(JSON.parse(calls[1].init.body).deviceCode,'d'.repeat(43));
|
|
90
|
+
await assert.rejects(browserSession(baseUrl,{notify:no,openBrowser:no,fetchImpl:async()=>json({data:{deviceCode:'d'.repeat(43),userCode:'ABCD-EFGH',verificationPath:'https://evil.example/',expiresIn:600,interval:5}})}),/AUTH_PROTOCOL_ERROR/);
|
|
91
|
+
});
|