@estiva-app/ui 0.20.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -2
- package/dist/gates/app-checks.d.ts +40 -0
- package/dist/gates/app-checks.d.ts.map +1 -0
- package/dist/gates/chunk-AUXD4GCY.js +478 -0
- package/dist/gates/chunk-AUXD4GCY.js.map +7 -0
- package/dist/gates/chunk-ZGJ2J5NU.js +754 -0
- package/dist/gates/chunk-ZGJ2J5NU.js.map +7 -0
- package/dist/gates/cli.d.ts +2 -0
- package/dist/gates/cli.d.ts.map +1 -0
- package/dist/gates/cli.js +46 -0
- package/dist/gates/cli.js.map +7 -0
- package/dist/gates/count.d.ts +20 -0
- package/dist/gates/count.d.ts.map +1 -0
- package/dist/gates/create-app-cli.d.ts +2 -0
- package/dist/gates/create-app-cli.d.ts.map +1 -0
- package/dist/gates/create-app.d.ts +23 -0
- package/dist/gates/create-app.d.ts.map +1 -0
- package/dist/gates/create-app.js +30 -0
- package/dist/gates/create-app.js.map +7 -0
- package/dist/gates/gate-config.d.ts +44 -0
- package/dist/gates/gate-config.d.ts.map +1 -0
- package/dist/gates/hook.d.ts +18 -0
- package/dist/gates/hook.d.ts.map +1 -0
- package/dist/gates/index.d.ts +30 -0
- package/dist/gates/index.d.ts.map +1 -0
- package/dist/gates/index.js +417 -0
- package/dist/gates/index.js.map +7 -0
- package/dist/gates/status.d.ts +88 -0
- package/dist/gates/status.d.ts.map +1 -0
- package/dist/gates/token-lint.d.ts +26 -0
- package/dist/gates/token-lint.d.ts.map +1 -0
- package/package.json +30 -4
- package/src/gates/app-checks.ts +226 -0
- package/src/gates/cli.ts +61 -0
- package/src/gates/count.ts +76 -0
- package/src/gates/create-app-cli.ts +25 -0
- package/src/gates/create-app.test.ts +72 -0
- package/src/gates/create-app.ts +797 -0
- package/src/gates/gate-config.ts +78 -0
- package/src/gates/gates.test.ts +194 -0
- package/src/gates/hook.ts +111 -0
- package/src/gates/index.ts +30 -0
- package/src/gates/status.ts +545 -0
- package/src/gates/token-lint.ts +231 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/gates/status.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Linter } from 'eslint';
|
|
2
|
+
/** Who reads the messages. */
|
|
3
|
+
export type TokenAudience = 'app' | 'package';
|
|
4
|
+
export interface TokenLintOptions {
|
|
5
|
+
/** The Tailwind config the unknown-class rule reads, relative to where ESLint runs. */
|
|
6
|
+
tailwindConfig?: string;
|
|
7
|
+
/** `app` (the default) names the package; `package` names `tokens.css` and the preset. */
|
|
8
|
+
audience?: TokenAudience;
|
|
9
|
+
}
|
|
10
|
+
/** What the token lint never reads: build output. */
|
|
11
|
+
export declare const TOKEN_LINT_IGNORES: string[];
|
|
12
|
+
/**
|
|
13
|
+
* The class rules. `no-unknown-classes` reads the app's own Tailwind config, so
|
|
14
|
+
* it knows exactly what the preset and the app generate.
|
|
15
|
+
*/
|
|
16
|
+
export declare function tokenLint({ tailwindConfig, audience }?: TokenLintOptions): Linter.Config;
|
|
17
|
+
/**
|
|
18
|
+
* UIG-28: the values the tokens already name, written by hand (Katerina's
|
|
19
|
+
* rulings B11, B12, C3 of 13 September and R2 of 15 September). Register it
|
|
20
|
+
* after `tokenLint`, whose parser and plugin settings it borrows. Under its own
|
|
21
|
+
* rule names, `token-values` and `token-spacing` (the same plugin twice, so one
|
|
22
|
+
* runs as errors and one as warnings): an escape for a hand-written size then
|
|
23
|
+
* cannot also silence a raw colour on the same line.
|
|
24
|
+
*/
|
|
25
|
+
export declare function tokenValues({ audience }?: Pick<TokenLintOptions, 'audience'>): Linter.Config;
|
|
26
|
+
//# sourceMappingURL=token-lint.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-lint.d.ts","sourceRoot":"","sources":["../../src/gates/token-lint.ts"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAKpC,8BAA8B;AAC9B,MAAM,MAAM,aAAa,GAAG,KAAK,GAAG,SAAS,CAAA;AAE7C,MAAM,WAAW,gBAAgB;IAC/B,uFAAuF;IACvF,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,0FAA0F;IAC1F,QAAQ,CAAC,EAAE,aAAa,CAAA;CACzB;AAED,qDAAqD;AACrD,eAAO,MAAM,kBAAkB,UAAkD,CAAA;AA6BjF;;;GAGG;AACH,wBAAgB,SAAS,CAAC,EAAE,cAAqC,EAAE,QAAgB,EAAE,GAAE,gBAAqB,GAAG,MAAM,CAAC,MAAM,CAqC3H;AAiCD;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,EAAE,QAAgB,EAAE,GAAE,IAAI,CAAC,gBAAgB,EAAE,UAAU,CAAM,GAAG,MAAM,CAAC,MAAM,CAqExG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estiva-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,15 +23,24 @@
|
|
|
23
23
|
"types": "./dist/eslint/index.d.ts",
|
|
24
24
|
"default": "./dist/eslint/index.js"
|
|
25
25
|
},
|
|
26
|
+
"./gates": {
|
|
27
|
+
"types": "./dist/gates/index.d.ts",
|
|
28
|
+
"default": "./dist/gates/index.js"
|
|
29
|
+
},
|
|
26
30
|
"./tailwind-preset": "./tailwind-preset.js",
|
|
27
31
|
"./tokens.css": "./tokens.css",
|
|
28
32
|
"./package.json": "./package.json",
|
|
29
33
|
"./base.css": "./base.css"
|
|
30
34
|
},
|
|
35
|
+
"bin": {
|
|
36
|
+
"create-estiva-app": "dist/gates/create-app.js",
|
|
37
|
+
"estiva-gates": "dist/gates/cli.js"
|
|
38
|
+
},
|
|
31
39
|
"scripts": {
|
|
32
40
|
"build": "node build.mjs && tsc -p tsconfig.build.json",
|
|
33
41
|
"storybook": "storybook dev -p 6008 --no-open",
|
|
34
42
|
"build-storybook": "storybook build",
|
|
43
|
+
"pretest": "node build.mjs",
|
|
35
44
|
"test": "vitest run --project unit",
|
|
36
45
|
"test:watch": "vitest --project unit",
|
|
37
46
|
"test:a11y": "vitest run --project storybook-signal --project storybook-ship",
|
|
@@ -40,15 +49,32 @@
|
|
|
40
49
|
"lint": "eslint .",
|
|
41
50
|
"prelint:rules": "node build.mjs",
|
|
42
51
|
"lint:rules": "eslint --config eslint.gates.config.js .",
|
|
43
|
-
"postlint:rules": "node
|
|
44
|
-
"
|
|
52
|
+
"postlint:rules": "node dist/gates/cli.js count --repo estiva-ui --package",
|
|
53
|
+
"pregates:status": "node build.mjs",
|
|
54
|
+
"gates:status": "node dist/gates/cli.js status",
|
|
55
|
+
"pregates:compare": "node build.mjs",
|
|
56
|
+
"gates:compare": "node scripts/gates-compare.mjs",
|
|
45
57
|
"prepublishOnly": "npm run build"
|
|
46
58
|
},
|
|
47
59
|
"peerDependencies": {
|
|
48
60
|
"@tabler/icons-react": "^3.41.1",
|
|
49
61
|
"react": "^19.0.0",
|
|
50
62
|
"react-dom": "^19.0.0",
|
|
51
|
-
"tailwindcss": "^3.4.0"
|
|
63
|
+
"tailwindcss": "^3.4.0",
|
|
64
|
+
"eslint": ">=9",
|
|
65
|
+
"eslint-plugin-better-tailwindcss": "^4.7.0",
|
|
66
|
+
"typescript-eslint": "^8.0.0"
|
|
67
|
+
},
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"eslint": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"eslint-plugin-better-tailwindcss": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"typescript-eslint": {
|
|
76
|
+
"optional": true
|
|
77
|
+
}
|
|
52
78
|
},
|
|
53
79
|
"devDependencies": {
|
|
54
80
|
"@eslint/js": "^10.0.1",
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* The gate checks every app runs, written once (docs/GATES.md §23).
|
|
4
|
+
*
|
|
5
|
+
* Until UIG-10 Peek and Ship each wrote them into their own
|
|
6
|
+
* `scripts/gates-checks.mjs`. 65 different checks for finished tickets, counted
|
|
7
|
+
* on 17 September: 28 were identical in both, 21 were the same check written two
|
|
8
|
+
* ways, 2 read each app's real page, and 14 read code only one app has. This is
|
|
9
|
+
* the first three groups as one list. The fourth stays in the app's own checks
|
|
10
|
+
* file, beside this list.
|
|
11
|
+
*
|
|
12
|
+
* Where Peek and Ship wrote one check two ways, the check here runs both, so it
|
|
13
|
+
* passes wherever either did: the raw-link probe with both of their texts, the
|
|
14
|
+
* token probes against each token config the app has.
|
|
15
|
+
*
|
|
16
|
+
* `scripts/gates-compare.mjs` holds this list to Peek's and Ship's: every check
|
|
17
|
+
* of theirs must be here, or be about code only that app has, or the comparison
|
|
18
|
+
* fails and names it.
|
|
19
|
+
*
|
|
20
|
+
* A check for a ticket not built yet is a first guess at the evidence, taken
|
|
21
|
+
* from the ticket's own text; the ticket that builds it changes it here.
|
|
22
|
+
*/
|
|
23
|
+
import type { CheckResult, GateCheck, GateHelpers, GateTicket } from './status'
|
|
24
|
+
|
|
25
|
+
export interface AppCheckOptions {
|
|
26
|
+
/** Where the app is inside the repository: `web` in Ship, `.` elsewhere. */
|
|
27
|
+
app?: string
|
|
28
|
+
/** A real page of the app, relative to the app, full of elements that are not controls. */
|
|
29
|
+
page: string
|
|
30
|
+
/** The ticket the gate chain is listed under, and whether this repo owns it: UIG-3 in Peek, UIG-4 in Ship. */
|
|
31
|
+
chain?: { ref: string; owner: boolean }
|
|
32
|
+
/** The token lint configs the app has, relative to the app. */
|
|
33
|
+
tokenConfigs?: string[]
|
|
34
|
+
/** The name of the app's header component a hand-made header row should name (UIG-22's first guess). */
|
|
35
|
+
headers?: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const APP_TICKET_TITLES: Record<string, string> = {
|
|
39
|
+
'UIG-2': 'The tracking rails — GATES.md, the status script, the guide committed',
|
|
40
|
+
'UIG-3': 'Tracer bullet — one rule, end to end, blocking in Peek',
|
|
41
|
+
'UIG-4': 'The same chain, blocking in Ship',
|
|
42
|
+
'UIG-6': 'Branch protection — the backstop, all three repos',
|
|
43
|
+
'UIG-7': 'Lint rule — every remaining raw element',
|
|
44
|
+
'UIG-8': 'Lint rule — forbid the reach',
|
|
45
|
+
'UIG-9': 'Lint rule — the className allow-list',
|
|
46
|
+
'UIG-13': "The registry widens to Peek's 115 and Ship's 74, with classification",
|
|
47
|
+
'UIG-19': 'Lock the contract in CI, and make the three Storybooks one search',
|
|
48
|
+
'UIG-20': 'The Claude skill, reading the registry',
|
|
49
|
+
'UIG-21': 'CLAUDE.md becomes an index, not a lecture',
|
|
50
|
+
'UIG-22': 'Fingerprint — a hand-made header row',
|
|
51
|
+
'UIG-23': 'Fingerprint — a hand-made empty state',
|
|
52
|
+
'UIG-24': 'Fingerprint — a browser tooltip where ours belongs',
|
|
53
|
+
'UIG-25': 'Fingerprint — a component copied out by hand',
|
|
54
|
+
'UIG-27': 'The components the apps had to build themselves — Link, ProgressBar, EmptyState padding',
|
|
55
|
+
'UIG-28': 'Close the two holes in the token contract — arbitrary values, and inline style',
|
|
56
|
+
'UIG-30': 'RichText — one component that draws a message\'s text, for both apps',
|
|
57
|
+
'UIG-32': 'Peek and Ship take their gate pieces from the package',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Run several checks as one: the first that does not pass is the answer. */
|
|
61
|
+
function all(h: GateHelpers, steps: (() => CheckResult | Promise<CheckResult>)[], label: string): () => Promise<CheckResult> {
|
|
62
|
+
return async () => {
|
|
63
|
+
for (const step of steps) {
|
|
64
|
+
const r = await step()
|
|
65
|
+
if (r.result !== 'pass') return r
|
|
66
|
+
}
|
|
67
|
+
return h.PASS(label)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function appChecks(h: GateHelpers, { app = '.', page, chain = { ref: 'UIG-3', owner: false }, tokenConfigs = ['eslint.tokens.config.js', 'eslint.config.js'], headers = ['ContainerHeader', 'SectionHeader'] }: AppCheckOptions): GateTicket[] {
|
|
72
|
+
const WEB = app === '.' ? '' : `${app.replace(/\/$/, '')}/`
|
|
73
|
+
const cwd = app === '.' ? undefined : app
|
|
74
|
+
const PROBE = 'src/components/__gates_probe__.tsx'
|
|
75
|
+
const GATES = 'eslint.gates.config.js'
|
|
76
|
+
const at = (rel: string) => `${WEB}${rel}`
|
|
77
|
+
const probe = (code: string, expect: 'error' | 'warning' | 'none', mentions?: string, file = PROBE, config = GATES) => () =>
|
|
78
|
+
h.lint({ ...(cwd ? { cwd } : {}), config, file, code, expect, mentions })
|
|
79
|
+
const component = (body: string) => `export function Probe() {\n return ${body}\n}\n`
|
|
80
|
+
const installed = at('node_modules/@estiva-app/ui/dist/index.d.ts')
|
|
81
|
+
const realPage = (): Promise<CheckResult> => h.lint({ ...(cwd ? { cwd } : {}), config: GATES, file: page, code: h.read(at(page)), expect: 'none' })
|
|
82
|
+
const button = component('<button type="button">x</button>')
|
|
83
|
+
const ticket = (ref: string, checks: GateCheck[], owner = false): GateTicket => ({ ref, title: APP_TICKET_TITLES[ref], owner, checks })
|
|
84
|
+
|
|
85
|
+
const tokenProbe = (code: string, expect: 'error' | 'warning' | 'none', mentions?: string, file = PROBE) =>
|
|
86
|
+
all(h, tokenConfigs.map((config) => probe(code, expect, mentions, file, config)), `${tokenConfigs.join(' and ')}: as expected`)
|
|
87
|
+
|
|
88
|
+
return [
|
|
89
|
+
ticket('UIG-2', [
|
|
90
|
+
{ what: 'npm run gates:status is wired', run: () => h.script('package.json', 'gates:status') },
|
|
91
|
+
]),
|
|
92
|
+
ticket(chain.ref, [
|
|
93
|
+
{ what: 'the gate lint config loads', run: () => h.loads(at(GATES)) },
|
|
94
|
+
{ what: `${at('package.json')} has lint:rules`, run: () => h.script(at('package.json'), 'lint:rules') },
|
|
95
|
+
{ what: 'CI runs lint:rules', run: () => h.ci('lint:rules') },
|
|
96
|
+
{ what: 'the committed hook runs the gate lint', run: () => h.hook('.claude/settings.json', 'gates') },
|
|
97
|
+
{ what: '.gates-count.json is committed and parses', run: () => h.json(h.exists('.gates-count.json') ? '.gates-count.json' : at('.gates-count.json')) },
|
|
98
|
+
{ what: 'a raw <button> is an error naming Button', run: probe(button, 'error', 'Button') },
|
|
99
|
+
{ what: 'the same in a story', run: probe(button, 'error', 'Button', 'src/stories/__gates_probe__.stories.tsx') },
|
|
100
|
+
{ what: 'a test file is not checked, while source is', run: async () => {
|
|
101
|
+
const source = await probe(button, 'error', 'Button')()
|
|
102
|
+
if (source.result !== 'pass') return h.FAIL(`source is not checked yet, so this proves nothing: ${source.detail}`)
|
|
103
|
+
return probe(button, 'none', undefined, 'src/components/__gates_probe__.test.tsx')()
|
|
104
|
+
} },
|
|
105
|
+
{ what: 'the debt list exists', run: () => h.file('docs/GATES-DEBT.md') },
|
|
106
|
+
], chain.owner),
|
|
107
|
+
ticket('UIG-6', [
|
|
108
|
+
{ what: 'GitHub requires the check gate to merge into main', run: () => h.protectedBranch(/^gate$/) },
|
|
109
|
+
{ what: "CI's job gate runs lint:rules", run: () => h.ciJob('gate', 'lint:rules') },
|
|
110
|
+
]),
|
|
111
|
+
ticket('UIG-7', [
|
|
112
|
+
{ what: 'a raw <input> is an error naming TextInput', run: probe(component('<input />'), 'error', 'TextInput') },
|
|
113
|
+
{ what: 'a raw <a> is an error naming Link', run: all(h, [probe(component('<a href="/topics">Topics</a>'), 'error', 'Link'), probe(component('<a href="/issues">Issues</a>'), 'error', 'Link')], 'a raw <a> is an error naming Link') },
|
|
114
|
+
{ what: 'a raw <form> is an error naming Form, and a file input one naming FilePicker', run: async () => {
|
|
115
|
+
const form = await probe(component('<form />'), 'error', '`Form`')()
|
|
116
|
+
if (form.result !== 'pass') return form
|
|
117
|
+
return probe(component('<input type="file" />'), 'error', 'FilePicker')()
|
|
118
|
+
} },
|
|
119
|
+
{ what: '<textarea>, <select>, <dialog> and <label> name Textarea, Select, DialogShell and Field', run: async () => {
|
|
120
|
+
for (const [element, part] of [['textarea', 'Textarea'], ['select', 'Select'], ['dialog', 'DialogShell'], ['label', 'Field']]) {
|
|
121
|
+
const one = await probe(component(`<${element} />`), 'error', `\`${part}\``)()
|
|
122
|
+
if (one.result !== 'pass') return one
|
|
123
|
+
}
|
|
124
|
+
return h.PASS('all four are errors, each naming its part')
|
|
125
|
+
} },
|
|
126
|
+
{ what: 'an element the package has no part for is an error that says to ask for one', run: probe(component('<iframe title="Map" />'), 'error', 'ask Katerina') },
|
|
127
|
+
{ what: 'an escaped element is not an error, while the same element is', run: async () => {
|
|
128
|
+
const raw = await probe(component('<form />'), 'error')()
|
|
129
|
+
if (raw.result !== 'pass') return h.FAIL(`a raw <form> is not caught yet, so this proves nothing: ${raw.detail}`)
|
|
130
|
+
return probe('export function Probe() {\n return (\n // @estiva-escape: a probe that keeps its element on purpose\n <form />\n )\n}\n', 'none')()
|
|
131
|
+
} },
|
|
132
|
+
{ what: 'a real page, full of elements that are not controls, gets no error', run: realPage },
|
|
133
|
+
]),
|
|
134
|
+
ticket('UIG-8', [
|
|
135
|
+
{ what: 'a Base UI import is an error naming the part built on it', run: probe("import { Popover } from '@base-ui/react/popover'\nexport const Probe = Popover\n", 'error', '`Popover`') },
|
|
136
|
+
{ what: 'createPortal is an error naming the floating parts', run: probe("import { createPortal } from 'react-dom'\nexport const Probe = (to: HTMLElement) => createPortal(<span />, to)\n", 'error', '`DialogShell`') },
|
|
137
|
+
{ what: 'a key listener on the whole page is an error, in a .ts file too', run: probe("export function listen() {\n window.addEventListener('keydown', () => {})\n}\n", 'error', '`DialogShell`', 'src/lib/__gates_probe__.ts') },
|
|
138
|
+
{ what: 'arrow keys by hand are an error; Enter in a field is not', run: async () => {
|
|
139
|
+
const arrows = await probe("export const onKeyDown = (e: KeyboardEvent) => e.key === 'ArrowDown'\n", 'error', '`Menu`', 'src/lib/__gates_probe__.ts')()
|
|
140
|
+
if (arrows.result !== 'pass') return arrows
|
|
141
|
+
return probe("export const onKeyDown = (e: KeyboardEvent) => e.key === 'Enter'\n", 'none', undefined, 'src/lib/__gates_probe__.ts')()
|
|
142
|
+
} },
|
|
143
|
+
{ what: 'a hand-written role="option" is an error naming Select', run: probe(component('<div role="option" />'), 'error', '`Select`') },
|
|
144
|
+
{ what: 'tabIndex={0} on a div is an error; tabIndex={-1} is not', run: async () => {
|
|
145
|
+
const zero = await probe(component('<div tabIndex={0} />'), 'error', '`Button`')()
|
|
146
|
+
if (zero.result !== 'pass') return h.FAIL(`tabIndex={0} is not caught yet, so this proves nothing: ${zero.detail}`)
|
|
147
|
+
return probe(component('<div tabIndex={-1} />'), 'none')()
|
|
148
|
+
} },
|
|
149
|
+
{ what: 'overflow-y-auto is an error naming ScrollArea', run: probe(component('<div className="h-64 overflow-y-auto" />'), 'error', '`ScrollArea`') },
|
|
150
|
+
{ what: 'an escaped listener is not an error, while the same listener is', run: async () => {
|
|
151
|
+
const raw = await probe("export function listen() {\n document.addEventListener('mousedown', () => {})\n}\n", 'error', undefined, 'src/lib/__gates_probe__.ts')()
|
|
152
|
+
if (raw.result !== 'pass') return h.FAIL(`a page listener is not caught yet, so this proves nothing: ${raw.detail}`)
|
|
153
|
+
return probe("export function listen() {\n // @estiva-escape: a probe that keeps its listener on purpose\n document.addEventListener('mousedown', () => {})\n}\n", 'none', undefined, 'src/lib/__gates_probe__.ts')()
|
|
154
|
+
} },
|
|
155
|
+
{ what: 'a real page gets no error', run: realPage },
|
|
156
|
+
]),
|
|
157
|
+
ticket('UIG-9', [
|
|
158
|
+
{ what: 'a border passed into Button is an error naming Button', run: probe(`import { Button } from '@estiva-app/ui'\n${component('<Button className="border">x</Button>')}`, 'error', 'on `Button` changes how it looks') },
|
|
159
|
+
{ what: 'placement passed into Button is not', run: probe(`import { Button } from '@estiva-app/ui'\n${component('<Button className="mt-2 w-full flex-1 relative">x</Button>')}`, 'none') },
|
|
160
|
+
{ what: 'an escaped look is not an error, while the same look is', run: async () => {
|
|
161
|
+
const raw = await probe(`import { Link } from '@estiva-app/ui'\n${component('<Link href="/x" className="after:absolute after:inset-0">x</Link>')}`, 'error', '`Link`')()
|
|
162
|
+
if (raw.result !== 'pass') return h.FAIL(`a restyled part is not caught yet, so this proves nothing: ${raw.detail}`)
|
|
163
|
+
return probe(`import { Link } from '@estiva-app/ui'\nexport function Probe() {\n return (\n // @estiva-escape: a probe that keeps its row link on purpose\n <Link href="/x" className="after:absolute after:inset-0">x</Link>\n )\n}\n`, 'none')()
|
|
164
|
+
} },
|
|
165
|
+
{ what: 'a real page gets no error', run: realPage },
|
|
166
|
+
]),
|
|
167
|
+
ticket('UIG-13', [
|
|
168
|
+
{ what: 'registry.json is committed and has entries', run: () => h.json(h.exists('registry.json') ? 'registry.json' : at('registry.json'), (d) => ((d as { entries?: unknown[]; components?: unknown[] }).entries ?? (d as { components?: unknown[] }).components ?? []).length > 0, 'registry.json has entries') },
|
|
169
|
+
]),
|
|
170
|
+
ticket('UIG-19', [
|
|
171
|
+
{ what: 'the usage-page contract runs in CI', run: () => h.ci(/[\w:-]*contract[\w:-]*/) },
|
|
172
|
+
]),
|
|
173
|
+
ticket('UIG-20', [
|
|
174
|
+
{ what: 'a committed skill runs ui:find', run: () => (h.listFiles('.claude/skills', (n) => n === 'SKILL.md').some((f) => h.read(f).includes('ui:find')) ? h.PASS('a skill runs ui:find') : h.FAIL('no skill in .claude/skills runs ui:find')) },
|
|
175
|
+
]),
|
|
176
|
+
ticket('UIG-21', [
|
|
177
|
+
{ what: 'path-scoped instructions exist', run: () => (h.listFiles('.claude/rules', (n) => n.endsWith('.md')).some((f) => /^paths:/m.test(h.read(f))) ? h.PASS('.claude/rules has paths: instructions') : h.FAIL('no .claude/rules file with paths:')) },
|
|
178
|
+
]),
|
|
179
|
+
ticket('UIG-22', [
|
|
180
|
+
{ what: 'a hand-made header row is an error naming the header part', run: all(h, headers.map((name) => probe(component(`<section className="flex flex-col"><div className="flex items-center px-3 py-2"><span>${name === 'SectionHeader' ? 'Issues' : 'Folders'}</span></div><div /></section>`), 'error', name)), 'a hand-made header row is an error naming the header part') },
|
|
181
|
+
]),
|
|
182
|
+
ticket('UIG-23', [
|
|
183
|
+
{ what: 'a hand-made empty line is an error naming EmptyState', run: probe("export function Probe({ items }: { items: string[] }) {\n return <div>{items.length === 0 ? <p className=\"text-text-secondary\">Nothing here</p> : items.map((i) => <span key={i}>{i}</span>)}</div>\n}\n", 'error', 'EmptyState') },
|
|
184
|
+
]),
|
|
185
|
+
ticket('UIG-24', [
|
|
186
|
+
{ what: 'a title= is an error naming WithTooltip', run: probe(`import { Button } from '@estiva-app/ui'\n${component('<Button title="Delete">x</Button>')}`, 'error', 'WithTooltip') },
|
|
187
|
+
]),
|
|
188
|
+
ticket('UIG-25', [
|
|
189
|
+
{ what: "SectionLabel's class list, typed by hand, is a warning", run: probe(component('<span className="text-[10px] uppercase tracking-wide text-text-muted">Label</span>'), 'warning', 'SectionLabel') },
|
|
190
|
+
]),
|
|
191
|
+
ticket('UIG-27', [
|
|
192
|
+
{ what: 'the installed package has Link', run: () => h.contains(installed, /\bLink\b/, 'the installed @estiva-app/ui exports Link') },
|
|
193
|
+
{ what: 'the installed package has ProgressBar', run: () => h.contains(installed, /\bProgressBar\b/, 'the installed @estiva-app/ui exports ProgressBar') },
|
|
194
|
+
]),
|
|
195
|
+
ticket('UIG-28', [
|
|
196
|
+
{ what: 'text-[14px] is an error', run: tokenProbe(component('<div className="text-[14px]">x</div>'), 'error') },
|
|
197
|
+
{ what: 'a colour in an inline style is an error', run: tokenProbe(component("<div style={{ backgroundColor: '#ff0000' }}>x</div>"), 'error') },
|
|
198
|
+
{ what: 'h-[240px] is a warning', run: tokenProbe(component('<div className="h-[240px]">x</div>'), 'warning') },
|
|
199
|
+
{ what: 'h-[240px] is not an error', run: tokenProbe(component('<div className="h-[240px]">x</div>'), 'none') },
|
|
200
|
+
{ what: 'a hand-written line height behind an arbitrary variant is an error', run: tokenProbe(component('<div className="[&_p]:leading-[1.4]">x</div>'), 'error') },
|
|
201
|
+
{ what: 'a raw colour in a class is an error (the pattern that matched nothing, R1)', run: tokenProbe(component('<div className="bg-[#5c69dc]">x</div>'), 'error', 'raw colour') },
|
|
202
|
+
{ what: 'a width and height from a prop pass', run: tokenProbe("export function Probe({ size }: { size: number }) {\n return <div style={{ width: size, height: size }}>x</div>\n}\n", 'none') },
|
|
203
|
+
{ what: 'a test file is not checked for hand-written values, while source is', run: all(h, tokenConfigs.map((config) => async () => {
|
|
204
|
+
const code = component("<div className=\"text-[14px]\" style={{ color: 'red' }}>x</div>")
|
|
205
|
+
const source = await probe(code, 'error', undefined, PROBE, config)()
|
|
206
|
+
if (source.result !== 'pass') return h.FAIL(`source is not checked yet, so this proves nothing: ${source.detail}`)
|
|
207
|
+
return probe(code, 'none', undefined, 'src/components/__gates_probe__.test.tsx', config)()
|
|
208
|
+
}), 'a test file is not checked for hand-written values, while source is') },
|
|
209
|
+
]),
|
|
210
|
+
ticket('UIG-30', [
|
|
211
|
+
{ what: 'the installed package has RichText', run: () => h.contains(installed, /\bRichText\b/, 'the installed @estiva-app/ui exports RichText') },
|
|
212
|
+
]),
|
|
213
|
+
// Katerina's ruling of 17 September (docs/GATES.md §23): one copy, in the package. An app made
|
|
214
|
+
// by create-estiva-app passes this from its first commit; Peek and Ship do once UIG-32 lands.
|
|
215
|
+
ticket('UIG-32', [
|
|
216
|
+
{ what: 'the gate pieces come from the package, and no repo keeps a copy', run: all(h, [
|
|
217
|
+
() => h.contains(at('eslint.gates.config.js'), /from '@estiva-app\/ui\/gates'/, 'the gate config imports @estiva-app/ui/gates'),
|
|
218
|
+
() => h.hook('.claude/settings.json', '@estiva-app/ui/dist/gates/cli.js'),
|
|
219
|
+
// `estiva-gates status` where npm can find the command; by path where it cannot,
|
|
220
|
+
// because the install is in the app's folder and the script is in the repo's top one (Ship).
|
|
221
|
+
() => h.contains('package.json', /"gates:status":\s*"[^"]*(?:estiva-gates|@estiva-app\/ui\/dist\/gates\/cli\.js)[^"]*status/, "gates:status runs the package's engine"),
|
|
222
|
+
() => (h.exists('scripts/gates-status.mjs') ? h.FAIL('scripts/gates-status.mjs is a copy of the status engine: run estiva-gates status instead') : h.PASS('no copy of the status engine')),
|
|
223
|
+
], 'the gate config, the hook and gates:status are the package\'s; no copy of the engine') },
|
|
224
|
+
]),
|
|
225
|
+
]
|
|
226
|
+
}
|
package/src/gates/cli.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* `estiva-gates` — the gate pieces as one command (docs/GATES.md §23).
|
|
4
|
+
*
|
|
5
|
+
* estiva-gates count --repo <name> [--package] write .gates-count.json (postlint:rules)
|
|
6
|
+
* estiva-gates hook [--app <dir>] [--package] the editor gate, run by a PreToolUse hook
|
|
7
|
+
* estiva-gates status [--app <dir>] [--json] [--detail] gates:status
|
|
8
|
+
*
|
|
9
|
+
* An app's `package.json` runs the first and the last through npm, which finds
|
|
10
|
+
* the command in `node_modules/.bin`. The hook is run by path, from the
|
|
11
|
+
* committed `.claude/settings.json`:
|
|
12
|
+
*
|
|
13
|
+
* node "$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js" hook
|
|
14
|
+
*
|
|
15
|
+
* A repo whose app sits in a folder of its own runs both by path, from the
|
|
16
|
+
* repo's top folder, and says where the app is — Ship (UIG-32):
|
|
17
|
+
*
|
|
18
|
+
* node "$CLAUDE_PROJECT_DIR/web/node_modules/@estiva-app/ui/dist/gates/cli.js" hook --app web
|
|
19
|
+
* node web/node_modules/@estiva-app/ui/dist/gates/cli.js status --app web
|
|
20
|
+
*/
|
|
21
|
+
import { writeGateCount } from './count'
|
|
22
|
+
import { runHook } from './hook'
|
|
23
|
+
import { runStatus } from './status'
|
|
24
|
+
|
|
25
|
+
const [command, ...rest] = process.argv.slice(2)
|
|
26
|
+
const flag = (name: string) => rest.includes(`--${name}`)
|
|
27
|
+
const value = (name: string) => {
|
|
28
|
+
const i = rest.indexOf(`--${name}`)
|
|
29
|
+
return i === -1 ? undefined : rest[i + 1]
|
|
30
|
+
}
|
|
31
|
+
const audience = flag('package') ? 'package' : 'app'
|
|
32
|
+
|
|
33
|
+
async function main(): Promise<number> {
|
|
34
|
+
switch (command) {
|
|
35
|
+
case 'count': {
|
|
36
|
+
const repo = value('repo')
|
|
37
|
+
if (!repo) {
|
|
38
|
+
process.stderr.write('estiva-gates count needs --repo <name>, the name written into .gates-count.json\n')
|
|
39
|
+
return 1
|
|
40
|
+
}
|
|
41
|
+
const result = await writeGateCount({ repo, audience, config: value('config') })
|
|
42
|
+
process.stdout.write(`${result.lines.join('\n')}\n`)
|
|
43
|
+
if (result.failures.length) process.stderr.write(`${result.failures.join('\n')}\n`)
|
|
44
|
+
return result.failures.length ? 1 : 0
|
|
45
|
+
}
|
|
46
|
+
case 'hook': {
|
|
47
|
+
const result = await runHook({ app: value('app'), audience })
|
|
48
|
+
if (result.code === 2) process.stderr.write(result.message)
|
|
49
|
+
return result.code
|
|
50
|
+
}
|
|
51
|
+
case 'status': {
|
|
52
|
+
process.stdout.write(`${await runStatus({ app: value('app'), json: flag('json'), detail: flag('detail') })}${flag('json') ? '' : '\n'}`)
|
|
53
|
+
return 0
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
process.stderr.write('estiva-gates <count | hook | status>\n')
|
|
57
|
+
return 1
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
process.exitCode = await main()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* Write `.gates-count.json`: what the gate finds, per rule — errors, warnings
|
|
4
|
+
* and escapes (seam S3 in docs/GATES.md §16). Written once (§23): until UIG-10
|
|
5
|
+
* Peek, Ship and this package each carried the same script, differing only in
|
|
6
|
+
* the repo's name and which rules it seeded.
|
|
7
|
+
*
|
|
8
|
+
* Runs after the gate lint (npm's `postlint:rules`), so CI runs it too. The
|
|
9
|
+
* file is committed: nothing compares it yet, and the later ratchet needs the
|
|
10
|
+
* history to start at the first commit. It lints with the gate's own config,
|
|
11
|
+
* with escapes reported, so an escape is counted rather than silent. The file
|
|
12
|
+
* is rewritten only when a count changes, so running the lint does not leave a
|
|
13
|
+
* changed file behind every time.
|
|
14
|
+
*
|
|
15
|
+
* It fails when an `eslint-disable` switched a gate rule off: that is not an
|
|
16
|
+
* escape, and the escape count cannot see it.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { createRequire } from 'node:module'
|
|
20
|
+
import { join, relative } from 'node:path'
|
|
21
|
+
import { pathToFileURL } from 'node:url'
|
|
22
|
+
import type { ESLint as ESLintClass } from 'eslint'
|
|
23
|
+
import { APP_RULE_IDS, countGates, PACKAGE_RULE_IDS, type GateCount } from '../eslint/index'
|
|
24
|
+
|
|
25
|
+
export interface CountOptions {
|
|
26
|
+
/** The folder the gate lints from, where `.gates-count.json` is written. */
|
|
27
|
+
root?: string
|
|
28
|
+
/** The name written into the file: `peek`, `ship`, `estiva-ui`, or the app's. */
|
|
29
|
+
repo: string
|
|
30
|
+
/** `app` seeds the app rules; `package` this package's inward set (UIG-5). */
|
|
31
|
+
audience?: 'app' | 'package'
|
|
32
|
+
/** The gate's config file, relative to `root`. */
|
|
33
|
+
config?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CountResult extends GateCount {
|
|
37
|
+
changed: boolean
|
|
38
|
+
/** What to print, line by line. */
|
|
39
|
+
lines: string[]
|
|
40
|
+
/** What to print to stderr when a rule was switched off; empty when none was. */
|
|
41
|
+
failures: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** ESLint from where the gate lints, never from beside this file: an app's own install. */
|
|
45
|
+
async function eslintFrom(root: string): Promise<typeof ESLintClass> {
|
|
46
|
+
const resolved = createRequire(join(root, 'package.json')).resolve('eslint')
|
|
47
|
+
return ((await import(pathToFileURL(resolved).href)) as { ESLint: typeof ESLintClass }).ESLint
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function writeGateCount({ root = process.cwd(), repo, audience = 'app', config = 'eslint.gates.config.js' }: CountOptions): Promise<CountResult> {
|
|
51
|
+
const out = join(root, '.gates-count.json')
|
|
52
|
+
const ESLint = await eslintFrom(root)
|
|
53
|
+
const eslint = new ESLint({
|
|
54
|
+
cwd: root,
|
|
55
|
+
overrideConfigFile: join(root, config),
|
|
56
|
+
overrideConfig: { settings: { estiva: { reportEscapes: true } } },
|
|
57
|
+
})
|
|
58
|
+
// The set the gate runs, so a rule with nothing to report still has its row.
|
|
59
|
+
const { rules, disabled } = countGates(await eslint.lintFiles(['.']), audience === 'package' ? PACKAGE_RULE_IDS : APP_RULE_IDS)
|
|
60
|
+
|
|
61
|
+
const previous = existsSync(out) ? (JSON.parse(readFileSync(out, 'utf8')) as { rules?: unknown }) : null
|
|
62
|
+
const changed = JSON.stringify(previous?.rules) !== JSON.stringify(rules)
|
|
63
|
+
if (changed) {
|
|
64
|
+
const count = { schemaVersion: 1, repo, generatedAt: new Date().toISOString(), rules }
|
|
65
|
+
writeFileSync(out, `${JSON.stringify(count, null, 2)}\n`)
|
|
66
|
+
}
|
|
67
|
+
const lines = [changed ? '.gates-count.json: written' : '.gates-count.json: unchanged']
|
|
68
|
+
for (const [id, c] of Object.entries(rules)) lines.push(` ${id}: ${c.errors} errors, ${c.warnings} warnings, ${c.escapes} escapes`)
|
|
69
|
+
|
|
70
|
+
const failures: string[] = []
|
|
71
|
+
if (disabled.length > 0) {
|
|
72
|
+
failures.push('', 'An eslint-disable switched a gate rule off. Write the reason above the element instead: // @estiva-escape: <reason>')
|
|
73
|
+
for (const d of disabled) failures.push(` ${relative(root, d.filePath)}:${d.line} ${d.ruleId}`)
|
|
74
|
+
}
|
|
75
|
+
return { rules, disabled, changed, lines, failures }
|
|
76
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* `create-estiva-app <name>` (UIG-10): the command. What it makes is
|
|
4
|
+
* `create-app.ts`; this file only reads the arguments and says what happened.
|
|
5
|
+
*
|
|
6
|
+
* npx -p @estiva-app/ui create-estiva-app leaf [--title Leaf] [--theme light] [--ui <version or tarball>]
|
|
7
|
+
*/
|
|
8
|
+
import { createApp } from './create-app'
|
|
9
|
+
|
|
10
|
+
const [name, ...rest] = process.argv.slice(2)
|
|
11
|
+
const value = (flag: string) => {
|
|
12
|
+
const i = rest.indexOf(`--${flag}`)
|
|
13
|
+
return i === -1 ? undefined : rest[i + 1]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
if (!name || name.startsWith('--')) throw new Error('create-estiva-app <name> [--title <on-screen name>] [--theme <theme>] [--ui <@estiva-app/ui version or tarball>]')
|
|
18
|
+
const dir = createApp({ name, title: value('title'), theme: value('theme'), ui: value('ui') })
|
|
19
|
+
process.stdout.write(
|
|
20
|
+
`Made ${dir}\n\nNext:\n cd ${name}\n npm install (on Linux, or in a Linux container, so the lockfile carries every platform)\n git init -b main && git add -A && git commit -m "The app, as create-estiva-app made it"\n npm run dev\n`,
|
|
21
|
+
)
|
|
22
|
+
} catch (error) {
|
|
23
|
+
process.stderr.write(`${(error as Error).message}\n`)
|
|
24
|
+
process.exitCode = 1
|
|
25
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
4
|
+
import { appFiles, createApp, themes } from './create-app'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `create-estiva-app` (UIG-10). What the app does when it runs is proved on a
|
|
8
|
+
* throwaway app (docs/GATES.md, UIG-10: building it); these hold what it is made
|
|
9
|
+
* of, and that it is made from this package alone.
|
|
10
|
+
*/
|
|
11
|
+
const versions = { '@estiva-app/identity': '^0.2.2', 'eslint-plugin-react-hooks': '^7.0.1' }
|
|
12
|
+
const files = appFiles({ name: 'probe-app', title: 'Probe', theme: 'dark', versions })
|
|
13
|
+
const scratch = mkdtempSync(join(process.cwd(), 'node_modules', '.create-app-test-'))
|
|
14
|
+
afterAll(() => rmSync(scratch, { recursive: true, force: true }))
|
|
15
|
+
|
|
16
|
+
describe('create-estiva-app', () => {
|
|
17
|
+
it('reads nothing but this package: no Peek, no Ship, no sibling checkout', () => {
|
|
18
|
+
const source = readFileSync(new URL('./create-app.ts', import.meta.url), 'utf8')
|
|
19
|
+
expect(source).not.toMatch(/\.\.\/(peek|ship)\b|GATES_PEEK|GATES_SHIP|K:[\\/]Estiva/)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('makes the frame, the checks and the gates, each as a file that imports the package', () => {
|
|
23
|
+
expect(Object.keys(files).sort()).toEqual(
|
|
24
|
+
[
|
|
25
|
+
'.claude/settings.json', '.env.example', '.gates-count.json', '.github/workflows/deploy.yml', '.gitignore', '.storybook/main.ts', '.storybook/preview.tsx',
|
|
26
|
+
'CLAUDE.md', 'README.md', 'docs/GATES-DEBT.md', 'eslint.config.js', 'eslint.gates.config.js', 'eslint.tokens.config.js', 'index.html', 'package.json',
|
|
27
|
+
'postcss.config.js', 'scripts/gates-checks.mjs', 'src/App.test.tsx', 'src/App.tsx', 'src/auth/AuthShell.tsx', 'src/auth/boot.ts', 'src/auth/estivaId.ts',
|
|
28
|
+
'src/config.ts', 'src/index.css', 'src/main.tsx', 'src/pages/HomePage.stories.tsx', 'src/pages/HomePage.tsx', 'src/vite-env.d.ts', 'tailwind.config.js',
|
|
29
|
+
'tsconfig.app.json', 'tsconfig.json', 'tsconfig.node.json', 'vite.config.ts',
|
|
30
|
+
].sort(),
|
|
31
|
+
)
|
|
32
|
+
expect(files['eslint.gates.config.js']).toContain("from '@estiva-app/ui/gates'")
|
|
33
|
+
expect(files['scripts/gates-checks.mjs']).toContain("appChecks(h, { page: 'src/pages/HomePage.tsx' })")
|
|
34
|
+
expect(JSON.parse(files['.claude/settings.json']).hooks.PreToolUse[0].hooks[0].command).toBe('node "$CLAUDE_PROJECT_DIR/node_modules/@estiva-app/ui/dist/gates/cli.js" hook')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('uses the sidebar frame and one theme, set on <html>', () => {
|
|
38
|
+
expect(files['src/App.tsx']).toMatch(/<AppShell[\s\S]*<Sidebar>[\s\S]*<NavItem/)
|
|
39
|
+
expect(files['src/App.tsx']).not.toContain('Rail')
|
|
40
|
+
expect(files['index.html']).toContain('<html lang="en" data-theme="dark">')
|
|
41
|
+
expect(files['.storybook/preview.tsx']).toContain('document.documentElement.dataset.theme = "dark"')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('starts the count at zero and keeps the CI job named gate', () => {
|
|
45
|
+
const count = JSON.parse(files['.gates-count.json'])
|
|
46
|
+
expect(Object.values(count.rules)).toEqual([{ errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }, { errors: 0, warnings: 0, escapes: 0 }])
|
|
47
|
+
expect(files['.github/workflows/deploy.yml']).toMatch(/\n {2}gate:\n[\s\S]*npm run lint:rules/)
|
|
48
|
+
expect(files['docs/GATES-DEBT.md']).toContain('Nothing.')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('never points at the real Estiva ID by itself', () => {
|
|
52
|
+
expect(files['.env.example']).toContain('VITE_ESTIVA_ID_ORIGIN=\n')
|
|
53
|
+
expect(Object.values(files).join('\n')).not.toContain('id.estiva.app')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('takes its tool versions from this package, and the two it does not use from what it is given', () => {
|
|
57
|
+
const own = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
58
|
+
const made = JSON.parse(files['package.json'])
|
|
59
|
+
expect(made.dependencies['@estiva-app/ui']).toBe(`^${own.version}`)
|
|
60
|
+
expect(made.devDependencies.vite).toBe(own.devDependencies.vite)
|
|
61
|
+
expect(made.devDependencies.eslint).toBe(own.devDependencies.eslint)
|
|
62
|
+
expect(made.dependencies['@estiva-app/identity']).toBe('^0.2.2')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('refuses a name an app cannot have, a theme the package has not got, and a folder that exists', () => {
|
|
66
|
+
expect(themes()).toEqual(expect.arrayContaining(['light', 'dark', 'signal', 'ship']))
|
|
67
|
+
expect(() => appFiles({ name: 'Leaf', versions })).toThrow(/not a name/)
|
|
68
|
+
expect(() => appFiles({ name: 'leaf', theme: 'purple', versions })).toThrow(/not one of the package's themes/)
|
|
69
|
+
createApp({ name: 'once', parent: scratch, versions })
|
|
70
|
+
expect(() => createApp({ name: 'once', parent: scratch, versions })).toThrow(/already exists/)
|
|
71
|
+
})
|
|
72
|
+
})
|