@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.
Files changed (77) hide show
  1. package/README.md +8 -8
  2. package/package.json +1 -1
  3. package/skills/finebi-cli-agent/SKILL.md +43 -123
  4. package/skills/finebi-cli-agent/agents/openai.yaml +1 -1
  5. package/skills/finebi-cli-agent/references/artifact-ordering.md +3 -3
  6. package/skills/finebi-cli-agent/references/command-workflows.md +30 -1
  7. package/skills/finebi-cli-agent/references/dsl-handoff-contract.md +1 -1
  8. package/skills/finebi-cli-agent/references/handoff-contract.md +6 -4
  9. package/skills/finebi-cli-agent/references/mutation-confirmation-contract.md +2 -0
  10. package/skills/finebi-cli-agent/references/scenario-workflows.md +10 -8
  11. package/skills/finebi-cli-agent/references/web-app.md +4 -2
  12. package/skills/finebi-cli-agent/references/widget-dsl-core.md +6 -0
  13. package/skills/finebi-cli-agent/scripts/finebi-cli/package.json +1 -1
  14. package/skills/finebi-cli-agent/scripts/finebi-cli/src/app.js +18 -6
  15. package/skills/finebi-cli-agent/scripts/finebi-cli/src/browser-auth.js +3 -4
  16. package/skills/finebi-cli-agent/scripts/finebi-cli/src/config.js +1 -1
  17. package/skills/finebi-cli-agent/scripts/finebi-cli/src/error-catalog.js +2 -2
  18. package/skills/finebi-cli-agent/scripts/finebi-cli/src/help.js +8 -5
  19. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/AGENTS.md +18 -5
  20. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/README.md +12 -5
  21. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/acceptance.md +14 -1
  22. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/auth-and-deploy.md +17 -2
  23. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/data-contract.md +66 -38
  24. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/docs/filters.md +2 -2
  25. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/gitignore.template +6 -0
  26. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/browser-session.mjs +59 -0
  27. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-proxy.mjs +25 -24
  28. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/dev-session.mjs +57 -0
  29. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/scripts/session-file.mjs +45 -0
  30. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/App.tsx +8 -1
  31. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/DataTable.tsx +1 -1
  32. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/components/Widget.tsx +1 -0
  33. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/client.ts +20 -4
  34. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.d.ts +2 -0
  35. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/config.js +15 -1
  36. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/normalize.ts +159 -1
  37. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/finebi/types.ts +8 -6
  38. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/src/retail.css +1 -1
  39. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/field-selection.test.mjs +70 -0
  40. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/proxy.test.mjs +20 -5
  41. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/runtime.test.mjs +7 -0
  42. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/session.test.mjs +91 -0
  43. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/table-normalize.test.mjs +189 -0
  44. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/tests/vite-auth.test.mjs +41 -0
  45. package/skills/finebi-cli-agent/scripts/finebi-cli/templates/web-app/vite.config.ts +9 -4
  46. package/src/app.js +18 -6
  47. package/src/browser-auth.js +3 -4
  48. package/src/config.js +1 -1
  49. package/src/error-catalog.js +2 -2
  50. package/src/help.js +8 -5
  51. package/templates/web-app/AGENTS.md +18 -5
  52. package/templates/web-app/README.md +12 -5
  53. package/templates/web-app/docs/acceptance.md +14 -1
  54. package/templates/web-app/docs/auth-and-deploy.md +17 -2
  55. package/templates/web-app/docs/data-contract.md +66 -38
  56. package/templates/web-app/docs/filters.md +2 -2
  57. package/templates/web-app/gitignore.template +6 -0
  58. package/templates/web-app/scripts/browser-session.mjs +59 -0
  59. package/templates/web-app/scripts/dev-proxy.mjs +25 -24
  60. package/templates/web-app/scripts/dev-session.mjs +57 -0
  61. package/templates/web-app/scripts/session-file.mjs +45 -0
  62. package/templates/web-app/src/App.tsx +8 -1
  63. package/templates/web-app/src/components/DataTable.tsx +1 -1
  64. package/templates/web-app/src/components/Widget.tsx +1 -0
  65. package/templates/web-app/src/finebi/client.ts +20 -4
  66. package/templates/web-app/src/finebi/config.d.ts +2 -0
  67. package/templates/web-app/src/finebi/config.js +15 -1
  68. package/templates/web-app/src/finebi/normalize.ts +159 -1
  69. package/templates/web-app/src/finebi/types.ts +8 -6
  70. package/templates/web-app/src/retail.css +1 -1
  71. package/templates/web-app/tests/field-selection.test.mjs +70 -0
  72. package/templates/web-app/tests/proxy.test.mjs +20 -5
  73. package/templates/web-app/tests/runtime.test.mjs +7 -0
  74. package/templates/web-app/tests/session.test.mjs +91 -0
  75. package/templates/web-app/tests/table-normalize.test.mjs +189 -0
  76. package/templates/web-app/tests/vite-auth.test.mjs +41 -0
  77. package/templates/web-app/vite.config.ts +9 -4
@@ -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 Column = {key: string; fieldId: string; name: string; type: 'STRING' | 'DECIMAL' | 'DATE' | 'UNKNOWN'; encoding: string | null};
3
- export type Dataset = {id: string; columns: Column[]; rows: {kind: 'DATA' | 'UNKNOWN'; cells: Cell[]}[]; returnedRowCount: number};
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:first-child{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}
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}
@@ -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 clears expired development session and explicit retry logs in again', async()=>{
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{const expired=await send();assert.equal(expired.status,401);assert.equal((await expired.json()).errorCode,'AUTH_REQUIRED');assert.equal((await send()).status,200);assert.equal(logins,2);}finally{await new Promise(r=>server.close(r));}
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
+ });