@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
@@ -0,0 +1,189 @@
1
+ import assert from 'node:assert/strict';
2
+ import {after, test} from 'node:test';
3
+ import {createServer} from 'vite';
4
+ import {createElement} from 'react';
5
+ import {renderToStaticMarkup} from 'react-dom/server';
6
+ const server = await createServer({configFile: false, cacheDir: '../../../temp/vite-table-tests', server: {middlewareMode: true, hmr: false}});
7
+ after(() => server.close());
8
+ const {normalize} = await server.ssrLoadModule('/src/finebi/normalize.ts');
9
+ const {DataTable} = await server.ssrLoadModule('/src/components/DataTable.tsx');
10
+ const {tableCsv} = await server.ssrLoadModule('/src/services/table-data.ts');
11
+ const metric = (text, rowSumInfo = {totalSum: false, innerSum: false}, extra = {}) => ({dId: 'amount', text, authority: true, rowSumInfo, ...extra});
12
+ const group = items => ({previewResult: {resultType: 'BIGROUP', header: [{dId: 'region', text: '地区'}, {dId: 'city', text: '城市'}, {dId: 'amount', text: '金额'}], items, hasNext: true, hasPrev: false}, widgetBean: {dimensions: {region: {type: 1}, city: {type: 1}}, settings: {tableStyle: {rowCounts: 10}}}});
13
+ const detailInfo = {totalSum: false, innerSum: false};
14
+ const totalInfo = {totalSum: true, innerSum: false};
15
+ const node = (dId, text, children = null, values = null) => ({dId, text, formatText: text, authority: true, children, values});
16
+ const crossMetric = (text, colSumInfo = detailInfo, rowSumInfo = detailInfo, dId = 'amount') => ({dId, text, authority: true, colSumInfo, rowSumInfo});
17
+ const cross = (top, left) => ({previewResult: {resultType: 'BICROSS', top: {header: [{dId: 'year', text: '年'}], items: [node(null, null, top)]}, left: {header: [{dId: 'region', text: '地区'}], items: [node(null, null, left)]}, hasXNext: true, hasXPrev: false, hasYNext: false, hasYPrev: false}});
18
+
19
+ test('cross display removes only repeated metric captions and DATA summary carriers', () => {
20
+ const source = cross([node('region', '金额', [node('target-name', '金额', null, [node('amount', '金额')])])], [
21
+ node('region', '合计', [node('__summary_dim_id__', '合计', null, [crossMetric('1')])]),
22
+ node('__summary_dim_id__', '合计', null, [crossMetric('2', detailInfo, totalInfo)]),
23
+ node('__summary_dim_id__', '合计', null, [crossMetric('3', detailInfo, {totalSum: false, innerSum: true})]),
24
+ node('__summary_dim_id__', '合计', null, [{...crossMetric('4'), rowSumInfo: null}]),
25
+ ]);
26
+ const data = normalize(source).datasets[0];
27
+ assert.equal(data.columns[1].name, '金额 / 金额', 'real repeated grouping labels must remain');
28
+ assert.equal(data.columns[1].path.length, 3, 'source path metadata must remain intact');
29
+ assert.equal(data.rows[0].cells[0].display, '合计', 'business dimension named 合计 must remain');
30
+ assert.equal(data.rows[0].path.length, 2);
31
+ assert.deepEqual(data.rows.slice(1).map(row => [row.kind, row.cells[0].display]), [['GRAND_TOTAL', '合计'], ['SUBTOTAL', '合计'], ['UNKNOWN', '合计']]);
32
+ });
33
+
34
+ test('cross metric rows deduplicate only the verified trailing metric caption', () => {
35
+ const rows = [
36
+ node('region', '金额', [node('target-name', '金额', [node('amount', '金额', null, [crossMetric('7')])])]),
37
+ node('region', '金额', [node('other-dimension', '金额', null, [crossMetric('8')])]),
38
+ node('target-name', '金额', [node('amount', '别名', null, [crossMetric('9')])]),
39
+ node('target-name', '金额', [node('amount', '金额', null, [crossMetric('10', detailInfo, detailInfo, 'count')])]),
40
+ ];
41
+ const data = normalize(cross([node('year', '2024', null, [{}])], rows)).datasets[0];
42
+ assert.deepEqual(data.rows.map(r => r.cells[0].display), ['金额 / 金额', '金额 / 金额', '金额 / 别名', '金额 / 金额']);
43
+ assert.equal(data.rows[0].path.length, 3);
44
+ assert.equal(data.rows[0].cells[1].sourceFieldId, 'amount');
45
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
46
+ assert.doesNotMatch(html, /金额 \/ 金额 \/ 金额/);
47
+ assert.doesNotMatch(tableCsv(data, data.rows), /金额 \/ 金额 \/ 金额/);
48
+ });
49
+
50
+ test('cross real null renders as dash while placeholders, missing payload and permissions stay distinct', () => {
51
+ const empty = {...crossMetric(null), value: null, formatText: null};
52
+ const cells = [empty, {...empty, dId: null}, {...empty, authority: false}, {...empty, authority: undefined}, {dId: 'amount', authority: true}, {...empty, formatText: '仅展示'}];
53
+ const data = normalize(cross([node('year', '2024', null, cells.map(() => ({})))], [node('region', '东部', null, cells)])).datasets[0];
54
+ assert.deepEqual(data.rows[0].cells.slice(1).map(c => c.state), ['NULL', 'UNAVAILABLE', 'RESTRICTED', 'UNAVAILABLE', 'UNAVAILABLE', 'UNAVAILABLE']);
55
+ assert.equal(data.rows[0].cells[1].reason, null);
56
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
57
+ assert.match(html, /<td>—<\/td><td>不可用<\/td><td>受限<\/td>/);
58
+ assert.match(tableCsv(data, data.rows), /"—","不可用","受限"/);
59
+ });
60
+
61
+ test('cross placeholders without valid metric ids never become available numeric values', () => {
62
+ const cells = [null, undefined, '', ' ', 12].map(dId => ({...crossMetric('12'), dId, value: 12}));
63
+ const data = normalize(cross([node('year', '2024', null, cells.map(() => ({})))], [node('region', '东部', null, cells)])).datasets[0];
64
+ for (const cell of data.rows[0].cells.slice(1)) {
65
+ assert.equal(cell.state, 'UNAVAILABLE');
66
+ assert.equal(cell.value, null);
67
+ assert.equal(cell.sourceFieldId, null);
68
+ }
69
+ });
70
+
71
+ test('cross table aligns repeated metric ids by column slots and preserves both axes and totals', () => {
72
+ for (const totalFirst of [false, true]) {
73
+ const columns = [node('year', '2024', null, [node('amount', '金额')]), node('year', '2025', null, [node('amount', '金额')])];
74
+ const summary = node('__summary_dim_id__', '合计', [node('amount', '金额', null, [{}])]);
75
+ columns.splice(totalFirst ? 0 : columns.length, 0, summary);
76
+ const values = [crossMetric('9007199254740993.12'), crossMetric('2.50')];
77
+ values.splice(totalFirst ? 0 : values.length, 0, crossMetric('9007199254740995.62', totalInfo));
78
+ const source = cross(columns, [node('region', '东部', [node('__summary_dim_id__', null, null, values)]), node('__summary_dim_id__', '合计', [node('amount', '金额', null, values.map(v => ({...v, rowSumInfo: totalInfo})))])]);
79
+ const result = normalize(source), data = result.datasets[0];
80
+ assert.equal(result.resultType, 'CROSS_TABLE'); assert.equal(result.normalization, 'COMPLETE');
81
+ assert.deepEqual(data.rows[0].cells.slice(1).map(c => c.value), values.map(v => v.text));
82
+ assert.equal(new Set(data.columns.map(c => c.fieldId)).size, 4);
83
+ assert.equal(data.rows[0].cells[1].sourceFieldId, 'amount');
84
+ assert.deepEqual(data.rows.map(r => r.kind), ['DATA', 'GRAND_TOTAL']);
85
+ assert.equal(data.columns[totalFirst ? 1 : 3].kind, 'GRAND_TOTAL');
86
+ assert.equal(result.range.mode, 'INITIAL_VIEW'); assert.equal(result.range.page, null);
87
+ assert.deepEqual(result.range.axes, {horizontal: {hasNext: true, hasPrevious: false}, vertical: {hasNext: false, hasPrevious: false}});
88
+ assert.equal(result.range.hasNext, true); assert.ok(result.warnings.includes('NEXT_PAGE_UNSUPPORTED'));
89
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
90
+ assert.match(html, /2024/); assert.match(html, /2025/); assert.match(html, /总计/);
91
+ assert.match(tableCsv(data, data.rows), /9007199254740993.12/);
92
+ assert.equal(source.previewResult.top.items[0].children.length, 3);
93
+ }
94
+ });
95
+
96
+ test('cross table supports metrics on rows and multi-level columns without id joins', () => {
97
+ const source = cross([node('year', '2024', [node('quarter', 'Q1', null, []), node('quarter', 'Q2', null, [])])], [
98
+ node('region', '东部', [node('target-name', '金额', [node('amount', '金额', null, [crossMetric('12'), crossMetric('18')])]), node('target-name', '数量', [node('count', '数量', null, [crossMetric('3', detailInfo, detailInfo, 'count'), crossMetric('4', detailInfo, detailInfo, 'count')])])]),
99
+ ]);
100
+ const data = normalize(source).datasets[0];
101
+ assert.equal(data.columns.length, 3); assert.equal(data.rows.length, 2);
102
+ assert.match(data.columns[1].name, /2024.*Q1/);
103
+ assert.match(data.rows[1].cells[0].display, /东部.*数量/);
104
+ assert.deepEqual(data.rows[1].cells.slice(1).map(c => c.sourceFieldId), ['count', 'count']);
105
+ });
106
+
107
+ test('cross table never exposes restricted axis labels and retains missing/ambiguous value states', () => {
108
+ const secret = node('year', 'secret column', null, [{}]); secret.authority = false;
109
+ const row = node('region', 'secret row', null, [crossMetric('123')]); row.authority = false;
110
+ const result = normalize(cross([secret], [row]));
111
+ assert.doesNotMatch(JSON.stringify(result), /secret column|secret row/);
112
+ assert.equal(result.datasets[0].rows[0].cells[0].state, 'RESTRICTED');
113
+ assert.equal(result.datasets[0].rows[0].cells[1].state, 'RESTRICTED');
114
+ const source = cross([node('year', 'A', null, [{}, {}])], [node('region', 'X', null, [{...crossMetric('1万'), formatText: '1万'}])]);
115
+ const incomplete = normalize(source);
116
+ assert.equal(incomplete.normalization, 'PARTIAL');
117
+ assert.equal(incomplete.datasets[0].rows[0].cells[1].state, 'UNAVAILABLE');
118
+ assert.equal(incomplete.datasets[0].rows[0].cells[2].reason, 'MISSING_CELL');
119
+ source.previewResult.left.items[0].children[0].values.push(crossMetric('2'), crossMetric('3'));
120
+ assert.equal(normalize(source).normalization, 'UNSUPPORTED', 'extra values must not be silently dropped');
121
+ });
122
+
123
+ test('cross table distinguishes empty views, missing axes and unsupported mixed node topology', () => {
124
+ assert.equal(normalize(cross([], [])).normalization, 'COMPLETE');
125
+ assert.equal(normalize(cross([], [])).datasets[0].returnedRowCount, 0);
126
+ assert.equal(normalize({previewResult: {resultType: 'BICROSS', left: {header: [], items: []}}}).normalization, 'UNSUPPORTED');
127
+ assert.equal(normalize(cross([node('x', 'A', [node('x', 'B')], [{}])], [])).normalization, 'UNSUPPORTED');
128
+ });
129
+
130
+ test('cross table handles no column dimensions and preserves metric-name intersection placeholders', () => {
131
+ const source = cross([], [node('target', '金额', null, [crossMetric('7'), {dId: null, text: null, value: null, authority: true}])]);
132
+ source.previewResult.top = {header: [], items: []};
133
+ const result = normalize(source);
134
+ assert.equal(result.normalization, 'COMPLETE');
135
+ assert.deepEqual(result.datasets[0].rows[0].cells.slice(1).map(c => c.state), ['AVAILABLE', 'UNAVAILABLE']);
136
+ assert.equal(result.datasets[0].rows[0].cells[2].value, null);
137
+ const swapped = cross([node('year', '2024', null, [node('amount', '金额')])], [node('region', '东部', null, [crossMetric('1', detailInfo, detailInfo, 'count')])]);
138
+ assert.equal(normalize(swapped).normalization, 'PARTIAL');
139
+ assert.equal(normalize(swapped).datasets[0].rows[0].cells[1].reason, 'AMBIGUOUS_FIELD');
140
+ });
141
+
142
+ test('group preserves parent keys, totals, metric precision and the first group page', () => {
143
+ const source = group([{dId: 'region', text: '东部', authority: true, values: [metric('9007199254740993.12', {totalSum: false, innerSum: true})], children: [
144
+ {dId: 'city', text: '甲城', authority: true, values: [metric('12.50', undefined, {formatText: '12.50元'})]},
145
+ ]}, {values: [metric('9007199254741005.62', {totalSum: true, innerSum: false})]}]);
146
+ const result = normalize(source), data = result.datasets[0];
147
+ assert.equal(result.resultType, 'GROUP_TABLE');
148
+ assert.equal(result.normalization, 'COMPLETE');
149
+ assert.deepEqual(data.rows.map(r => r.kind), ['SUBTOTAL', 'DATA', 'GRAND_TOTAL']);
150
+ assert.deepEqual(data.rows[1].cells.map(c => c.value), ['东部', '甲城', '12.50']);
151
+ assert.equal(data.rows[0].cells[2].value, '9007199254740993.12');
152
+ assert.equal(data.returnedRowCount, 3);
153
+ assert.deepEqual(result.range, {mode: 'PAGE', page: 1, pageSize: 10, pageUnit: 'GROUP', hasNext: true, hasPrevious: false, total: {value: null, unit: 'GROUP'}});
154
+ assert.ok(result.warnings.includes('NEXT_PAGE_UNSUPPORTED'));
155
+ const html = renderToStaticMarkup(createElement(DataTable, {dataset: data, pageSize: 10, binding: {type: 'table'}}));
156
+ assert.match(html, /小计/); assert.match(html, /总计/);
157
+ assert.match(tableCsv(data, data.rows), /GRAND_TOTAL/);
158
+ });
159
+
160
+ test('group keeps restricted and ambiguous cells unavailable and does not infer numeric group keys', () => {
161
+ const source = group([{dId: 'region', text: '42', authority: true, values: [metric('12万', undefined, {formatText: '12万'})], children: [
162
+ {dId: 'city', text: '秘密', authority: false, values: [metric('999', undefined, {authority: false})]},
163
+ {dId: 'city', text: '乙城', authority: true, values: [metric('1'), metric('2')]},
164
+ {dId: 'city', text: '丙城', authority: true, values: [metric(null, null)]},
165
+ ]}]);
166
+ source.widgetBean.dimensions.region.type = 2;
167
+ const result = normalize(source), rows = result.datasets[0].rows;
168
+ assert.equal(rows[0].cells[0].reason, 'GROUP_KEY_SEMANTICS_UNKNOWN');
169
+ assert.equal(rows[0].cells[2].state, 'UNAVAILABLE');
170
+ assert.equal(rows[1].cells[1].state, 'RESTRICTED');
171
+ assert.equal(rows[1].cells[2].state, 'RESTRICTED');
172
+ assert.equal(rows[2].cells[2].reason, 'AMBIGUOUS_FIELD');
173
+ assert.equal(rows[3].cells[2].state, 'UNAVAILABLE');
174
+ assert.equal(rows[3].kind, 'UNKNOWN');
175
+ assert.equal(result.normalization, 'PARTIAL');
176
+ });
177
+
178
+ test('group header expansion reports an initial view and malformed groups are not empty successes', () => {
179
+ const source = group([]);
180
+ source.widgetBean.settings.tableStyle.showRowHeaderIcon = true;
181
+ const result = normalize(source);
182
+ assert.equal(result.range.mode, 'INITIAL_VIEW');
183
+ assert.equal(result.range.page, null);
184
+ assert.equal(result.range.hasNext, null);
185
+ assert.equal(result.datasets[0].returnedRowCount, 0);
186
+ assert.equal(normalize({previewResult: {resultType: 'BIGROUP'}}).normalization, 'UNSUPPORTED');
187
+ source.previewResult.items = [null, {dId: 'unknown', values: [metric('1')]}];
188
+ assert.equal(normalize(source).normalization, 'PARTIAL');
189
+ });
@@ -0,0 +1,41 @@
1
+ import assert from 'node:assert/strict';
2
+ import {test} from 'node:test';
3
+ import {createServer as httpServer} from 'node:http';
4
+ import {mkdir,mkdtemp,cp,writeFile,readFile} from 'node:fs/promises';
5
+ import {resolve} from 'node:path';
6
+ import {createServer} from 'vite';
7
+
8
+ test('real Vite uses project env, persists sessions, blocks credential files and survives token updates',async()=>{
9
+ await mkdir('temp',{recursive:true});
10
+ const root=await mkdtemp(resolve('temp','vite-auth-'));
11
+ for(const name of ['vite.config.ts','scripts','src','public','index.html','package.json'])await cp(resolve(name),resolve(root,name),{recursive:true});
12
+ await mkdir(resolve(root,'temp'));
13
+ await writeFile(resolve(root,'temp','staged-session'),'staged-secret');
14
+ let logins=0,queries=0;
15
+ const upstream=httpServer(async(req,res)=>{
16
+ for await(const _ of req){};
17
+ res.setHeader('content-type','application/json');
18
+ if(req.url==='/decision/login'){logins++;res.end(JSON.stringify({data:{accessToken:'persisted-session'}}));return;}
19
+ assert.equal(req.headers.cookie,'fine_auth_token=persisted-session');queries++;
20
+ res.end(JSON.stringify({success:true,data:{}}));
21
+ });
22
+ await new Promise(r=>upstream.listen(0,'127.0.0.1',r));
23
+ const baseUrl=`http://127.0.0.1:${upstream.address().port}/decision`;
24
+ await writeFile(resolve(root,'.env.local'),`FINEBI_DEV_BASE_URL=${baseUrl}\nFINEBI_DEV_USERNAME=alice\nFINEBI_DEV_PASSWORD=password\n`);
25
+ await writeFile(resolve(root,'public','app-config.json'),JSON.stringify({basePath:'/decision',dashboardId:'r',widgets:[{entryId:'w'}],maxRows:10,filters:[]}));
26
+ const server=await createServer({root,configFile:resolve(root,'vite.config.ts'),cacheDir:resolve(root,'temp','cache'),server:{port:0},logLevel:'silent'});
27
+ try {
28
+ await server.listen();
29
+ const url=`http://127.0.0.1:${server.httpServer.address().port}`;
30
+ assert.equal((await fetch(url+'/')).status,200,'project under a temp parent must still serve its index');
31
+ assert.equal((await fetch(url+'/src/main.tsx')).status,200,'project source must remain loadable');
32
+ const send=()=>fetch(url+'/decision/v5/api/dashboard/report/r/widgets/query',{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:JSON.stringify({widgetIds:['w'],pageLimit:10})});
33
+ const result=await send();assert.equal(result.status,200);assert.doesNotMatch(await result.text(),/persisted-session|password/);
34
+ assert.match(await readFile(resolve(root,'.env.local'),'utf8'),/FINEBI_DEV_TOKEN="persisted-session"/);
35
+ assert.equal((await fetch(url+'/.env.local')).status,403);
36
+ assert.equal((await fetch(url+'/temp/staged-session')).status,403);
37
+ // Allow the file watcher to process the save; it must not restart the session manager.
38
+ await new Promise(r=>setTimeout(r,400));
39
+ assert.equal((await send()).status,200);assert.equal(logins,1);assert.equal(queries,2);
40
+ } finally {await server.close();await new Promise(r=>upstream.close(r));}
41
+ });
@@ -1,15 +1,20 @@
1
1
  import {readFileSync} from 'node:fs';
2
+ import {resolve} from 'node:path';
2
3
  import {fileURLToPath} from 'node:url';
3
- import {defineConfig, loadEnv} from 'vite';
4
+ import {defineConfig, loadEnv, normalizePath} from 'vite';
4
5
  // @ts-expect-error The development-only Node module is tested directly with node:test.
5
6
  import {createDevMiddleware} from './scripts/dev-proxy.mjs';
7
+ const projectTemp=normalizePath(fileURLToPath(new URL('./temp',import.meta.url)))+'/**';
6
8
  export default defineConfig(({mode}) => ({
7
9
  base: './',
8
10
  resolve:{alias:[{find:/^date-fns\/locale$/,replacement:fileURLToPath(new URL('./src/components/calendar-locales.ts',import.meta.url))}]},
9
- server: {host:'127.0.0.1',port:4173,strictPort:true},
11
+ server: {host:'127.0.0.1',port:4173,strictPort:true,watch:{ignored:['**/.env.local',projectTemp]},
12
+ fs:{deny:['.env','.env.*','*.{crt,pem}','**/.git/**',projectTemp]}},
10
13
  plugins:[{name:'finebi-development-session',apply:'serve',configureServer(server) {
11
14
  const config=JSON.parse(readFileSync(new URL('./public/app-config.json',import.meta.url),'utf8'));
12
- const env={...loadEnv(mode,process.cwd(),'FINEBI_DEV_'),...process.env};
13
- server.middlewares.use(createDevMiddleware(config,env));
15
+ const env={...loadEnv(mode,server.config.root,'FINEBI_DEV_'),...process.env};
16
+ const middleware=createDevMiddleware(config,env,fetch,{file:resolve(server.config.root,'.env.local')});
17
+ server.middlewares.use(middleware);
18
+ server.httpServer?.once('close',()=>middleware.dispose());
14
19
  }}],
15
20
  }));