@finebi/finebi-cli 1.6.1 → 1.6.3
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/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 +18 -6
- 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 +1 -1
- 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 +8 -5
- 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 +14 -1
- 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/gitignore.template +6 -0
- 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 +8 -1
- 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/Widget.tsx +1 -0
- 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 +2 -0
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +15 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +159 -1
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +8 -6
- package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +1 -1
- 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/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/vite.config.ts +9 -4
- package/src/app.js +18 -6
- package/src/browser-auth.js +3 -4
- package/src/config.js +1 -1
- package/src/error-catalog.js +2 -2
- package/src/help.js +8 -5
- package/templates/web-app/AGENTS.md +18 -5
- package/templates/web-app/README.md +12 -5
- package/templates/web-app/docs/acceptance.md +14 -1
- 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/gitignore.template +6 -0
- 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 +8 -1
- package/templates/web-app/src/components/DataTable.tsx +1 -1
- package/templates/web-app/src/components/Widget.tsx +1 -0
- package/templates/web-app/src/finebi/client.ts +20 -4
- package/templates/web-app/src/finebi/config.d.ts +2 -0
- package/templates/web-app/src/finebi/config.js +15 -1
- package/templates/web-app/src/finebi/normalize.ts +159 -1
- package/templates/web-app/src/finebi/types.ts +8 -6
- package/templates/web-app/src/retail.css +1 -1
- 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/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/vite.config.ts +9 -4
|
@@ -10,6 +10,13 @@ const FilterBar=lazy(()=>import('./components/FilterBar').then(module=>({default
|
|
|
10
10
|
function Dashboard({config}: {config: AppConfig}) {
|
|
11
11
|
const [filters,setFilters]=useState<FilterState>({}),[refresh,setRefresh]=useState(0);
|
|
12
12
|
const [states,setStates]=useState<Record<string,WidgetState>>({});
|
|
13
|
+
const [activeHash,setActiveHash]=useState(()=>window.location.hash||'#dashboard');
|
|
14
|
+
useEffect(()=>{
|
|
15
|
+
const syncHash=()=>setActiveHash(window.location.hash||'#dashboard');
|
|
16
|
+
window.addEventListener('hashchange',syncHash);
|
|
17
|
+
syncHash();
|
|
18
|
+
return ()=>window.removeEventListener('hashchange',syncHash);
|
|
19
|
+
},[]);
|
|
13
20
|
const generation=useRef(0),retries=useRef(new Map<string,AbortController>());
|
|
14
21
|
useEffect(()=>{
|
|
15
22
|
const controller=new AbortController();generation.current++;setStates({});
|
|
@@ -34,7 +41,7 @@ function Dashboard({config}: {config: AppConfig}) {
|
|
|
34
41
|
const cockpit=config.layout==='cockpit';
|
|
35
42
|
const separateKpis=cockpit||retail;
|
|
36
43
|
return <div className={`app ${config.layout}`}>
|
|
37
|
-
{retail&&<nav className="retail-nav" aria-label="页面导航"><a className="retail-brand" href="#dashboard"><b>F.</b><span>FineBI <small>经营简报</small></span></a><div><a href="#dashboard">经营概览</a><a href="#components">销售分析</a><a href="#data-notes">口径说明</a></div><span className="retail-live">动态数据</span></nav>}
|
|
44
|
+
{retail&&<nav className="retail-nav" aria-label="页面导航"><a className="retail-brand" href="#dashboard"><b>F.</b><span>FineBI <small>经营简报</small></span></a><div><a href="#dashboard" aria-current={activeHash==='#dashboard'?'location':undefined}>经营概览</a><a href="#components" aria-current={activeHash==='#components'?'location':undefined}>销售分析</a><a href="#data-notes" aria-current={activeHash==='#data-notes'?'location':undefined}>口径说明</a></div><span className="retail-live">动态数据</span></nav>}
|
|
38
45
|
{config.layout!=='overview'&&!retail&&<aside><strong>FineBI<span>数据应用</span></strong><nav><a href="#dashboard">数据概览</a><a href="#components">分析组件</a>{config.insights&&<a href="#observations">经营观察</a>}<a href="#data-notes">数据口径</a></nav><p className="source-label">{config.directoryEntryId?'目录发布来源':'分析主题来源'}<br/>访问者权限由 FineBI 控制</p></aside>}
|
|
39
46
|
<main id="dashboard"><header><div><span className="eyebrow">FINEBI · LIVE DATA</span><h1>{config.title}</h1><p className="muted">{config.description??'基于源看板的实时数据,按当前访问权限呈现'}</p></div><div className="header-actions"><a href={retail?'#data-notes':config.basePath} target={retail?undefined:'_blank'} rel="noreferrer">{retail?'口径说明':'打开 FineBI'}</a><button onClick={()=>setRefresh(n=>n+1)}>刷新数据</button></div></header>
|
|
40
47
|
{config.filters.length>0&&<Boundary><Suspense fallback={<p role="status">正在加载筛选控件…</p>}><FilterBar bindings={config.filters} onApply={setFilters} cockpit={cockpit} scope={scope} compact={retail}/></Suspense></Boundary>}
|
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx
CHANGED
|
@@ -15,7 +15,7 @@ export function DataTable({dataset, pageSize,binding}: {dataset: Dataset; pageSi
|
|
|
15
15
|
const download=()=>{const url=URL.createObjectURL(new Blob([tableCsv(visible,shown)],{type:'text/csv;charset=utf-8'}));const link=document.createElement('a');link.href=url;link.download='loaded-results.csv';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
|
16
16
|
return <><div className="table-tools"><label>搜索已加载数据 <input placeholder="搜索已加载数据" value={search} onChange={e => {setSearch(e.target.value);setPage(0);}}/></label><span>已加载 {dataset.returnedRowCount} 行 · 匹配 {rows.length} 行</span><button onClick={download}>导出匹配结果 CSV</button></div>
|
|
17
17
|
{(binding.localFilterFieldIds?.length??0)>0&&<div className="local-filters">{binding.localFilterFieldIds!.map(id=>{const index=fieldIndex(dataset,id);return <label key={id}>{dataset.columns[index].name}(已加载值)<select value={filters[id]??''} onChange={e=>{setFilters(old=>({...old,[id]:e.target.value}));setPage(0);}}><option value="">全部已加载值</option>{[...new Set(dataset.rows.map(row=>displayCell(row.cells[index])))].filter(Boolean).map(value=><option key={value}>{value}</option>)}</select></label>;})}</div>}
|
|
18
|
-
<div className="table-scroll"><table><thead><tr><th>序号</th>{visible.columns.map(c => <th key={c.key} aria-sort={sort?.fieldId===c.fieldId?(sort.direction==='asc'?'ascending':'descending'):'none'}><button className="sort-column" onClick={()=>{setSort({fieldId:c.fieldId,direction:sort?.fieldId===c.fieldId&&sort.direction==='asc'?'desc':'asc'});setPage(0);}}>{c.name} {sort?.fieldId===c.fieldId?(sort.direction==='asc'?'↑':'↓'):'↕'}</button></th>)}<th>行类型</th></tr></thead><tbody>{shown.slice(current * pageSize, (current + 1) * pageSize).map((row,i) => <tr key={current * pageSize + i}><td>{current * pageSize + i + 1}</td>{row.cells.map((c,j) => <td key={visible.columns[j].key}>{displayCell(c)}</td>)}<td>{
|
|
18
|
+
<div className="table-scroll"><table><thead><tr><th>序号</th>{visible.columns.map(c => <th key={c.key} aria-sort={sort?.fieldId===c.fieldId?(sort.direction==='asc'?'ascending':'descending'):'none'}><button className="sort-column" onClick={()=>{setSort({fieldId:c.fieldId,direction:sort?.fieldId===c.fieldId&&sort.direction==='asc'?'desc':'asc'});setPage(0);}}>{c.name} {sort?.fieldId===c.fieldId?(sort.direction==='asc'?'↑':'↓'):'↕'}</button></th>)}<th>行类型</th></tr></thead><tbody>{shown.slice(current * pageSize, (current + 1) * pageSize).map((row,i) => <tr key={current * pageSize + i}><td>{current * pageSize + i + 1}</td>{row.cells.map((c,j) => <td key={visible.columns[j].key}>{displayCell(c)}</td>)}<td>{{DATA:'数据',SUBTOTAL:'小计',GRAND_TOTAL:'总计',UNKNOWN:'UNKNOWN'}[row.kind]}</td></tr>)}</tbody></table></div>
|
|
19
19
|
{!rows.length && <p className="empty">没有匹配的数据</p>}
|
|
20
20
|
<nav className="pager" aria-label="已加载数据分页"><button disabled={current===0} onClick={()=>setPage(current-1)}>上一页</button><span>{current+1} / {pages}</span><button disabled={current===pages-1} onClick={()=>setPage(current+1)}>下一页</button></nav></>;
|
|
21
21
|
}
|
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx
CHANGED
|
@@ -32,6 +32,7 @@ export function Widget({state,binding,pageSize,onRetry,compact=false}: {state:Wi
|
|
|
32
32
|
{state.error&&<div className="component-error" role="alert"><p>{'code' in state.error?String(state.error.code)+' · ':''}{state.error.message}</p><button onClick={onRetry}>重试组件</button></div>}
|
|
33
33
|
{result&&<>
|
|
34
34
|
{compact?<details className="query-details"><summary>查询范围{selected?` · ${selected.returnedRowCount} 行`:''}</summary><p>{result.range.mode} · {result.normalization} · 后续数据:{String(result.range.hasNext)}</p><p>筛选作用:{binding.filterNames?.join('、')||'沿用源组件条件'}</p></details>:<p className="result-range">{result.range.mode} · 本次{selected?` ${selected.returnedRowCount} 行`:'多个模型,需显式选择'}{result.range.total.value!==null?` · 查询总行数 ${result.range.total.value}`:''} · hasNext={String(result.range.hasNext)} · {result.normalization}</p>}
|
|
35
|
+
{result.range.axes&&<p className="result-range">交叉表当前视图 · 横向还有数据:{result.range.axes.horizontal.hasNext===null?'未知':result.range.axes.horizontal.hasNext?'是':'否'} · 纵向还有数据:{result.range.axes.vertical.hasNext===null?'未知':result.range.axes.vertical.hasNext?'是':'否'}</p>}
|
|
35
36
|
{(result.warnings.length>0||result.normalization!=='COMPLETE'||result.range.hasNext===true)&&<div className="notice" role="status">{result.range.hasNext||result.warnings.includes('NEXT_PAGE_UNSUPPORTED')?'当前仅展示首屏返回结果,本地分页不代表服务端续页。':'部分源图形未转换,当前展示已返回的数据模型。'}{compact?<details><summary>查看适配说明</summary><small>{result.normalization} · {result.warnings.join(' · ')}</small></details>:<small>{result.normalization} · {result.warnings.join(' · ')}</small>}</div>}
|
|
36
37
|
<Boundary key={state.at} onRetry={onRetry}><Content result={result} binding={binding} pageSize={pageSize}/></Boundary>
|
|
37
38
|
</>}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {normalize} from './normalize';
|
|
2
2
|
import {encodeFilters} from './filters.js';
|
|
3
|
-
import {configErrors} from './config.js';
|
|
3
|
+
import {configErrors, sameDimensionIds} from './config.js';
|
|
4
4
|
import type {AppConfig, QueryResult, FilterState, WidgetBinding} from './types';
|
|
5
5
|
export class FineBIError extends Error {
|
|
6
6
|
constructor(public code: string, message: string) {super(message); this.name = 'FineBIError';}
|
|
@@ -14,10 +14,16 @@ export async function queryWidgets(config: AppConfig, filters: FilterState = {},
|
|
|
14
14
|
const refer = encodeFilters(config.filters,filters);
|
|
15
15
|
const ids = [...new Set(widgetIds ?? config.widgets.map(w => w.entryId))];
|
|
16
16
|
if(!ids.length||ids.some(id=>!config.widgets.some(w=>w.entryId===id)))throw new Error('组件不在配置范围内');
|
|
17
|
+
const dimensionSelections:Record<string,string[]> = Object.create(null);
|
|
18
|
+
for(const id of ids) {
|
|
19
|
+
const bindings=config.widgets.filter(w=>w.entryId===id);
|
|
20
|
+
if(bindings.some(w=>!sameDimensionIds(w.dimensionIds,bindings[0].dimensionIds)))throw new Error('同一组件有不同字段选择,请按展示绑定分别查询');
|
|
21
|
+
if(bindings[0].dimensionIds!==undefined)dimensionSelections[id]=bindings[0].dimensionIds;
|
|
22
|
+
}
|
|
17
23
|
const response = await fetchImpl(`${config.basePath.replace(/\/$/, '')}/v5/api/dashboard/report/${encodeURIComponent(config.dashboardId)}/widgets/query`, {
|
|
18
24
|
method: 'POST', credentials: 'include', signal, redirect: 'manual',
|
|
19
25
|
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'},
|
|
20
|
-
body: JSON.stringify({widgetIds: ids, includeWidgetBean: true, pageLimit: config.maxRows, refer,...(config.directoryEntryId?{reportEntryId:config.directoryEntryId}:{})}),
|
|
26
|
+
body: JSON.stringify({widgetIds: ids, includeWidgetBean: true, pageLimit: config.maxRows, refer,...(config.directoryEntryId?{reportEntryId:config.directoryEntryId}:{}),...(Object.keys(dimensionSelections).length?{dimensionSelections}:{})}),
|
|
21
27
|
});
|
|
22
28
|
if (response.status === 401 || response.type === 'opaqueredirect' || response.status >= 300 && response.status < 400) throw new FineBIError('AUTH_REQUIRED', '登录已失效,请登录 FineBI 后重试');
|
|
23
29
|
if (response.status === 403) throw new FineBIError('PERMISSION_DENIED', '当前用户没有访问权限');
|
|
@@ -26,7 +32,15 @@ export async function queryWidgets(config: AppConfig, filters: FilterState = {},
|
|
|
26
32
|
const code = body.errorCode ?? body.code;
|
|
27
33
|
if (['TIME_OUT','JWT_EXPIRED','LOGIN_INFO_NOT_AVAILABLE','CANNOT_GET_TOKEN','AUTH_REQUIRED'].includes(code)) throw new FineBIError('AUTH_REQUIRED', '登录已失效,请登录 FineBI 后重试');
|
|
28
34
|
if (code === 'PERMISSION_DENIED') throw new FineBIError('PERMISSION_DENIED', '当前用户没有数据或字段权限');
|
|
29
|
-
|
|
35
|
+
const developmentAuthErrors: Record<string,string> = {
|
|
36
|
+
AUTH_PLUGIN_REQUIRED:'请更新 FineBI 服务端插件以启用浏览器授权',
|
|
37
|
+
AUTH_HTTPS_REQUIRED:'浏览器授权需要 HTTPS FineBI 地址',
|
|
38
|
+
AUTH_PROTOCOL_ERROR:'认证响应不符合预期,请检查 FineBI 地址和版本',
|
|
39
|
+
AUTH_UNAVAILABLE:'授权服务暂不可用,请稍后重试',
|
|
40
|
+
AUTH_STORAGE_FAILED:'无法保存本地会话,请检查 .env.local 文件权限',
|
|
41
|
+
};
|
|
42
|
+
if (developmentAuthErrors[code]) throw new FineBIError(code, `${developmentAuthErrors[code]};详情见开发终端`);
|
|
43
|
+
if (!response.ok || body.success !== true) throw new FineBIError(typeof code==='string'?code:'QUERY_FAILED', 'FineBI 查询失败,请检查组件、控件绑定、平台版本及访问权限');
|
|
30
44
|
const results: Record<string, QueryResult> = {};
|
|
31
45
|
for (const id of ids) {
|
|
32
46
|
if (!body.data?.[id]?.previewResult) throw new FineBIError('INVALID_RESPONSE', `接口未返回组件 ${id},不能视为空数据`);
|
|
@@ -35,8 +49,10 @@ export async function queryWidgets(config: AppConfig, filters: FilterState = {},
|
|
|
35
49
|
return results;
|
|
36
50
|
}
|
|
37
51
|
export async function queryWidget(config:AppConfig,binding:WidgetBinding,filters:FilterState={},signal?:AbortSignal,fetchImpl:typeof fetch=fetch):Promise<QueryResult> {
|
|
52
|
+
validateConfig(config);
|
|
53
|
+
if(!config.widgets.includes(binding))throw new Error('展示绑定不在配置范围内');
|
|
38
54
|
// Validate all draft names before projecting the verified component scope.
|
|
39
55
|
encodeFilters(config.filters,filters);
|
|
40
56
|
const scoped=binding.filterNames ? Object.fromEntries(Object.entries(filters).filter(([name])=>binding.filterNames!.includes(name))) : filters;
|
|
41
|
-
return (await queryWidgets(config,scoped,signal,fetchImpl,[binding.entryId]))[binding.entryId];
|
|
57
|
+
return (await queryWidgets({...config,widgets:[binding]},scoped,signal,fetchImpl,[binding.entryId]))[binding.entryId];
|
|
42
58
|
}
|
|
@@ -1 +1,3 @@
|
|
|
1
1
|
export function configErrors(config: unknown): string[];
|
|
2
|
+
export function sameDimensionIds(left: string[] | undefined, right: string[] | undefined): boolean;
|
|
3
|
+
export function validateDimensionSelections(widgets: {entryId: string; dimensionIds?: string[]}[], widgetIds: string[], selections: unknown): void;
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import {validateFilterBindings} from './filters.js';
|
|
2
2
|
const nonempty=value=>typeof value==='string'&&value.trim().length>0;
|
|
3
3
|
const record=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
|
|
4
|
+
const validDimensionIds=value=>Array.isArray(value)&&value.every(nonempty)&&new Set(value).size===value.length;
|
|
5
|
+
export function sameDimensionIds(left,right) {
|
|
6
|
+
if(left===undefined||right===undefined)return left===right;
|
|
7
|
+
return validDimensionIds(left)&&validDimensionIds(right)&&left.length===right.length&&left.every(id=>right.includes(id));
|
|
8
|
+
}
|
|
9
|
+
export function validateDimensionSelections(widgets,widgetIds,selections) {
|
|
10
|
+
if(selections!==undefined&&(!record(selections)||Object.keys(selections).some(id=>!widgetIds.includes(id))))throw new Error('Invalid dimension selections');
|
|
11
|
+
for(const id of widgetIds) {
|
|
12
|
+
const selected=selections&&Object.hasOwn(selections,id)?selections[id]:undefined;
|
|
13
|
+
if(selected!==undefined&&!validDimensionIds(selected))throw new Error('Invalid dimensionIds');
|
|
14
|
+
if(!widgets.some(w=>w.entryId===id&&sameDimensionIds(w.dimensionIds,selected)))throw new Error('Unconfigured dimension selection');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
4
17
|
export function configErrors(config) {
|
|
5
18
|
if(!record(config))return ['configuration must be a JSON object.'];
|
|
6
19
|
const errors=[];
|
|
@@ -21,9 +34,10 @@ export function configErrors(config) {
|
|
|
21
34
|
if(!Array.isArray(config.widgets)||!config.widgets.length)return [...errors,'widgets must be a non-empty array.'];
|
|
22
35
|
for(const [i,w] of config.widgets.entries()) {
|
|
23
36
|
if(!record(w)){errors.push(`widgets[${i}] must be an object.`);continue;}
|
|
24
|
-
const fields=['entryId','name','type','datasetId','categoryFieldId','valueFieldId','series','description','filterNames','tableFieldIds','localFilterFieldIds','valueFormat','categoryFormat','categoryTimeZone'];
|
|
37
|
+
const fields=['entryId','name','type','datasetId','categoryFieldId','valueFieldId','series','description','filterNames','tableFieldIds','localFilterFieldIds','valueFormat','categoryFormat','categoryTimeZone','dimensionIds'];
|
|
25
38
|
for(const field of Object.keys(w))if(!fields.includes(field))errors.push(`widgets[${i}].${field} is unsupported.`);
|
|
26
39
|
for(const field of ['entryId','name'])if(!nonempty(w[field]))errors.push(`widgets[${i}].${field} is required.`);
|
|
40
|
+
if('dimensionIds' in w&&!validDimensionIds(w.dimensionIds))errors.push(`widgets[${i}].dimensionIds must be unique non-empty strings.`);
|
|
27
41
|
if(!['table','kpi','bar','line','pie'].includes(w.type))errors.push(`widgets[${i}].type is unsupported.`);
|
|
28
42
|
if('valueFormat' in w&&(w.type!=='kpi'||!['number','percent'].includes(w.valueFormat)))errors.push(`widgets[${i}].valueFormat requires a KPI and number or percent.`);
|
|
29
43
|
if('categoryFormat' in w||'categoryTimeZone' in w) {
|
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {Cell, Column, Dataset, QueryResult} from './types';
|
|
1
|
+
import type {AxisLabel, Cell, Column, Dataset, QueryResult, RowKind} from './types';
|
|
2
2
|
|
|
3
3
|
// Native preview JSON is deliberately confined to this boundary.
|
|
4
4
|
type Native = Record<string, any>;
|
|
@@ -46,6 +46,28 @@ export function normalize(source: Native): QueryResult {
|
|
|
46
46
|
r.datasets.push(dataset);
|
|
47
47
|
if (p.hasNext) warn(r, 'NEXT_PAGE_UNSUPPORTED');
|
|
48
48
|
if (r.range.total.value !== null && dataset.rows.length < r.range.total.value) warn(r, 'RESULT_TRUNCATED');
|
|
49
|
+
} else if (p?.resultType === 'BIGROUP') {
|
|
50
|
+
r.resultType = 'GROUP_TABLE';
|
|
51
|
+
if (!Array.isArray(p.header) || !Array.isArray(p.items)) {
|
|
52
|
+
r.normalization = 'UNSUPPORTED'; warn(r, 'INVALID_GROUP_TABLE'); return r;
|
|
53
|
+
}
|
|
54
|
+
const style = source.widgetBean?.settings?.tableStyle;
|
|
55
|
+
if (style?.showRowHeaderIcon === true) r.range.mode = 'INITIAL_VIEW';
|
|
56
|
+
else r.range = {mode: 'PAGE', page: 1, pageSize: Number.isSafeInteger(style?.rowCounts) && style.rowCounts > 0 ? style.rowCounts : null, pageUnit: 'GROUP', hasNext: typeof p.hasNext === 'boolean' ? p.hasNext : null, hasPrevious: typeof p.hasPrev === 'boolean' ? p.hasPrev : null, total: {value: null, unit: 'GROUP'}};
|
|
57
|
+
r.datasets.push(groupDataset(p, source.widgetBean, r));
|
|
58
|
+
if (p.hasNext) warn(r, 'NEXT_PAGE_UNSUPPORTED');
|
|
59
|
+
} else if (p?.resultType === 'BICROSS') {
|
|
60
|
+
r.resultType = 'CROSS_TABLE'; r.range.mode = 'INITIAL_VIEW';
|
|
61
|
+
const flag = (v: unknown) => typeof v === 'boolean' ? v : null;
|
|
62
|
+
const either = (a: boolean | null, b: boolean | null) => a === true || b === true ? true : a === false && b === false ? false : null;
|
|
63
|
+
const horizontal = {hasNext: flag(p.hasXNext), hasPrevious: flag(p.hasXPrev)};
|
|
64
|
+
const vertical = {hasNext: flag(p.hasYNext), hasPrevious: flag(p.hasYPrev)};
|
|
65
|
+
r.range.axes = {horizontal, vertical};
|
|
66
|
+
r.range.hasNext = either(horizontal.hasNext, vertical.hasNext);
|
|
67
|
+
r.range.hasPrevious = either(horizontal.hasPrevious, vertical.hasPrevious);
|
|
68
|
+
if (r.range.hasNext) warn(r, 'NEXT_PAGE_UNSUPPORTED');
|
|
69
|
+
const dataset = crossDataset(p, r);
|
|
70
|
+
if (dataset) r.datasets.push(dataset);
|
|
49
71
|
} else if (p?.resultType === 'CHART') {
|
|
50
72
|
r.resultType = 'CHART'; r.range.mode = 'UNPAGED';
|
|
51
73
|
const models: Native[] = [];
|
|
@@ -75,3 +97,139 @@ export function normalize(source: Native): QueryResult {
|
|
|
75
97
|
function detailType(type: number): Column['type'] {
|
|
76
98
|
return type === 1 ? 'STRING' : type === 2 ? 'DECIMAL' : [3,13,15,20].includes(type) ? 'DATE' : 'UNKNOWN';
|
|
77
99
|
}
|
|
100
|
+
|
|
101
|
+
function rowKind(values: Native[], axis = 'rowSumInfo'): RowKind {
|
|
102
|
+
const kinds = values.map(metric => {
|
|
103
|
+
const info = metric?.[axis];
|
|
104
|
+
if (typeof info?.totalSum !== 'boolean' || typeof info?.innerSum !== 'boolean' || info.totalSum && info.innerSum) return 'UNKNOWN';
|
|
105
|
+
return info.totalSum ? 'GRAND_TOTAL' : info.innerSum ? 'SUBTOTAL' : 'DATA';
|
|
106
|
+
});
|
|
107
|
+
return kinds.length && kinds.every(kind => kind === kinds[0]) ? kinds[0] : 'UNKNOWN';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Native top leaves and left value arrays share the converter's slot order.
|
|
111
|
+
// Field IDs repeat across cross columns and must never be used to join cells.
|
|
112
|
+
function crossDataset(preview: Native, result: QueryResult): Dataset | null {
|
|
113
|
+
const reject = (reason: string) => {result.normalization = 'UNSUPPORTED'; warn(result, reason); return null;};
|
|
114
|
+
const record = (v: unknown): v is Native => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
115
|
+
if (![preview.top, preview.left].every(axis => record(axis) && Array.isArray(axis.header) && Array.isArray(axis.items))) return reject('INVALID_CROSS_TABLE');
|
|
116
|
+
type Leaf = {path: AxisLabel[]; values: Native[]; restricted: boolean; unknownAuthority: boolean};
|
|
117
|
+
let invalid = false;
|
|
118
|
+
const leaves = (items: Native[]): Leaf[] => {
|
|
119
|
+
const output: Leaf[] = [];
|
|
120
|
+
const visit = (item: Native, parents: AxisLabel[], restricted: boolean, unknownAuthority: boolean) => {
|
|
121
|
+
if (!record(item) || item.children != null && !Array.isArray(item.children) || item.values != null && !Array.isArray(item.values)) {invalid = true; return;}
|
|
122
|
+
const children: Native[] = item.children ?? [], values: Native[] = item.values ?? [];
|
|
123
|
+
if (children.length && values.length || values.some(v => !record(v))) {invalid = true; return;}
|
|
124
|
+
restricted ||= item.authority === false;
|
|
125
|
+
const label = text(item.formatText) ?? text(item.text);
|
|
126
|
+
unknownAuthority ||= label !== null && item.authority !== true && item.authority !== false;
|
|
127
|
+
const path = [...parents];
|
|
128
|
+
if (label !== null) path.push({fieldId: text(item.dId), label: restricted ? '受限' : unknownAuthority ? '不可用' : label, state: restricted ? 'RESTRICTED' : unknownAuthority ? 'UNAVAILABLE' : 'AVAILABLE'});
|
|
129
|
+
if (children.length) for (const child of children) visit(child, path, restricted, unknownAuthority);
|
|
130
|
+
else if (values.length || item.dId != null) output.push({path, values, restricted, unknownAuthority});
|
|
131
|
+
};
|
|
132
|
+
for (const item of items) visit(item, [], false, false);
|
|
133
|
+
return output;
|
|
134
|
+
};
|
|
135
|
+
const top = leaves(preview.top.items), left = leaves(preview.left.items);
|
|
136
|
+
if (invalid) return reject('CROSS_TABLE_TOPOLOGY_UNSUPPORTED');
|
|
137
|
+
const slots = top.flatMap(leaf => (leaf.values.length ? leaf.values : [{}]).map(metric => {
|
|
138
|
+
const path = [...leaf.path], label = text(metric.formatText) ?? text(metric.text);
|
|
139
|
+
const restricted = leaf.restricted || metric.authority === false;
|
|
140
|
+
const unknownAuthority = leaf.unknownAuthority || label !== null && metric.authority !== true && metric.authority !== false;
|
|
141
|
+
if (label !== null && (path.at(-1)?.label !== label || path.at(-1)?.fieldId !== metric.dId)) path.push({fieldId: text(metric.dId), label: restricted ? '受限' : unknownAuthority ? '不可用' : label, state: restricted ? 'RESTRICTED' : unknownAuthority ? 'UNAVAILABLE' : 'AVAILABLE'});
|
|
142
|
+
const titleParts = path.map(p => p.label);
|
|
143
|
+
if (path.length > leaf.path.length && titleParts.at(-1) === titleParts.at(-2)) titleParts.pop();
|
|
144
|
+
return {path, title: titleParts.join(' / '), restricted, unknownAuthority, sourceFieldId: text(metric.dId)};
|
|
145
|
+
}));
|
|
146
|
+
// With no column dimensions the platform emits an empty top axis;
|
|
147
|
+
// each left values array still contains the ordered metric slots.
|
|
148
|
+
if (!slots.length && !preview.top.header.length && !preview.top.items.length) {
|
|
149
|
+
const width = Math.max(0, ...left.map(leaf => leaf.values.length));
|
|
150
|
+
for (let index = 0; index < width; index++) slots.push({path: [], title: '', restricted: false, unknownAuthority: false, sourceFieldId: null});
|
|
151
|
+
}
|
|
152
|
+
if (left.some(leaf => leaf.values.length > slots.length)) return reject('CROSS_TABLE_WIDTH_MISMATCH');
|
|
153
|
+
if (!slots.length && left.some(leaf => leaf.values.length)) return reject('CROSS_TABLE_WITHOUT_COLUMNS');
|
|
154
|
+
const dataset: Dataset = {id: 'main', columns: [column('cross:row', '行分组', 0, 'STRING'), ...slots.map((slot, index) => {
|
|
155
|
+
const metrics = left.map(leaf => leaf.values[index]).filter(metric => metric?.dId != null);
|
|
156
|
+
const kind = rowKind(metrics, 'colSumInfo');
|
|
157
|
+
const suffix = kind === 'GRAND_TOTAL' ? '(总计)' : kind === 'SUBTOTAL' ? '(小计)' : '';
|
|
158
|
+
return {...column(`cross:${index}`, (slot.title || `列 ${index + 1}`) + suffix, index + 1, 'DECIMAL'), path: slot.path, kind};
|
|
159
|
+
})], rows: [], returnedRowCount: left.length};
|
|
160
|
+
for (const leaf of left) {
|
|
161
|
+
if (leaf.values.length !== slots.length) partial(result, 'CROSS_TABLE_WIDTH_MISMATCH');
|
|
162
|
+
const kind = rowKind(leaf.values.filter(metric => metric.dId != null));
|
|
163
|
+
const titlePath = leaf.path.filter(p => kind !== 'DATA' || p.fieldId !== '__summary_dim_id__');
|
|
164
|
+
const metricIds = leaf.values.map(metric => text(metric.dId)).filter(id => id !== null);
|
|
165
|
+
const last = titlePath.at(-1);
|
|
166
|
+
if (last?.fieldId && last.label === titlePath.at(-2)?.label
|
|
167
|
+
&& metricIds.length && metricIds.every(id => id === last.fieldId)) titlePath.pop();
|
|
168
|
+
const label = titlePath.map(p => p.label).join(' / ') || '—';
|
|
169
|
+
const rowCell: Cell = leaf.restricted ? value(null, '受限', false, 'STRING') : leaf.unknownAuthority ? unavailable('AUTHORITY_UNAVAILABLE') : value(label, label, true, 'STRING');
|
|
170
|
+
const cells = slots.map((slot, index): Cell => {
|
|
171
|
+
const metric = leaf.values[index];
|
|
172
|
+
const meta = {sourceFieldId: text(metric?.dId), columnKind: rowKind(metric ? [metric] : [], 'colSumInfo')};
|
|
173
|
+
if (leaf.restricted || slot.restricted || metric?.authority === false) return {...value(null, '受限', false, 'DECIMAL'), ...meta};
|
|
174
|
+
if (leaf.unknownAuthority || slot.unknownAuthority) return {...unavailable('AUTHORITY_UNAVAILABLE'), ...meta};
|
|
175
|
+
if (!metric) return {...unavailable('MISSING_CELL'), ...meta};
|
|
176
|
+
if (typeof metric.dId !== 'string' || !metric.dId.trim()) return {...unavailable('MISSING_FIELD_ID'), ...meta, sourceFieldId: null};
|
|
177
|
+
if (slot.sourceFieldId && metric.dId && slot.sourceFieldId !== metric.dId) {
|
|
178
|
+
partial(result, 'CROSS_TABLE_METRIC_MISMATCH'); return {...unavailable('AMBIGUOUS_FIELD'), ...meta};
|
|
179
|
+
}
|
|
180
|
+
const raw = metric.value ?? metric.text, display = text(metric.formatText) ?? text(metric.text);
|
|
181
|
+
if (typeof metric.dId === 'string' && metric.dId.trim() && metric.authority === true
|
|
182
|
+
&& metric.value === null && metric.text === null && metric.formatText === null) {
|
|
183
|
+
return {...value(null, null, true, 'DECIMAL'), ...meta};
|
|
184
|
+
}
|
|
185
|
+
// Empty metric placeholders have no proven business-null semantics.
|
|
186
|
+
return {...(raw == null ? unavailable('DISPLAY_ONLY', display) : value(raw, display, metric.authority, 'DECIMAL')), ...meta};
|
|
187
|
+
});
|
|
188
|
+
dataset.rows.push({kind, path: leaf.path, cells: [rowCell, ...cells]});
|
|
189
|
+
}
|
|
190
|
+
return dataset;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function groupDataset(preview: Native, widget: Native, result: QueryResult): Dataset {
|
|
194
|
+
const list = (items: unknown): Native[] => Array.isArray(items) ? items : [];
|
|
195
|
+
const metrics = new Set<string>();
|
|
196
|
+
const collect = (items: Native[]) => {
|
|
197
|
+
for (const item of items) {
|
|
198
|
+
for (const metric of list(item?.values)) if (typeof metric?.dId === 'string') metrics.add(metric.dId);
|
|
199
|
+
collect(list(item?.children));
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
collect(preview.items);
|
|
203
|
+
const dataset: Dataset = {id: 'main', columns: preview.header.map((header: Native, index: number) => {
|
|
204
|
+
const type = widget?.dimensions?.[header?.dId]?.type;
|
|
205
|
+
return column(header?.dId, header?.text, index, metrics.has(header?.dId) ? 'DECIMAL' : type === 1 ? 'STRING' : type === 2 ? 'DECIMAL' : 'UNKNOWN');
|
|
206
|
+
}), rows: [], returnedRowCount: 0};
|
|
207
|
+
const ids = new Set(dataset.columns.map(c => c.fieldId));
|
|
208
|
+
const visit = (item: Native, parents: Map<string, Native>) => {
|
|
209
|
+
if (!item || typeof item !== 'object') {partial(result, 'MISSING_GROUP_NODE'); return;}
|
|
210
|
+
const path = new Map(parents);
|
|
211
|
+
if (item.dId != null) path.set(item.dId, item);
|
|
212
|
+
const values = list(item.values), children = list(item.children);
|
|
213
|
+
if (item.values != null && !Array.isArray(item.values) || item.children != null && !Array.isArray(item.children)) partial(result, 'INVALID_GROUP_NODE');
|
|
214
|
+
if (item.dId != null && !ids.has(item.dId) || values.some(metric => !ids.has(metric?.dId))) partial(result, 'UNMAPPED_GROUP_FIELD');
|
|
215
|
+
if (values.length || !children.length) dataset.rows.push({kind: rowKind(values), cells: dataset.columns.map(c => {
|
|
216
|
+
const matching = values.filter(metric => metric?.dId === c.fieldId), dimension = path.get(c.fieldId);
|
|
217
|
+
if (matching.length === 1 && !dimension) {
|
|
218
|
+
const metric = matching[0], display = text(metric.formatText) ?? text(metric.text), raw = metric.value ?? metric.text;
|
|
219
|
+
if (raw == null && metric.authority === true) return unavailable('DISPLAY_ONLY', display);
|
|
220
|
+
return value(raw, display, metric.authority, c.type);
|
|
221
|
+
}
|
|
222
|
+
if (!matching.length && dimension) {
|
|
223
|
+
const display = text(dimension.formatText) ?? text(dimension.text);
|
|
224
|
+
if (dimension.authority === false) return value(null, display, false, c.type);
|
|
225
|
+
return c.type === 'STRING' ? value(dimension.text, display, dimension.authority, c.type) : unavailable('GROUP_KEY_SEMANTICS_UNKNOWN', display);
|
|
226
|
+
}
|
|
227
|
+
if (matching.length) partial(result, 'AMBIGUOUS_FIELD');
|
|
228
|
+
return unavailable(matching.length ? 'AMBIGUOUS_FIELD' : 'MISSING_CELL');
|
|
229
|
+
})});
|
|
230
|
+
for (const child of children) visit(child, path);
|
|
231
|
+
};
|
|
232
|
+
for (const item of preview.items) visit(item, new Map());
|
|
233
|
+
dataset.returnedRowCount = dataset.rows.length;
|
|
234
|
+
return dataset;
|
|
235
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
export type Cell = {value: string | null; display: string | null; state: 'AVAILABLE' | 'NULL' | 'UNAVAILABLE' | 'RESTRICTED'; reason: string | null};
|
|
2
|
-
export type
|
|
3
|
-
export type
|
|
1
|
+
export type Cell = {value: string | null; display: string | null; state: 'AVAILABLE' | 'NULL' | 'UNAVAILABLE' | 'RESTRICTED'; reason: string | null; sourceFieldId?: string | null; columnKind?: RowKind};
|
|
2
|
+
export type AxisLabel = {fieldId: string | null; label: string; state: Cell['state']};
|
|
3
|
+
export type Column = {key: string; fieldId: string; name: string; type: 'STRING' | 'DECIMAL' | 'DATE' | 'UNKNOWN'; encoding: string | null; path?: AxisLabel[]; kind?: RowKind};
|
|
4
|
+
export type RowKind = 'DATA' | 'SUBTOTAL' | 'GRAND_TOTAL' | 'UNKNOWN';
|
|
5
|
+
export type Dataset = {id: string; columns: Column[]; rows: {kind: RowKind; cells: Cell[]; path?: AxisLabel[]}[]; returnedRowCount: number};
|
|
4
6
|
export type QueryResult = {
|
|
5
|
-
schemaVersion: '1'; resultType: 'DETAIL_TABLE' | 'CHART' | 'UNKNOWN';
|
|
7
|
+
schemaVersion: '1'; resultType: 'DETAIL_TABLE' | 'GROUP_TABLE' | 'CROSS_TABLE' | 'CHART' | 'UNKNOWN';
|
|
6
8
|
normalization: 'COMPLETE' | 'PARTIAL' | 'UNSUPPORTED'; datasets: Dataset[]; warnings: string[];
|
|
7
|
-
range: {mode: 'PAGE' | 'UNPAGED' | 'UNKNOWN'; page: number | null; pageSize: number | null; pageUnit: string | null; hasNext: boolean | null; hasPrevious: boolean | null; total: {value: number | null; unit: string | null}};
|
|
9
|
+
range: {mode: 'PAGE' | 'INITIAL_VIEW' | 'UNPAGED' | 'UNKNOWN'; page: number | null; pageSize: number | null; pageUnit: string | null; hasNext: boolean | null; hasPrevious: boolean | null; total: {value: number | null; unit: string | null}; axes?: {horizontal: {hasNext: boolean | null; hasPrevious: boolean | null}; vertical: {hasNext: boolean | null; hasPrevious: boolean | null}}};
|
|
8
10
|
};
|
|
9
|
-
export type WidgetBinding = {entryId: string; name: string; type: 'table' | 'bar' | 'line' | 'pie' | 'kpi'; datasetId?: string; categoryFieldId?: string; valueFieldId?: string; series?: {fieldId:string;label:string}[]; description?:string; filterNames?:string[]; tableFieldIds?:string[]; localFilterFieldIds?:string[]; valueFormat?:'number'|'percent'; categoryFormat?:'epoch-month'; categoryTimeZone?:string};
|
|
11
|
+
export type WidgetBinding = {entryId: string; dimensionIds?: string[]; name: string; type: 'table' | 'bar' | 'line' | 'pie' | 'kpi'; datasetId?: string; categoryFieldId?: string; valueFieldId?: string; series?: {fieldId:string;label:string}[]; description?:string; filterNames?:string[]; tableFieldIds?:string[]; localFilterFieldIds?:string[]; valueFormat?:'number'|'percent'; categoryFormat?:'epoch-month'; categoryTimeZone?:string};
|
|
10
12
|
export type FilterBinding = {name: string; label: string} & (
|
|
11
13
|
{type: 'date' | 'date-range' | 'year' | 'month' | 'month-range' | 'quarter'; options?: never; allowNull?: never}
|
|
12
14
|
| {type: 'text' | 'number'; options?: never; allowNull?: boolean}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
.retail{--chart-1:#3158d5;--chart-2:#e18047;--chart-3:#82a7a0;--chart-4:#9b8fc6;--chart-5:#d2b977;background:#f5f3ee;color:#24272d;min-height:100vh;font-family:"Segoe UI","Microsoft YaHei",sans-serif}
|
|
2
2
|
.retail main{max-width:1480px;padding:48px 48px 24px}.retail a{color:#3158d5}.retail button,.retail select,.retail input{border-radius:5px}.retail :focus-visible{outline:3px solid #e18047;outline-offset:3px}
|
|
3
3
|
.retail-nav{position:sticky;top:0;z-index:30}.retail :is(#dashboard,#components,#data-notes){scroll-margin-top:100px}
|
|
4
|
-
.retail-nav{height:78px;padding:0 48px;border-bottom:1px solid #dcd9d1;display:flex;align-items:center;justify-content:space-between;gap:30px;background:#faf9f6}.retail-nav a{text-decoration:none;color:#40434b}.retail-brand{display:flex;align-items:center;gap:12px;font-weight:700;font-size:17px;white-space:nowrap}.retail-brand b{display:grid;place-items:center;background:#24272d;color:#faf9f6;font-family:Georgia,serif;font-size:30px;width:39px;height:39px;border-radius:4px}.retail-brand small{font-size:12px;font-weight:400;border-left:1px solid #bbb7ad;margin-left:10px;padding-left:12px}.retail-nav>div{display:flex;gap:36px;font-size:13px}.retail-nav>div a
|
|
4
|
+
.retail-nav{height:78px;padding:0 48px;border-bottom:1px solid #dcd9d1;display:flex;align-items:center;justify-content:space-between;gap:30px;background:#faf9f6}.retail-nav a{text-decoration:none;color:#40434b}.retail-brand{display:flex;align-items:center;gap:12px;font-weight:700;font-size:17px;white-space:nowrap}.retail-brand b{display:grid;place-items:center;background:#24272d;color:#faf9f6;font-family:Georgia,serif;font-size:30px;width:39px;height:39px;border-radius:4px}.retail-brand small{font-size:12px;font-weight:400;border-left:1px solid #bbb7ad;margin-left:10px;padding-left:12px}.retail-nav>div{display:flex;gap:36px;font-size:13px}.retail-nav>div a[aria-current="location"]{color:#3158d5;font-weight:650}.retail-live{font-size:12px;white-space:nowrap}.retail-live:before{content:"";display:inline-block;width:6px;height:6px;background:#3158d5;border-radius:50%;margin-right:8px}
|
|
5
5
|
.retail header{align-items:flex-end;margin-bottom:30px;gap:30px}.retail .eyebrow{font-size:10px;letter-spacing:2.5px;color:#6d706e}.retail h1{font-size:36px;letter-spacing:-1.2px;font-weight:650;margin:12px 0}.retail header .muted{font-size:13px;max-width:750px;line-height:1.9}.retail .muted{color:#75776f}.retail .header-actions{display:flex;align-items:center;gap:20px;white-space:nowrap;font-size:12px}.retail .header-actions button{background:#24272d;color:#fff;border-color:#24272d;padding:11px 18px}.retail .header-actions a{color:#656863;text-decoration:none}
|
|
6
6
|
.retail .filter-card{border-radius:8px;border-color:#dedbd3;background:#faf9f6;box-shadow:none;padding:20px 24px;margin-bottom:8px}.retail .filter-heading{margin-bottom:14px}.retail .filter-heading p{display:none}.retail .filter-symbol{height:28px;width:28px;background:#eaeefa;border-radius:5px;color:#3158d5}.retail .filter-heading h2{font-size:13px;color:#424740}.retail .filter-fields{gap:14px}.retail .filter-fields:has(>.filter-field:only-child){max-width:760px}.retail .filter-label-row{margin-bottom:5px}.retail .filter-label-row label{color:#575d65}.retail .filter-bottom{margin-top:9px;padding-top:12px;border-color:#e3e0d8}.retail .filter-scope{font-size:11px;color:#797d7e;line-height:1.8;margin:4px 0}.retail .field-hint{min-height:0;margin-top:6px}.retail .filter-status{background:#e9edf9;color:#3158d5}.retail .filter-status.pending{background:#fff0db;color:#855316}.retail .query-status{font-size:11px;color:#75776f;text-align:right;margin:12px 0 28px}
|
|
7
7
|
.retail .section-heading{display:flex;align-items:baseline;gap:16px;margin:35px 0 18px}.retail .section-heading>span{font:10px "Segoe UI",sans-serif;letter-spacing:1.5px;color:#85877d}.retail .section-heading h2{font-size:18px;letter-spacing:-.3px;margin:0}.retail .section-heading p{font-size:11px;color:#85877d;margin:0 0 0 auto}
|
package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs
ADDED
|
@@ -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');});
|
|
@@ -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
|
+
});
|