basalt-ui 1.23.0 → 1.24.0
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/AGENTS.md +31 -17
- package/CHANGELOG.md +23 -0
- package/MIGRATING.md +222 -6
- package/README.md +159 -49
- package/agent/rules/basalt-app.md +8 -2
- package/agent/rules/basalt-charts.md +38 -2
- package/agent/rules/basalt-content.md +3 -3
- package/agent/rules/basalt-data.md +45 -13
- package/agent/rules/basalt-mantine.md +4 -4
- package/agent/rules/basalt-query.md +57 -0
- package/agent/rules/basalt-tokens.md +18 -5
- package/agent/skills/basalt-app/SKILL.md +32 -12
- package/agent/skills/basalt-charts/SKILL.md +4 -2
- package/agent/templates/CLAUDE-block.md.tpl +3 -2
- package/dist/charts/kinds/BandStrip.d.ts +20 -6
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +59 -6
- package/dist/charts/kinds/MirroredBars.d.ts +4 -2
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +13 -0
- package/dist/cli/index.d.ts +4 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +269 -27
- package/dist/dashboard/empty-state.d.ts +6 -2
- package/dist/dashboard/empty-state.d.ts.map +1 -1
- package/dist/dashboard/empty-state.js +1 -1
- package/dist/dashboard/index.d.ts +1 -0
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +8 -0
- package/dist/dashboard/query-state.d.ts +96 -0
- package/dist/dashboard/query-state.d.ts.map +1 -0
- package/dist/dashboard/query-state.js +123 -0
- package/dist/dashboard/query-state.test.d.ts +2 -0
- package/dist/dashboard/query-state.test.d.ts.map +1 -0
- package/dist/data/data-table.d.ts +61 -4
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +41 -4
- package/dist/data/data-table.test.d.ts +2 -0
- package/dist/data/data-table.test.d.ts.map +1 -0
- package/dist/data/table.d.ts +1 -1
- package/dist/data/table.d.ts.map +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +50 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/query/error-message.d.ts +41 -0
- package/dist/query/error-message.d.ts.map +1 -0
- package/dist/query/error-message.js +30 -0
- package/dist/query/error-message.test.d.ts +2 -0
- package/dist/query/error-message.test.d.ts.map +1 -0
- package/dist/query/index.d.ts +1 -19
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +2 -14
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/charts/kinds/BandStrip.test.tsx +87 -5
- package/src/charts/kinds/BandStrip.tsx +105 -12
- package/src/charts/kinds/MirroredBars.test.tsx +39 -0
- package/src/charts/kinds/MirroredBars.tsx +38 -2
- package/src/cli/check-theme.test.ts +34 -0
- package/src/cli/help.test.ts +74 -1
- package/src/cli/index.ts +496 -41
- package/src/cli/tokens-css.test.ts +129 -0
- package/src/cli/toolchain-wiring.test.ts +146 -0
- package/src/dashboard/empty-state.tsx +9 -3
- package/src/dashboard/index.ts +11 -0
- package/src/dashboard/query-state.test.tsx +152 -0
- package/src/dashboard/query-state.tsx +272 -0
- package/src/data/data-table.test.tsx +158 -0
- package/src/data/data-table.tsx +123 -5
- package/src/data/table.ts +6 -1
- package/src/guard/check-source.test.ts +214 -0
- package/src/guard/index.ts +169 -15
- package/src/index.ts +9 -0
- package/src/query/error-message.test.ts +67 -0
- package/src/query/error-message.ts +70 -0
- package/src/query/index.ts +2 -34
- package/src/surfaces.ts +1 -1
package/dist/query/index.d.ts
CHANGED
|
@@ -40,25 +40,7 @@ export declare function unwrap<TData>(response: Promise<{
|
|
|
40
40
|
data: TData | null;
|
|
41
41
|
error: unknown;
|
|
42
42
|
}>): Promise<TData>;
|
|
43
|
-
|
|
44
|
-
* Extract a human-readable message from an unknown thrown value (e.g. the raw Eden envelope
|
|
45
|
-
* thrown by `unwrap`). Removes the 8-line three-branch boilerplate consumers duplicate.
|
|
46
|
-
*
|
|
47
|
-
* Resolution order:
|
|
48
|
-
* 1. `Error` instance → err.message
|
|
49
|
-
* 2. `{ message: string }` → message (Eden / Elysia error shape)
|
|
50
|
-
* 3. `{ value: ... }` → toErrorMessage(value) recursively (Eden nested value envelope)
|
|
51
|
-
* 4. plain string → itself
|
|
52
|
-
* 5. anything else → JSON.stringify with safe fallback to String()
|
|
53
|
-
*
|
|
54
|
-
* @example
|
|
55
|
-
* try {
|
|
56
|
-
* await unwrap(api.resource.post({ body }))
|
|
57
|
-
* } catch (e) {
|
|
58
|
-
* toast(toErrorMessage(e))
|
|
59
|
-
* }
|
|
60
|
-
*/
|
|
61
|
-
export declare function toErrorMessage(err: unknown): string;
|
|
43
|
+
export { toErrorMessage, errorStatus } from './error-message.js';
|
|
62
44
|
export { QueryClientProvider, QueryErrorResetBoundary, useQueryErrorResetBoundary, } from '@tanstack/react-query';
|
|
63
45
|
export { useQuery, useSuspenseQuery, useMutation, useQueryClient, useInfiniteQuery, queryOptions, } from '@tanstack/react-query';
|
|
64
46
|
export { BasaltQueryDevtools } from './devtools.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/query/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AACnD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA;AAW9D;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,CAAC,EAAE,iBAAiB,GAAG,WAAW,CAQ/E;AAID;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,MAAM,CAAC,KAAK,EAChC,QAAQ,EAAE,OAAO,CAAC;IAAE,IAAI,EAAE,KAAK,GAAG,IAAI,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAC,GACxD,OAAO,CAAC,KAAK,CAAC,CAQhB;AAID
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/query/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AACnD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAA;AAW9D;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,CAAC,EAAE,iBAAiB,GAAG,WAAW,CAQ/E;AAID;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,MAAM,CAAC,KAAK,EAChC,QAAQ,EAAE,OAAO,CAAC;IAAE,IAAI,EAAE,KAAK,GAAG,IAAI,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAC,GACxD,OAAO,CAAC,KAAK,CAAC,CAQhB;AAID,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAK7D,OAAO,EACL,mBAAmB,EACnB,uBAAuB,EACvB,0BAA0B,GAC3B,MAAM,uBAAuB,CAAA;AAG9B,OAAO,EACL,QAAQ,EACR,gBAAgB,EAChB,WAAW,EACX,cAAc,EACd,gBAAgB,EAChB,YAAY,GACb,MAAM,uBAAuB,CAAA;AAI9B,OAAO,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAA"}
|
package/dist/query/index.js
CHANGED
|
@@ -23,20 +23,7 @@ async function unwrap(response) {
|
|
|
23
23
|
);
|
|
24
24
|
return data;
|
|
25
25
|
}
|
|
26
|
-
|
|
27
|
-
if (err instanceof Error) return err.message;
|
|
28
|
-
if (typeof err === "string") return err;
|
|
29
|
-
if (typeof err === "object" && err !== null) {
|
|
30
|
-
const obj = err;
|
|
31
|
-
if (typeof obj["message"] === "string") return obj["message"];
|
|
32
|
-
if ("value" in obj) return toErrorMessage(obj["value"]);
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
return JSON.stringify(err);
|
|
36
|
-
} catch {
|
|
37
|
-
return String(err);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
26
|
+
import { toErrorMessage, errorStatus } from "./error-message.js";
|
|
40
27
|
import {
|
|
41
28
|
QueryClientProvider,
|
|
42
29
|
QueryErrorResetBoundary,
|
|
@@ -56,6 +43,7 @@ export {
|
|
|
56
43
|
QueryClientProvider,
|
|
57
44
|
QueryErrorResetBoundary,
|
|
58
45
|
createBasaltQueryClient,
|
|
46
|
+
errorStatus,
|
|
59
47
|
queryOptions,
|
|
60
48
|
toErrorMessage,
|
|
61
49
|
unwrap,
|
package/dist/surfaces.d.ts
CHANGED
|
@@ -131,7 +131,7 @@ export declare const SURFACES: {
|
|
|
131
131
|
readonly rule: "mantine";
|
|
132
132
|
readonly skill: readonly ["basalt-app", "basalt-design"];
|
|
133
133
|
readonly guardKinds: readonly ["raw-motion-value", "card-with-border", "raw-form-control", "sub-16-input-font", "mantine-shade-index"];
|
|
134
|
-
readonly description: "BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, SettingsSection/SettingsRow/DangerZone)";
|
|
134
|
+
readonly description: "BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, QueryState/LoadingState/ErrorState, SettingsSection/SettingsRow/DangerZone)";
|
|
135
135
|
readonly optionalPeers: readonly ["react-markdown", "remark-gfm", "rehype-sanitize", "remend", "shiki", "beautiful-mermaid"];
|
|
136
136
|
readonly forbiddenImports: readonly [];
|
|
137
137
|
};
|
package/dist/surfaces.js
CHANGED
|
@@ -29,7 +29,7 @@ const SURFACES = {
|
|
|
29
29
|
"sub-16-input-font",
|
|
30
30
|
"mantine-shade-index"
|
|
31
31
|
],
|
|
32
|
-
description: "BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, SettingsSection/SettingsRow/DangerZone)",
|
|
32
|
+
description: "BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, QueryState/LoadingState/ErrorState, SettingsSection/SettingsRow/DangerZone)",
|
|
33
33
|
optionalPeers: [
|
|
34
34
|
"react-markdown",
|
|
35
35
|
"remark-gfm",
|
package/llms.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# basalt-ui v1.
|
|
1
|
+
# basalt-ui v1.24.0
|
|
2
2
|
# Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
|
|
3
3
|
#
|
|
4
4
|
# basalt-ui is an opinionated framework for Mantine-based React apps.
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
# ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
|
|
20
20
|
|
|
21
21
|
Package: basalt-ui
|
|
22
|
-
Version: 1.
|
|
22
|
+
Version: 1.24.0
|
|
23
23
|
|
|
24
24
|
## basalt-ui
|
|
25
25
|
Import: import { ... } from 'basalt-ui'
|
|
26
|
-
Description: BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, SettingsSection/SettingsRow/DangerZone)
|
|
26
|
+
Description: BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, QueryState/LoadingState/ErrorState, SettingsSection/SettingsRow/DangerZone)
|
|
27
27
|
Layer: mantine-coupled
|
|
28
28
|
Rule: basalt-mantine
|
|
29
29
|
OptionalPeers: react-markdown@^10.1.0, remark-gfm@^4.0.1, rehype-sanitize@^6.0.0, remend@1.3.0, shiki@^4.3.0, beautiful-mermaid@^1.1.0
|
package/package.json
CHANGED
|
@@ -87,13 +87,77 @@ describe('BandStrip — fills derive from series', () => {
|
|
|
87
87
|
// Overridden: the ramp value, not the series default.
|
|
88
88
|
expect(fills).toContain('color-mix(in srgb, #a00 62%, transparent)')
|
|
89
89
|
})
|
|
90
|
+
})
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
/**
|
|
93
|
+
* The old contract here was "a state key absent from `series` draws nothing". On a strip whose
|
|
94
|
+
* whole vocabulary is measured/not-measured, a missing band is not "unknown" — it is a coverage
|
|
95
|
+
* gap, so a typo'd key silently asserted an absence nobody measured. These pin the replacement:
|
|
96
|
+
* loud where it is a bug being written, honest where it is a feed that grew a state.
|
|
97
|
+
*/
|
|
98
|
+
/** Run `fn` with the production dev-gate, restoring whatever the runner had set. */
|
|
99
|
+
function inProd(fn: () => void): void {
|
|
100
|
+
const previous = process.env['NODE_ENV']
|
|
101
|
+
process.env['NODE_ENV'] = 'production'
|
|
102
|
+
try {
|
|
103
|
+
fn()
|
|
104
|
+
} finally {
|
|
105
|
+
if (previous === undefined) delete process.env['NODE_ENV']
|
|
106
|
+
else process.env['NODE_ENV'] = previous
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('BandStrip — an unresolvable state key can never read as absence', () => {
|
|
111
|
+
test('a state naming no `series` entry THROWS in dev, naming the key and the known ones', () => {
|
|
112
|
+
expect(() => renderStrip({ getBand: () => ({ state: 'nope' }) })).toThrow(
|
|
113
|
+
/BandSpan.state "nope" names no `series` entry \(known: clean, loss, absent\)/,
|
|
95
114
|
)
|
|
96
|
-
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('a marker naming no `series` entry throws in dev too', () => {
|
|
118
|
+
expect(() =>
|
|
119
|
+
renderStrip({ getBand: (d: Slot) => ({ ...getBand(d), marker: { state: 'typo' } }) }),
|
|
120
|
+
).toThrow(/BandSpan.marker.state "typo"/)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('in production it draws a dashed neutral band — never nothing, never a state fill', () => {
|
|
124
|
+
inProd(() => {
|
|
125
|
+
const { container } = renderStrip({ getBand: () => ({ state: 'nope' }) })
|
|
126
|
+
const painted = [...container.querySelectorAll('rect[fill]')].filter(
|
|
127
|
+
(r) => r.getAttribute('fill') !== 'transparent',
|
|
128
|
+
)
|
|
129
|
+
expect(painted).toHaveLength(3)
|
|
130
|
+
// The unknown treatment belongs to no legend entry: dashed outline, neutral fill, and none of
|
|
131
|
+
// the three series colours.
|
|
132
|
+
for (const rect of painted) {
|
|
133
|
+
expect(rect.getAttribute('stroke-dasharray')).toBe('3 2')
|
|
134
|
+
expect(rect.getAttribute('fill')).toBe(
|
|
135
|
+
'color-mix(in srgb, var(--vx-neutral) 20%, transparent)',
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('in production the tooltip names the unresolved key instead of a state the legend carries', async () => {
|
|
142
|
+
process.env['NODE_ENV'] = 'production'
|
|
143
|
+
try {
|
|
144
|
+
renderStrip({ getBand: () => ({ state: 'nope' }), ariaLabel: 'Availability' })
|
|
145
|
+
fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' })
|
|
146
|
+
const tip = await screen.findByRole('tooltip')
|
|
147
|
+
expect(tip.textContent).toContain('Unknown state')
|
|
148
|
+
expect(tip.textContent).toContain('nope')
|
|
149
|
+
expect(tip.textContent).not.toContain('Not measured')
|
|
150
|
+
} finally {
|
|
151
|
+
delete process.env['NODE_ENV']
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('`absentState` naming no `series` entry throws in EVERY environment — it is a prop, not a datum', () => {
|
|
156
|
+
inProd(() => {
|
|
157
|
+
expect(() => renderStrip({ absentState: 'ghost' })).toThrow(
|
|
158
|
+
/absentState "ghost" names no `series` entry/,
|
|
159
|
+
)
|
|
160
|
+
})
|
|
97
161
|
})
|
|
98
162
|
})
|
|
99
163
|
|
|
@@ -193,6 +257,24 @@ describe('BandStrip — the tooltip row is DERIVED from the hovered band’s sta
|
|
|
193
257
|
expect(tip2.textContent).not.toContain('No loss')
|
|
194
258
|
})
|
|
195
259
|
|
|
260
|
+
test('formatValue returning null renders an em dash, not an empty value', async () => {
|
|
261
|
+
const series: BandStripSeries<Slot>[] = SERIES.map((entry) =>
|
|
262
|
+
entry.key === 'absent'
|
|
263
|
+
? { ...entry, formatValue: (d: Slot) => (d.loss === null ? null : `${d.loss}%`) }
|
|
264
|
+
: entry,
|
|
265
|
+
)
|
|
266
|
+
renderStrip({ series, ariaLabel: 'Availability' })
|
|
267
|
+
const slider = screen.getByRole('slider')
|
|
268
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
269
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
270
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
271
|
+
|
|
272
|
+
const tip = await screen.findByRole('tooltip')
|
|
273
|
+
expect(tip.textContent).toContain('Not measured')
|
|
274
|
+
// Distinct from `''`, which renders the label with no value at all.
|
|
275
|
+
expect(tip.textContent).toContain('\u2014')
|
|
276
|
+
})
|
|
277
|
+
|
|
196
278
|
test('the focusable overlay carries the chart’s own aria-label, not a generic one', () => {
|
|
197
279
|
renderStrip({ ariaLabel: 'Availability per bucket' })
|
|
198
280
|
expect(screen.getByRole('slider').getAttribute('aria-label')).toBe('Availability per bucket')
|
|
@@ -25,17 +25,30 @@ import { fmtAxisDate } from '../utils/format'
|
|
|
25
25
|
* numeric value to plot — `formatValue` reads the datum directly.
|
|
26
26
|
*/
|
|
27
27
|
export type BandStripSeries<T> = SeriesStyle & {
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The derived tooltip row's value for a band in this state.
|
|
30
|
+
*
|
|
31
|
+
* Return `null` for a datum in this state whose reading is ABSENT — the row renders
|
|
32
|
+
* {@link NO_READING} instead, which is the escape `ChartSeries.getValue` has and this shape did
|
|
33
|
+
* not: with no value accessor, an absent reading had to come back as `''` and render as a label
|
|
34
|
+
* with an empty value, indistinguishable from a state whose name is the whole reading.
|
|
35
|
+
*
|
|
36
|
+
* Omit the function entirely for a state whose NAME *is* the whole reading ("Not measured").
|
|
37
|
+
*/
|
|
38
|
+
formatValue?: (d: T) => string | null
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
/** One drawn band — which state it is in, and the three ways a state can be qualified. */
|
|
34
42
|
export type BandSpan = {
|
|
35
43
|
/**
|
|
36
44
|
* Series key naming this band's state: the tie to its legend entry, its fill, and its derived
|
|
37
|
-
* tooltip row.
|
|
38
|
-
*
|
|
45
|
+
* tooltip row.
|
|
46
|
+
*
|
|
47
|
+
* A key not present in `series` is a bug TypeScript cannot catch (this is a `string`), and it
|
|
48
|
+
* used to draw NOTHING — which on a strip whose vocabulary is measured/not-measured is
|
|
49
|
+
* indistinguishable from a real coverage gap, so a typo silently asserted "not measured". It now
|
|
50
|
+
* throws in development and draws an explicit unknown band in production; see
|
|
51
|
+
* {@link unknownStateError} for why the two differ.
|
|
39
52
|
*/
|
|
40
53
|
state: string
|
|
41
54
|
/**
|
|
@@ -99,7 +112,8 @@ export type BandStripProps<T> = {
|
|
|
99
112
|
legend?: ChartLegendConfig | false
|
|
100
113
|
/** Colour of the absence hatch. Names a `series` entry, so absence is legended like any other
|
|
101
114
|
* state — a hatched band is the one mark a reader cannot decode from an axis. Default
|
|
102
|
-
* `VX.neutral`, unnamed.
|
|
115
|
+
* `VX.neutral`, unnamed. A key naming no `series` entry throws (a PROP, so a typo can only be a
|
|
116
|
+
* wiring error) rather than falling back to the unnamed default. */
|
|
103
117
|
absentState?: string
|
|
104
118
|
/** Per-side overrides of the measured margins — applied last. */
|
|
105
119
|
margin?: Partial<ChartMargin>
|
|
@@ -112,6 +126,52 @@ export type BandStripProps<T> = {
|
|
|
112
126
|
|
|
113
127
|
const DEFAULT_MARKER_INSET = 6
|
|
114
128
|
|
|
129
|
+
/** Rendered by a `formatValue` that returned `null` — an absent reading, told apart from a state
|
|
130
|
+
* whose name is the whole reading (which renders no value at all). */
|
|
131
|
+
const NO_READING = '\u2014'
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The one band treatment that belongs to no legend entry. A dashed neutral outline appears nowhere
|
|
135
|
+
* else on a strip — not as a state fill, not as the absence hatch — so an unresolvable key reads as
|
|
136
|
+
* "unnamed", never as data.
|
|
137
|
+
*/
|
|
138
|
+
const UNKNOWN_MARK = {
|
|
139
|
+
fill: alpha(VX.neutral, 0.2),
|
|
140
|
+
stroke: VX.neutral,
|
|
141
|
+
strokeWidth: 1,
|
|
142
|
+
strokeDasharray: '3 2',
|
|
143
|
+
} as const
|
|
144
|
+
|
|
145
|
+
/** The house dev gate (`src/provider`, `src/agent-chat` use the same expression): `basaltViteConfig`
|
|
146
|
+
* defines `process.env.NODE_ENV`, so a production bundle constant-folds this to `false` and drops
|
|
147
|
+
* the throw. Read per call, never hoisted to a module const, so a test can flip it. */
|
|
148
|
+
function isDev(): boolean {
|
|
149
|
+
return process.env['NODE_ENV'] !== 'production'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A `state` (or `marker.state`) naming no `series` entry.
|
|
154
|
+
*
|
|
155
|
+
* Dev throws, production draws {@link UNKNOWN_MARK}: `state` comes off the DATUM, so a feed that
|
|
156
|
+
* grows a state basalt has never seen must degrade to an honest unknown rather than take the page
|
|
157
|
+
* down — while a typo, which is the same input, still fails loudly everywhere it is being written.
|
|
158
|
+
* The one outcome ruled out in both is the old one: dropping the band, which renders as absence.
|
|
159
|
+
*/
|
|
160
|
+
function unknownStateError(what: string, key: string, known: Iterable<string>): Error {
|
|
161
|
+
return new Error(
|
|
162
|
+
`BandStrip: ${what} "${key}" names no \`series\` entry (known: ${[...known].join(', ')}) — so ` +
|
|
163
|
+
'it has no legend entry, no colour and no tooltip row. Add the state to `series`, or map the ' +
|
|
164
|
+
'datum onto one that is there.',
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A band's tooltip value: `null` from `formatValue` is an absent reading, `undefined` (no
|
|
169
|
+
* `formatValue` at all) is a state whose label is the whole row. */
|
|
170
|
+
function formatBandValue<T>(style: BandStripSeries<T>, datum: T): string {
|
|
171
|
+
if (style.formatValue === undefined) return ''
|
|
172
|
+
return style.formatValue(datum) ?? NO_READING
|
|
173
|
+
}
|
|
174
|
+
|
|
115
175
|
/**
|
|
116
176
|
* A 1-D categorical band strip: one rect per slot over a shared x axis, no y dimension at all.
|
|
117
177
|
*
|
|
@@ -187,6 +247,9 @@ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
|
|
|
187
247
|
const { bands, margin, plotWidth, scale, cursor, point, crosshairX, step, bandWidth } = band
|
|
188
248
|
const stripHeight = Math.max(plot.height - margin.top - margin.bottom, 1)
|
|
189
249
|
const hatchId = `${chartId}-band-absent`
|
|
250
|
+
if (absentState !== undefined && !styleByKey.has(absentState)) {
|
|
251
|
+
throw unknownStateError('absentState', absentState, styleByKey.keys())
|
|
252
|
+
}
|
|
190
253
|
const absentColor =
|
|
191
254
|
(absentState === undefined ? undefined : styleByKey.get(absentState)?.color) ?? VX.neutral
|
|
192
255
|
|
|
@@ -195,16 +258,35 @@ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
|
|
|
195
258
|
bands.forEach((d, i) => {
|
|
196
259
|
const span = getBand(d)
|
|
197
260
|
const style = styleByKey.get(span.state)
|
|
198
|
-
|
|
261
|
+
const x = i * step
|
|
262
|
+
if (style === undefined) {
|
|
263
|
+
if (isDev()) throw unknownStateError('BandSpan.state', span.state, styleByKey.keys())
|
|
264
|
+
out.push(
|
|
265
|
+
<rect
|
|
266
|
+
key={getX(d)}
|
|
267
|
+
{...UNKNOWN_MARK}
|
|
268
|
+
x={x}
|
|
269
|
+
y={0}
|
|
270
|
+
width={bandWidth}
|
|
271
|
+
height={stripHeight}
|
|
272
|
+
rx={1}
|
|
273
|
+
pointerEvents="none"
|
|
274
|
+
/>,
|
|
275
|
+
)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
if (hidden.has(span.state)) return
|
|
199
279
|
// Clamped AND finite-checked. `foldedFrom` is carried by the consumer's datum, so a
|
|
200
280
|
// 0/0 on an un-folded slot arrives here as NaN — which `Math.min`/`Math.max` propagate
|
|
201
281
|
// straight into `width="NaN"`, a band that silently fails to paint.
|
|
202
282
|
const absent = clampFraction(span.absentFraction)
|
|
203
283
|
const measuredWidth = bandWidth * (1 - absent)
|
|
204
284
|
const hatchWidth = bandWidth - measuredWidth
|
|
205
|
-
const x = i * step
|
|
206
285
|
const markerKey = span.marker?.state ?? span.state
|
|
207
286
|
const markerColor = styleByKey.get(markerKey)?.color
|
|
287
|
+
if (span.marker !== undefined && markerColor === undefined && isDev()) {
|
|
288
|
+
throw unknownStateError('BandSpan.marker.state', markerKey, styleByKey.keys())
|
|
289
|
+
}
|
|
208
290
|
const inset = span.marker?.inset ?? DEFAULT_MARKER_INSET
|
|
209
291
|
out.push(
|
|
210
292
|
<g key={getX(d)}>
|
|
@@ -230,14 +312,14 @@ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
|
|
|
230
312
|
pointerEvents="none"
|
|
231
313
|
/>
|
|
232
314
|
)}
|
|
233
|
-
{span.marker !== undefined &&
|
|
315
|
+
{span.marker !== undefined && measuredWidth > 0 && (
|
|
234
316
|
<rect
|
|
317
|
+
{...(markerColor === undefined ? UNKNOWN_MARK : { fill: markerColor })}
|
|
235
318
|
x={x}
|
|
236
319
|
y={inset}
|
|
237
320
|
width={measuredWidth}
|
|
238
321
|
height={Math.max(stripHeight - 2 * inset, 2)}
|
|
239
322
|
rx={1}
|
|
240
|
-
fill={markerColor}
|
|
241
323
|
pointerEvents="none"
|
|
242
324
|
/>
|
|
243
325
|
)}
|
|
@@ -347,11 +429,22 @@ function deriveBandRow<T>(
|
|
|
347
429
|
datum: T,
|
|
348
430
|
): { color: string; label: string; value: string; shape: 'line' | 'bar'; dashed: boolean } | null {
|
|
349
431
|
const style = styleByKey.get(span.state)
|
|
350
|
-
|
|
432
|
+
// Production-only: dev threw while drawing. The row names the KEY rather than a state, so the
|
|
433
|
+
// tooltip cannot read back a state the legend does not carry.
|
|
434
|
+
if (style === undefined) {
|
|
435
|
+
return {
|
|
436
|
+
color: VX.neutral,
|
|
437
|
+
label: 'Unknown state',
|
|
438
|
+
value: span.state,
|
|
439
|
+
shape: 'bar',
|
|
440
|
+
dashed: true,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (style.tooltip === false || hidden.has(span.state)) return null
|
|
351
444
|
return {
|
|
352
445
|
color: style.color,
|
|
353
446
|
label: style.label,
|
|
354
|
-
value: style
|
|
447
|
+
value: formatBandValue(style, datum),
|
|
355
448
|
shape: style.mark === 'line' ? 'line' : 'bar',
|
|
356
449
|
dashed: style.dash === 'dashed',
|
|
357
450
|
}
|
|
@@ -191,3 +191,42 @@ describe('MirroredBars — tooltip rows are derived, each in its own pane’s un
|
|
|
191
191
|
expect(tip.textContent).not.toContain('Upload')
|
|
192
192
|
})
|
|
193
193
|
})
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The `BandStrip` silent-drop class, in this kind's shape. An unresolvable pane key left the pane
|
|
197
|
+
* AND its axis unrendered — which on a chart whose point is that idle and unmeasured must stay
|
|
198
|
+
* distinguishable reads as "that half measured nothing".
|
|
199
|
+
*/
|
|
200
|
+
describe('MirroredBars — an unresolvable key can never read as a measured zero', () => {
|
|
201
|
+
test('a pane key naming no `series` entry throws instead of hiding the pane', () => {
|
|
202
|
+
expect(() => renderChart({ up: { key: 'uplaod', format: (v) => `${v}u`, ticks: 2 } })).toThrow(
|
|
203
|
+
/up.key "uplaod" names no `series` entry \(known: down, up, absent\)/,
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('it throws in production too — a pane key is a PROP, so a typo is always a wiring error', () => {
|
|
208
|
+
const previous = process.env['NODE_ENV']
|
|
209
|
+
process.env['NODE_ENV'] = 'production'
|
|
210
|
+
try {
|
|
211
|
+
expect(() =>
|
|
212
|
+
renderChart({ down: { key: 'donw', format: (v) => `${v}d`, ticks: 2 } }),
|
|
213
|
+
).toThrow(/down.key "donw"/)
|
|
214
|
+
} finally {
|
|
215
|
+
if (previous === undefined) delete process.env['NODE_ENV']
|
|
216
|
+
else process.env['NODE_ENV'] = previous
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('`absentState` naming no `series` entry throws rather than hatching in an unnamed neutral', () => {
|
|
221
|
+
expect(() => renderChart({ absentState: 'ghost' })).toThrow(
|
|
222
|
+
/absentState "ghost" names no `series` entry/,
|
|
223
|
+
)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('a resolvable absentState still draws the hatch in that entry’s colour', () => {
|
|
227
|
+
const { container } = renderChart({ absentState: 'absent' })
|
|
228
|
+
expect(container.querySelector('pattern line')?.getAttribute('stroke')).toBe(
|
|
229
|
+
'color-mix(in srgb, #555 70%, transparent)',
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
@@ -25,7 +25,8 @@ import { fmtAxisDate } from '../utils/format'
|
|
|
25
25
|
|
|
26
26
|
/** One of the two panes. Each resolves its own domain — that is the whole point of the kind. */
|
|
27
27
|
export type MirroredBarPane = {
|
|
28
|
-
/** Key of the `series` entry this pane draws: its colour, its legend entry, its tooltip row.
|
|
28
|
+
/** Key of the `series` entry this pane draws: its colour, its legend entry, its tooltip row.
|
|
29
|
+
* A key naming no entry throws — see {@link assertPaneKey}. */
|
|
29
30
|
key: string
|
|
30
31
|
/**
|
|
31
32
|
* The pane's upper bound. `'auto'` (default) is its own visible maximum; a number pins it.
|
|
@@ -85,7 +86,8 @@ export type MirroredBarsProps<T> = {
|
|
|
85
86
|
cursorResolution?: CursorResolution
|
|
86
87
|
tooltip?: BandTooltipConfig<T> | false
|
|
87
88
|
legend?: ChartLegendConfig | false
|
|
88
|
-
/** Series key whose colour the absence hatch is drawn in. Default `VX.neutral`.
|
|
89
|
+
/** Series key whose colour the absence hatch is drawn in. Default `VX.neutral`. A key naming no
|
|
90
|
+
* `series` entry throws rather than falling back to the unnamed default. */
|
|
89
91
|
absentState?: string
|
|
90
92
|
margin?: Partial<ChartMargin>
|
|
91
93
|
ariaLabel?: string
|
|
@@ -95,6 +97,31 @@ export type MirroredBarsProps<T> = {
|
|
|
95
97
|
const DEFAULT_UP_FRACTION = 0.35
|
|
96
98
|
const DEFAULT_PANE_TICKS = 3
|
|
97
99
|
|
|
100
|
+
/**
|
|
101
|
+
* A pane key naming no `series` entry.
|
|
102
|
+
*
|
|
103
|
+
* This is `BandStrip`'s silent-drop bug in the shape this kind has: an unresolvable `up.key` left
|
|
104
|
+
* `upSeries` undefined, which made `upVisible` false, which hid the pane AND its axis — rendering
|
|
105
|
+
* as "that half measured nothing", the measured-and-idle state the whole kind exists to keep
|
|
106
|
+
* distinguishable from absence. Unlike `BandSpan.state`, a pane key is a PROP and never comes off a
|
|
107
|
+
* datum, so a typo can only ever be a wiring error: this throws in every environment, with no
|
|
108
|
+
* production fallback, because there is no honest way to draw a pane whose colour and legend entry
|
|
109
|
+
* do not exist.
|
|
110
|
+
*/
|
|
111
|
+
function assertPaneKey<T>(
|
|
112
|
+
side: 'up' | 'down',
|
|
113
|
+
key: string,
|
|
114
|
+
resolved: ChartSeries<T> | undefined,
|
|
115
|
+
series: readonly ChartSeries<T>[],
|
|
116
|
+
): void {
|
|
117
|
+
if (resolved !== undefined) return
|
|
118
|
+
throw new Error(
|
|
119
|
+
`MirroredBars: ${side}.key "${key}" names no \`series\` entry ` +
|
|
120
|
+
`(known: ${series.map((s) => s.key).join(', ')}) — the pane would silently vanish, which ` +
|
|
121
|
+
'reads as a measured zero. Add the series, or point the pane at one that exists.',
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
98
125
|
/**
|
|
99
126
|
* Two bar panes over ONE x scale and one cursor, mirrored around a shared baseline, each in its
|
|
100
127
|
* own independent domain.
|
|
@@ -159,6 +186,8 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
|
|
|
159
186
|
const seriesByKey = useMemo(() => new Map(series.map((s) => [s.key, s])), [series])
|
|
160
187
|
const upSeries = seriesByKey.get(up.key)
|
|
161
188
|
const downSeries = seriesByKey.get(down.key)
|
|
189
|
+
assertPaneKey('up', up.key, upSeries, series)
|
|
190
|
+
assertPaneKey('down', down.key, downSeries, series)
|
|
162
191
|
const upVisible = upSeries !== undefined && !hidden.has(up.key)
|
|
163
192
|
const downVisible = downSeries !== undefined && !hidden.has(down.key)
|
|
164
193
|
|
|
@@ -221,6 +250,13 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
|
|
|
221
250
|
)
|
|
222
251
|
|
|
223
252
|
const hatchId = `${chartId}-mirrored-absent`
|
|
253
|
+
if (absentState !== undefined && !seriesByKey.has(absentState)) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`MirroredBars: absentState "${absentState}" names no \`series\` entry ` +
|
|
256
|
+
`(known: ${series.map((s) => s.key).join(', ')}) — the hatch would fall back to an unnamed ` +
|
|
257
|
+
'neutral, and a hatched bar is the one mark a reader cannot decode from either axis.',
|
|
258
|
+
)
|
|
259
|
+
}
|
|
224
260
|
const absentColor =
|
|
225
261
|
(absentState === undefined ? undefined : seriesByKey.get(absentState)?.color) ?? VX.neutral
|
|
226
262
|
|
|
@@ -654,6 +654,40 @@ describe('check-theme scans .css', () => {
|
|
|
654
654
|
})
|
|
655
655
|
})
|
|
656
656
|
|
|
657
|
+
// Round 9, rollhook: `SCANNABLE_EXT` stopped at `tsx?|css|html?|webmanifest`, so the marketing
|
|
658
|
+
// site's two `.astro` templates — its ENTIRE markup layer — were never opened, and check-theme
|
|
659
|
+
// reported a clean 4-file scan. `.jsx` and `.vue` were missing from the same regex, so a plain-JS
|
|
660
|
+
// or Vue consumer was unguarded end to end.
|
|
661
|
+
describe('check-theme scans the component extensions it was skipping', () => {
|
|
662
|
+
it('flags a raw hex in an .astro template', () => {
|
|
663
|
+
fixtureAt('pages/index.astro', '<style>\n .hero { color: #ff0000; }\n</style>\n')
|
|
664
|
+
const { code, err } = run()
|
|
665
|
+
expect(code).toBe(1)
|
|
666
|
+
expect(err).toContain('raw-hex')
|
|
667
|
+
expect(err).toContain('index.astro')
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
it('flags a raw hex in a .vue single-file component', () => {
|
|
671
|
+
fixtureAt('components/Hero.vue', '<style>\n .hero { color: #ff0000; }\n</style>\n')
|
|
672
|
+
const { code, err } = run()
|
|
673
|
+
expect(code).toBe(1)
|
|
674
|
+
expect(err).toContain('Hero.vue')
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('flags a raw inline border in a .jsx component', () => {
|
|
678
|
+
fixtureAt('Card.jsx', `export const C = () => <div style={{ border: '1px solid #ccc' }} />\n`)
|
|
679
|
+
const { code, err } = run()
|
|
680
|
+
expect(code).toBe(1)
|
|
681
|
+
expect(err).toContain('raw-surface')
|
|
682
|
+
expect(err).toContain('Card.jsx')
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it('stays quiet on a clean .astro file — the extension is scanned, not condemned', () => {
|
|
686
|
+
fixtureAt('pages/index.astro', '<style>\n .hero { color: var(--vx-ink); }\n</style>\n')
|
|
687
|
+
expect(run().code).toBe(0)
|
|
688
|
+
})
|
|
689
|
+
})
|
|
690
|
+
|
|
657
691
|
describe('check-theme comment skipping', () => {
|
|
658
692
|
it('does NOT flag a banned element mentioned in a pure line comment', () => {
|
|
659
693
|
fixtureAt(
|
package/src/cli/help.test.ts
CHANGED
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
* matched `cmd === 'sync'` first and `--help` was just another item in `flags`, ignored).
|
|
5
5
|
*/
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from 'node:fs'
|
|
8
16
|
import { tmpdir } from 'node:os'
|
|
9
17
|
import { resolve } from 'node:path'
|
|
10
18
|
|
|
@@ -95,3 +103,68 @@ describe('run() --help short-circuits before dispatch', () => {
|
|
|
95
103
|
expect(existsSync(resolve(dir, MANIFEST_PATH))).toBe(true)
|
|
96
104
|
})
|
|
97
105
|
})
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `--version` and the unknown-flag gate — one failure mode, two halves.
|
|
109
|
+
*
|
|
110
|
+
* Six consumer repos in one upgrade round reached for `basalt-ui --version` to prove WHICH CLI ran
|
|
111
|
+
* (a `bunx` cache does not re-resolve, so a pinned version and the version that gates you can
|
|
112
|
+
* differ) and got a usage dump with no version in it. The other half is the same shape: an
|
|
113
|
+
* unrecognized FLAG was silently ignored and the command exited 0, so a mistyped gate read as a
|
|
114
|
+
* passing gate.
|
|
115
|
+
*/
|
|
116
|
+
describe('run() --version and the unknown-flag gate', () => {
|
|
117
|
+
const version = (
|
|
118
|
+
JSON.parse(readFileSync(resolve(import.meta.dir, '../../package.json'), 'utf8')) as {
|
|
119
|
+
version: string
|
|
120
|
+
}
|
|
121
|
+
).version
|
|
122
|
+
|
|
123
|
+
it('`--version` prints the bare resolved version and exits 0', () => {
|
|
124
|
+
const { code, log } = capture(() => run(['--version'], dir))
|
|
125
|
+
expect(code).toBe(0)
|
|
126
|
+
expect(log.trim()).toBe(version)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('`-v` and `version` are the same thing', () => {
|
|
130
|
+
expect(capture(() => run(['-v'], dir)).log.trim()).toBe(version)
|
|
131
|
+
expect(capture(() => run(['version'], dir)).log.trim()).toBe(version)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('the printed line is greppable on its own — no usage block around it', () => {
|
|
135
|
+
const { log } = capture(() => run(['--version'], dir))
|
|
136
|
+
expect(log).not.toContain('Usage: basalt')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('an unrecognized flag exits 1 and NAMES it, on a command that would otherwise pass', () => {
|
|
140
|
+
const { code, log } = capture(() => run(['doctor', '--json'], dir))
|
|
141
|
+
expect(code).toBe(1)
|
|
142
|
+
expect(log).toContain("unrecognized flag '--json'")
|
|
143
|
+
expect(log).toContain('--tokens-only')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('a near-miss flag does not slip through as a silent no-op', () => {
|
|
147
|
+
const { code, log } = capture(() => run(['check-theme', '--audit-allow'], dir))
|
|
148
|
+
expect(code).toBe(1)
|
|
149
|
+
expect(log).toContain("unrecognized flag '--audit-allow'")
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('a value-taking flag does not make its VALUE look like an unknown flag', () => {
|
|
153
|
+
const out = resolve(dir, 'tokens.css')
|
|
154
|
+
const { code } = capture(() => run(['tokens:css', '--out', out, '--only', 'core'], dir))
|
|
155
|
+
expect(code).toBe(0)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('an unknown COMMAND names what it did not understand, above the usage block', () => {
|
|
159
|
+
const { code, log } = capture(() => run(['check-theme --audit-allows'], dir))
|
|
160
|
+
expect(code).toBe(1)
|
|
161
|
+
expect(log).toContain("unknown command 'check-theme --audit-allows'")
|
|
162
|
+
expect(log).toContain('Usage: basalt')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('`--help` still wins over the flag gate, so a reader can always find out what is accepted', () => {
|
|
166
|
+
const { code, log } = capture(() => run(['doctor', '--json', '--help'], dir))
|
|
167
|
+
expect(code).toBe(0)
|
|
168
|
+
expect(log).toContain('Usage: basalt')
|
|
169
|
+
})
|
|
170
|
+
})
|