@weft-js/cli 0.0.0-stage → 0.1.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/LICENSE +16 -0
- package/README.md +74 -2
- package/bin/weft.mjs +8 -0
- package/docs/async.md +40 -0
- package/docs/browser.md +39 -0
- package/docs/config.md +93 -0
- package/docs/design.md +97 -0
- package/docs/fonts.md +70 -0
- package/docs/icons.md +102 -0
- package/docs/lists.md +23 -0
- package/docs/meta.md +82 -0
- package/docs/plugins.md +143 -0
- package/docs/props.md +77 -0
- package/docs/rules.md +32 -0
- package/docs/rust.md +223 -0
- package/docs/start-short.md +8 -0
- package/docs/start.md +422 -0
- package/docs/state.md +96 -0
- package/docs/style.md +59 -0
- package/docs/surface.md +111 -0
- package/docs/testing.md +71 -0
- package/docs/ui-state.md +92 -0
- package/docs/verify.md +48 -0
- package/package.json +39 -4
- package/src/add-rust.mjs +123 -0
- package/src/add.mjs +358 -0
- package/src/agents-md.mjs +134 -0
- package/src/alias.mjs +59 -0
- package/src/audit.mjs +333 -0
- package/src/browser/cli.mjs +34 -0
- package/src/browser/client.mjs +132 -0
- package/src/browser/commands.mjs +143 -0
- package/src/catalog.mjs +479 -0
- package/src/check.mjs +328 -0
- package/src/cli.mjs +332 -0
- package/src/config.d.ts +1 -0
- package/src/config.mjs +5 -0
- package/src/contributions.mjs +69 -0
- package/src/db-layer.mjs +33 -0
- package/src/design-checks.mjs +439 -0
- package/src/diag.mjs +74 -0
- package/src/docs.mjs +131 -0
- package/src/doctor.mjs +30 -0
- package/src/files.mjs +47 -0
- package/src/fix.mjs +40 -0
- package/src/gate.mjs +276 -0
- package/src/index.mjs +9 -0
- package/src/infer/engine.cjs +525 -0
- package/src/infer/facts.cjs +252 -0
- package/src/infer/hosts.cjs +114 -0
- package/src/infer/lsp.mjs +131 -0
- package/src/infer/session.cjs +105 -0
- package/src/infer/shadow.cjs +46 -0
- package/src/infer/worker.cjs +97 -0
- package/src/jsx-scan.mjs +86 -0
- package/src/load-config.mjs +274 -0
- package/src/map.mjs +110 -0
- package/src/mcp.mjs +195 -0
- package/src/meta-table.mjs +28 -0
- package/src/mutate.mjs +140 -0
- package/src/plugin-checks.mjs +89 -0
- package/src/plugins.mjs +172 -0
- package/src/project.mjs +219 -0
- package/src/props-checks.mjs +46 -0
- package/src/props-listener.mjs +38 -0
- package/src/react-checks.mjs +36 -0
- package/src/share-checks.mjs +74 -0
- package/src/site.mjs +19 -0
- package/src/slots.mjs +96 -0
- package/src/start.mjs +292 -0
- package/src/state-checks.mjs +213 -0
- package/src/style.mjs +320 -0
- package/src/surface-checks.mjs +218 -0
- package/src/tailwind.mjs +22 -0
- package/src/tasks.mjs +99 -0
- package/src/testdiff.mjs +157 -0
- package/src/tests.mjs +572 -0
- package/src/text-checks.mjs +172 -0
- package/src/tokens.mjs +24 -0
- package/src/trust.mjs +282 -0
- package/src/weft.mjs +66 -0
- package/src/workspace.mjs +105 -0
- package/ts-plugin/index.js +137 -0
- package/ts-plugin/package.json +1 -0
package/docs/surface.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Surfaces (`@weft-js/core/surface`)
|
|
2
|
+
|
|
3
|
+
A canvas or WebGL view that sits in layout like any other child. `setup` runs once on the client, props are live, and
|
|
4
|
+
nothing around it re-renders. Never write a raw `<canvas>`.
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
import { state } from '@weft-js/core'
|
|
8
|
+
import { defineSurface, Surface } from '@weft-js/core/surface'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
|
|
11
|
+
export const Bars = defineSurface({
|
|
12
|
+
name: 'bars',
|
|
13
|
+
kind: 'canvas2d', // or 'webgl2'
|
|
14
|
+
props: z.object({ data: z.array(z.number()) }), // or defineSurface<{ data: number[] }, 'canvas2d'>({ ... })
|
|
15
|
+
setup(s, props) { // runs once; `props.data` is live, so never destructure it
|
|
16
|
+
s.draw(() => { // repaints when a box it read changes; the canvas is cleared first
|
|
17
|
+
const { width, height } = s.size.value // CSS px, live
|
|
18
|
+
s.ctx.fillStyle = s.tokens.accent.css // live theme colours: `.css`, or `.linear` for shaders
|
|
19
|
+
props.data.forEach((v, i) => s.ctx.fillRect(i * 12, height - v, 10, v))
|
|
20
|
+
})
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export function Page() {
|
|
25
|
+
const rows = state([3, 7, 5])
|
|
26
|
+
return <Surface use={Bars} data={rows.value} aspect="16/9" label="Sales by month" />
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
A control beside the surface needs only a box and a live prop. Nothing re-renders when it moves:
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { state } from '@weft-js/core'
|
|
34
|
+
import { defineSurface, Surface } from '@weft-js/core/surface'
|
|
35
|
+
|
|
36
|
+
export const Stars = defineSurface<{ density: number }, 'canvas2d'>({ // name the kind too, so `s.ctx` is typed
|
|
37
|
+
name: 'stars',
|
|
38
|
+
kind: 'canvas2d',
|
|
39
|
+
setup(s, props) {
|
|
40
|
+
s.frame(({ seconds }) => { // every tick; reading `props.density` here keeps it live
|
|
41
|
+
for (let i = 0; i < props.density; i++) s.ctx.fillRect((i * 37 + seconds * 20) % s.size.value.width, (i * 91) % s.size.value.height, 1, 1)
|
|
42
|
+
})
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export function Sky() {
|
|
47
|
+
const density = state(100)
|
|
48
|
+
return <>
|
|
49
|
+
<input type="range" min="10" max="500" value={density.value} onInput={e => (density.value = e.currentTarget.valueAsNumber)} aria-label="Density" />
|
|
50
|
+
<Surface use={Stars} density={density.value} aspect="16/9" decorative />
|
|
51
|
+
</>
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**`<Surface>`:** `use`, your props, `aspect` (default `2/1`), `label` (required, X009) or `decorative`, `class`, `style`.
|
|
56
|
+
The server renders a sized placeholder, so nothing shifts when the canvas mounts. Put pointer handlers on `s.el`.
|
|
57
|
+
|
|
58
|
+
**`s`:**
|
|
59
|
+
- `s.ctx` (`canvas2d`), `s.gl` (`webgl2`), `s.canvas`, `s.el`
|
|
60
|
+
- `s.size`, `s.dpr`, `s.scale`: boxes. `canvas2d` draws in CSS px; `webgl2` has its viewport set for you
|
|
61
|
+
- `s.tokens.accent`: `--accent`, else `--warp-accent`, as `{ css, linear }`
|
|
62
|
+
- `s.draw(fn)`: paint when a box it read changes
|
|
63
|
+
- `s.frame(t => ...)`: animate: `t.frame`, `t.seconds`, `t.delta`. Also repaints on a box change while paused
|
|
64
|
+
- `s.invalidate()`: book a paint by hand
|
|
65
|
+
- `s.text(text, options)`: text; see below
|
|
66
|
+
- `return () => ...` from `setup` to clean up
|
|
67
|
+
|
|
68
|
+
`defineSurface` also takes `fps` (default 60), `maxDpr` (default 2), `clear: false`, `context` (`getContext` attributes),
|
|
69
|
+
and `pixel: { width, height }` for a fixed target with integer scale and nearest sampling.
|
|
70
|
+
|
|
71
|
+
**The clock** is shared: `import { clock } from '@weft-js/core/surface'`, then `clock.pause()`, `clock.play()`, `clock.seek(seconds)`.
|
|
72
|
+
`seek` paints every animated surface now, so a capture is deterministic. Under `prefers-reduced-motion: reduce` it
|
|
73
|
+
starts paused on the first frame. Off-screen surfaces skip their paints.
|
|
74
|
+
|
|
75
|
+
## Text
|
|
76
|
+
|
|
77
|
+
Never `fillText` (X010). `s.text()` keeps a real element over the text, so selection, find, translation, screen readers,
|
|
78
|
+
forced colours and print all work:
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
const score = s.text(() => `Score ${props.score}`, { x: 8, y: 8, width: 200, font: '600 16px Inter', lineHeight: 22 })
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- Options may be a function, for live positions: `() => ({ x: s.size.value.width / 2, ... })`.
|
|
85
|
+
- `font` and `lineHeight` default to the CSS on the mirror. `color` defaults to the surface's. `align` is `left`,
|
|
86
|
+
`center` or `right`. Name a family (X002).
|
|
87
|
+
- It returns `{ el, lineCount, height }`; the last two are boxes.
|
|
88
|
+
- Canvas `s.text()` needs the `text.measure` slot, so add `text()` from `@weft-js/text` to `plugins` in
|
|
89
|
+
weft.config.ts (P101). Its engine loads lazily. On `webgl2` the mirror is the visible text, and no plugin is needed.
|
|
90
|
+
- `s.decorative(() => ctx.fillText(...))` is for text that is truly decoration.
|
|
91
|
+
|
|
92
|
+
## Testing
|
|
93
|
+
|
|
94
|
+
jsdom has no canvas, so `@weft-js/core/test` gives it a recording one:
|
|
95
|
+
|
|
96
|
+
<!-- example: skip imports the Page above from its own file -->
|
|
97
|
+
```tsx
|
|
98
|
+
import { canvasCalls, clock, fakeMeasure, flush, render } from '@weft-js/core/test'
|
|
99
|
+
import { expect, test } from 'vitest'
|
|
100
|
+
import { Page } from './Page' // the Bars page above
|
|
101
|
+
|
|
102
|
+
test('draws a bar per value', async () => {
|
|
103
|
+
clock.install()
|
|
104
|
+
render(Page, {}, { slots: { 'text.measure': fakeMeasure() } })
|
|
105
|
+
await flush()
|
|
106
|
+
await clock.advance(16) // a frame
|
|
107
|
+
expect(canvasCalls(document.querySelector('canvas')!).filter(c => c.name === 'fillRect')).toHaveLength(3)
|
|
108
|
+
})
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`weft surfaces --json` (MCP `surfaces`) lists what the project already has.
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Testing (`@weft-js/core/test`, vitest)
|
|
2
|
+
|
|
3
|
+
A new app has no test runner. Add one:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -D vitest jsdom @testing-library/dom @testing-library/user-event
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
<!-- example: skip @weft-js/core/vite has no type declarations yet (TS7016 under strict) -->
|
|
10
|
+
```ts
|
|
11
|
+
// vitest.config.ts
|
|
12
|
+
import weft from '@weft-js/core/vite'
|
|
13
|
+
import { defineConfig } from 'vitest/config'
|
|
14
|
+
|
|
15
|
+
export default defineConfig({ plugins: [weft()], test: { environment: 'jsdom' } })
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then `npx vitest run`. A test:
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { state } from '@weft-js/core'
|
|
22
|
+
import { render, screen, user } from '@weft-js/core/test'
|
|
23
|
+
import { expect, test } from 'vitest'
|
|
24
|
+
|
|
25
|
+
function Cart() {
|
|
26
|
+
const count = state(0)
|
|
27
|
+
return <button type="button" onClick={() => count.value++}>Cart ({count.value})</button>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('adding to the cart', async () => {
|
|
31
|
+
render(Cart)
|
|
32
|
+
await user.click(screen.getByRole('button', { name: 'Cart (0)' }))
|
|
33
|
+
expect(screen.getByRole('button', { name: 'Cart (1)' })).toBeTruthy()
|
|
34
|
+
await expect(() => user.click(screen.getByRole('button'))).toTouchAtMost(1, { runs: 2 })
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- `render(Component, props, options)` mounts into the document with a fresh app; unmounted after each test.
|
|
39
|
+
`options.state` seeds `defineState` scopes, `options.storage` (`memoryStorage()`) is shared between renders to play
|
|
40
|
+
two tabs or a reload, and `options.slots` binds plugin slots. `scope(handle)` runs one scope with no UI.
|
|
41
|
+
- `flush()` applies pending updates. Every `user` action and `clock.advance` already ends in one.
|
|
42
|
+
- `screen` has role, label and text queries only (`getByRole`, `getByLabelText`, `getByText`, with `getAll`/`query`/`find`).
|
|
43
|
+
- `user.click`, `dblClick`, `type`, `clear`, `keyboard`, `hover`, `unhover`, `tab`, `selectOptions`, `deselectOptions`,
|
|
44
|
+
`upload` and `paste` (`@testing-library/user-event`) each end in `flush()`: never sleep or `waitFor`.
|
|
45
|
+
- `clock.install()` before the code schedules timers, then `await clock.advance(ms)` (or `clock.runAll()`): drives
|
|
46
|
+
timers, rAF, `Date` and the resources that wait on them, and flushes. Uninstalled after each test.
|
|
47
|
+
- `touches(fn)` returns `{ mutations, runs, … }` for one interaction. `toTouchAtMost(n, { runs })` bounds both: DOM writes
|
|
48
|
+
alone miss identity bugs that re-run every row but write the DOM once (W014).
|
|
49
|
+
|
|
50
|
+
## Junk tests: T001–T009
|
|
51
|
+
|
|
52
|
+
`weft check` reports these on test files (`test.include` in the config). A test that finishes without an assertion
|
|
53
|
+
also fails at runtime (T001). T012 and T013 are about open listeners and real sockets: `weft docs browser` and
|
|
54
|
+
`weft docs start`; T010, T011 and T020 are the gate: `weft docs verify`.
|
|
55
|
+
|
|
56
|
+
| Code | Catches | Instead |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| T001 | No assertion, or `expect(x)` with no matcher | Assert what the user sees |
|
|
59
|
+
| T002 | `expect(true).toBe(true)`, `expect(x).toBe(x)` | Assert on the code's output |
|
|
60
|
+
| T003 | `n.value = 5; expect(n.value).toBe(5)` | Act through the UI, assert what depends on it |
|
|
61
|
+
| T004 | `setTimeout` sleeps, real timers, `waitFor` | `flush()`, `clock.advance(ms)` |
|
|
62
|
+
| T005 | `container`, selectors, classes, `innerHTML`, test ids, app state in a component test (warning) | Role, label, text |
|
|
63
|
+
| T006 | `vi.mock('@weft-js/core')`, mocking the component under test | Fake the edges (API, storage) |
|
|
64
|
+
| T007 | Snapshots | Behavior assertions |
|
|
65
|
+
| T008 | `expect` under `if`/`?:`/`&&`/`catch`, or only inside loops | Assert the condition, then assert unconditionally |
|
|
66
|
+
| T009 | `.only`, `.skip`, `fit`, `xit` | Remove it; `test.fails` for a test meant to fail |
|
|
67
|
+
|
|
68
|
+
A deliberate exception needs a reason: `// weft-allow T007: the exported SVG is the product`. In the file's header
|
|
69
|
+
comments it covers the whole file; elsewhere, its own line and the next.
|
|
70
|
+
|
|
71
|
+
Plugins can use this clock too: `fakePay` from `@weft-js/pay/test` plays a rail on it, through your real webhook and ledger. `weft docs pay`.
|
package/docs/ui-state.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Shared UI state
|
|
2
|
+
|
|
3
|
+
Hover, focus and press are usually a pile of event handlers. `interaction()` makes them plain values you can read, draw and share. An app that doesn't use it ships none of it.
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { derived, watch } from '@weft-js/core'
|
|
7
|
+
import { interaction } from '@weft-js/core/interaction'
|
|
8
|
+
import { Button, Checkbox } from '@weft-js/warp'
|
|
9
|
+
|
|
10
|
+
type Todo = { id: string; title: string }
|
|
11
|
+
|
|
12
|
+
export function TodoRow({ todo }: { todo: Todo }) {
|
|
13
|
+
const row = interaction({ share: () => `todo:${todo.id}` })
|
|
14
|
+
const seen = derived(() => (row.remote.value.hover ? 'someone is here' : ''))
|
|
15
|
+
watch(() => row.hover.value, on => { if (on) void fetch(`/api/todos/${todo.id}`) }) // only ever your own hover
|
|
16
|
+
return (
|
|
17
|
+
<li>
|
|
18
|
+
<Checkbox ui={row}>{todo.title}</Checkbox>
|
|
19
|
+
<Button ui={row} emphasis="soft">Open</Button>
|
|
20
|
+
<small>{seen.value}</small>
|
|
21
|
+
</li>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Give a warp component the row with `ui`, and it fills the row from real events and draws from it. Everything else is reading:
|
|
27
|
+
|
|
28
|
+
| Read | It means | Who writes it |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `row.hover.value`, `row.focus.value`, `row.pressed.value` | true or false, on this page | real events, or your code |
|
|
31
|
+
| `row.remote.value.hover`, `.focus`, `.pressed` | how many others have it: other tabs and other clients. A count, never who. | only the network |
|
|
32
|
+
|
|
33
|
+
**Yours and theirs never mix.** Someone else's hover can't set your `row.hover`, so a `watch` on it can't run for them and can't echo back. To draw both by hand, read both: `row.hover.value || row.remote.value.hover`. warp does this for you: a row lit from anywhere gets `data-hold`, and `data-remote` when it's only others.
|
|
34
|
+
|
|
35
|
+
**What a press leaves behind is data.** `pressed` is true only while a button is held down, so others see the press and then it's gone. The tick a press leaves on a checkbox is the app's data, not UI state: share it the way you share any data, with `state(…, { persist, sync: 'tabs' })` for tabs or live data for other clients. The demo above does both.
|
|
36
|
+
|
|
37
|
+
## How far it reaches
|
|
38
|
+
|
|
39
|
+
| Where | How it travels | What you do |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| The same page | the same object: one key is one row | nothing |
|
|
42
|
+
| Other tabs of this browser | `BroadcastChannel`, one post a frame at most | nothing |
|
|
43
|
+
| Other browsers | one socket per browser, held by one tab | allow it, below |
|
|
44
|
+
|
|
45
|
+
Drop `share` and the row is private to that one `interaction()` call. An app without `@weft-js/start` shares on the page only.
|
|
46
|
+
|
|
47
|
+
When the tab holding the socket closes or crashes, another tab takes over; nothing flashes, and nobody sees a gap except, for one round trip, a hover made during the handover. When a tab or client goes, what it held clears.
|
|
48
|
+
|
|
49
|
+
## Who may see what
|
|
50
|
+
|
|
51
|
+
Sharing with other clients is off until you say who may, in `src/realtime.server.ts`:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { realtime } from '@weft-js/start'
|
|
55
|
+
|
|
56
|
+
export default realtime({ share: ({ ctx, key }) => ctx.user !== null && key.startsWith('todo:') })
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
That rule runs once per key. If it reads a database, a 200-row list means 200 queries, so answer them together instead. You get up to 400 keys in one call and give back the ones allowed:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { realtime } from '@weft-js/start'
|
|
63
|
+
|
|
64
|
+
// visibleKeys is yours: one query that returns the keys this user may see.
|
|
65
|
+
declare function visibleKeys(userId: string, keys: string[]): Promise<string[]>
|
|
66
|
+
|
|
67
|
+
export default realtime({
|
|
68
|
+
share: { keys: async ({ ctx, keys }) => (ctx.user ? visibleKeys(ctx.user.id, keys) : []) },
|
|
69
|
+
})
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Either form runs when a client joins and at each session re-check, and uses the same `ctx` as a channel's `authorize`. A rule that throws refuses what it was asked. With no `share` the socket refuses every key (R011), and a refused key still works between tabs. `() => true` must be written out.
|
|
73
|
+
|
|
74
|
+
Peers see the three bits and an opaque connection id. No name, no pointer position, nothing typed. For names and cursors use `@weft-js/multiplayer`.
|
|
75
|
+
|
|
76
|
+
**Hover tells people things.** What someone lingers on in a shared document, or which field of a form has focus, is a real signal. warp never shares by default: you opt in row by row, and it's worth telling your users.
|
|
77
|
+
|
|
78
|
+
## Details
|
|
79
|
+
|
|
80
|
+
- **Keys** are read once, so a prop goes in as a function: `share: () => `todo:${props.id}``. A plain string is fine when it's fixed. To change key, give the component a new `key`.
|
|
81
|
+
- **Costs.** A 200-row list is one frame to join, and a joiner costs other clients nothing. High-frequency changes send at most one frame per 60 ms, carrying the latest value of every row that changed. `limits.shareKeys` (1000) caps keys per socket.
|
|
82
|
+
- **Touch** has no hover, so `row.hover` stays false on a phone; press still works, and a phone can show someone else's hover.
|
|
83
|
+
- **The server** renders every row as idle, so hydration has nothing to reconcile. Rows are live a moment after the page is.
|
|
84
|
+
- **Components that take `ui`:** Button, Checkbox, Switch, Radio, Toggle, Chip, Card. Remote state never touches `aria-*` or focus: it is decoration.
|
|
85
|
+
|
|
86
|
+
`weft check` flags keys that can't match (U001), keys built from what a user typed (U002), `share` with no rule (U003), writing `remote` (U004) and `ui` on a component that doesn't take it (U005).
|
|
87
|
+
|
|
88
|
+
## One browser, one socket
|
|
89
|
+
|
|
90
|
+
All tabs of a browser share one WebSocket, held by one tab, and `share`, `channel` and `live` all ride it. You don't set this up. To your code and to the server each tab is still its own connection: `exceptSelf`, `left`, presence and rate limits work per tab as they did. `limits.connections` now counts browsers.
|
|
91
|
+
|
|
92
|
+
When the leader tab closes, another takes over within a few milliseconds. Every tab's channels rejoin and its live queries resubscribe, so presence in other tabs may blink once. A browser without Web Locks gives each tab its own socket. A host that can't do WebSockets falls back to `/_weft/rt` over SSE for `live`.
|
package/docs/verify.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Verify your work
|
|
2
|
+
|
|
3
|
+
Two checks before a change is done: see it work in the running app, and get its tests through the gate.
|
|
4
|
+
|
|
5
|
+
## The running app: `weft browser` or `__weft__.agent`
|
|
6
|
+
|
|
7
|
+
Run the app in dev: `weft dev`, or `vite` for an app without `@weft-js/start`. In dev, every page loads
|
|
8
|
+
`@weft-js/core/agent`, as long as `@testing-library/dom` and `@testing-library/user-event` are installed (they are what
|
|
9
|
+
`weft docs testing` adds; without them a page gets only the devtools graph). Drive it whichever way you can reach it:
|
|
10
|
+
|
|
11
|
+
- **Your own browser** (a browser tool that runs JS, any agent): open the app's URL, then
|
|
12
|
+
`await __weft__.agent.run('snapshot')`, `await __weft__.agent.act({ role: 'button', name: 'Add' }, 'click')`, …
|
|
13
|
+
- **The CLI or MCP**: `weft browser <command>` (tools `browser_*` in `weft mcp`). The dev server relays each command to
|
|
14
|
+
the open tab. Nothing open? Open the URL in your browser, or `weft browser launch` (a headless Chrome).
|
|
15
|
+
|
|
16
|
+
Commands (CLI form; the JS and MCP forms take the same params):
|
|
17
|
+
|
|
18
|
+
- `snapshot [--interactive] [--delta]`: roles, names and refs (`- button "Add Banana" [ref=e15]`), plus the reactive
|
|
19
|
+
graph with real names: `Cart.total = 4.5 ← Cart.lines → 1 binding in <Cart> (Cart.tsx:12)`. Big ones go to a file.
|
|
20
|
+
- `act button "Add Banana" click`, `act label Coupon fill SAVE10`, `act @e15 click`: user-event, like `user` in
|
|
21
|
+
@weft-js/core/test. Returns what changed on the page.
|
|
22
|
+
- `assert status - text "Total: $4.50"`; expectations: visible, absent, text, exactText, value, checked, unchecked,
|
|
23
|
+
disabled, enabled, focused, count. Retries briefly for async loads.
|
|
24
|
+
- `graph Cart` and `set Cart.coupon '"SAVE10"'`: read the graph, write a box by name to reach a state fast.
|
|
25
|
+
- `time pause [--reload]`, `time advance 3000`, `time resume`: the page's clock (timers, intervals, rAF, Date).
|
|
26
|
+
`--reload` pauses from page load, so startup timers are faked too.
|
|
27
|
+
- `net offline|online|latency 300|drop|reset`: `navigator.onLine`, the events, `fetch`, open WebSockets and
|
|
28
|
+
EventSources. `drop` is a server restart; check the app reconnects.
|
|
29
|
+
- `record start` … `record code --component App --from ./App --out src/App.test.tsx`: what you did (acts, asserts,
|
|
30
|
+
clicks and typing from any browser tool) as a `@weft-js/core/test` test. It then has to pass the gate.
|
|
31
|
+
- `logs`: console errors and uncaught errors, with the binding they came from.
|
|
32
|
+
|
|
33
|
+
## The test gate: `weft test --gate`
|
|
34
|
+
|
|
35
|
+
Runs on the diff (`--base <ref>`, default the uncommitted change), in scratch copies; your working tree is never
|
|
36
|
+
touched.
|
|
37
|
+
|
|
38
|
+
- **T010, fail-before:** each new or changed test must fail on the code before the change. A test that passes on the
|
|
39
|
+
old code doesn't test the change.
|
|
40
|
+
- **T011, mutants:** each new test must kill at least one mutant of the changed lines (operators, boundaries,
|
|
41
|
+
literals, conditions, removed calls, JSX text). Surviving mutants come back with what to assert. Sampled within
|
|
42
|
+
`test.gate.mutationBudgetSec` (default 60) and `maxMutants` (40); when sampled, T011 is a warning.
|
|
43
|
+
- **T020, no weakening:** deleting, skipping or loosening an existing test (`toBe(3)` → `toBeTruthy()`) needs
|
|
44
|
+
`// weft-allow T020: <reason>` on it.
|
|
45
|
+
- The static T-codes run on the changed tests; T005 (implementation queries) blocks here.
|
|
46
|
+
|
|
47
|
+
Tests added for code that didn't change have nothing to fail on: fail-before is skipped, and the mutants come from
|
|
48
|
+
the source files the tests import. `--refactor` promises no behavior change: then no test may change.
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The weft command: dev, build, check, the agent layer and the MCP server",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"weft": "./bin/weft.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.mjs",
|
|
12
|
+
"./config": "./src/config.mjs"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=26"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@weft-js/create": "0.1.0",
|
|
19
|
+
"axe-core": "4.14.0",
|
|
20
|
+
"typescript": "6.0.3",
|
|
21
|
+
"yaml": "2.9.1",
|
|
22
|
+
"zod": "4.6.7"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@weft-js/core": "^0.1.0"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"weft"
|
|
32
|
+
],
|
|
33
|
+
"files": [
|
|
34
|
+
"bin",
|
|
35
|
+
"src",
|
|
36
|
+
"docs",
|
|
37
|
+
"ts-plugin",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
]
|
|
41
|
+
}
|
package/src/add-rust.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// `weft add rust [name]`: a crate, its server module and one working function, so `weft check` passes on the first run.
|
|
2
|
+
// `native()` by default (one hot function in this process); `--remote` for a sidecar, the same crate behind HTTP. The
|
|
3
|
+
// `weft` crate isn't on crates.io: it comes with @weft-js/start, so the app's crate and its TS can't disagree on a version.
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, join, relative } from 'node:path'
|
|
7
|
+
import { start } from './start.mjs'
|
|
8
|
+
|
|
9
|
+
const refused = message => Object.assign(new Error(message), { code: 'E_ADD' })
|
|
10
|
+
const usage = message => Object.assign(new Error(message), { code: 'E_USAGE' })
|
|
11
|
+
const TOOLCHAIN = '1.99.1'
|
|
12
|
+
|
|
13
|
+
const camel = name => name.replace(/[-_](.)/g, (_, c) => c.toUpperCase())
|
|
14
|
+
const pascal = name => { const c = camel(name); return c[0].toUpperCase() + c.slice(1) }
|
|
15
|
+
const lib = name => name.replaceAll('-', '_')
|
|
16
|
+
|
|
17
|
+
const manifest = (name, crate, remote) => `[package]
|
|
18
|
+
name = "${name}"
|
|
19
|
+
version = "0.0.0"
|
|
20
|
+
edition = "2024"
|
|
21
|
+
publish = false
|
|
22
|
+
${remote ? '' : `
|
|
23
|
+
[lib]
|
|
24
|
+
crate-type = ["cdylib"]
|
|
25
|
+
`}
|
|
26
|
+
[dependencies]
|
|
27
|
+
serde = { version = "1", features = ["derive"] }
|
|
28
|
+
serde_json = "1"
|
|
29
|
+
${remote ? `tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
|
30
|
+
weft = { path = "${crate}" }` : `weft = { path = "${crate}", default-features = false, features = ["native"] }`}
|
|
31
|
+
|
|
32
|
+
[build-dependencies]
|
|
33
|
+
weft = { path = "${crate}", default-features = false, features = ["build"] }
|
|
34
|
+
|
|
35
|
+
# A rebuild in \`weft dev\` should stay quick and not be a hundred times slower than the real thing.
|
|
36
|
+
[profile.dev]
|
|
37
|
+
opt-level = 1
|
|
38
|
+
|
|
39
|
+
[profile.release]
|
|
40
|
+
lto = "fat"
|
|
41
|
+
codegen-units = 1
|
|
42
|
+
|
|
43
|
+
[workspace]
|
|
44
|
+
`
|
|
45
|
+
|
|
46
|
+
const body = (name, remote) => `weft::contract!(); // types and a \`Service\` trait, generated from your Zod: never hand-written
|
|
47
|
+
use contract::*;
|
|
48
|
+
|
|
49
|
+
struct ${pascal(name)};
|
|
50
|
+
|
|
51
|
+
impl Service for ${pascal(name)} {
|
|
52
|
+
async fn digest(&self, _ctx: weft::Ctx, input: DigestInput) -> Result<DigestOutput, weft::Error> {
|
|
53
|
+
let hash = input.text.bytes().fold(0x811c9dc5u32, |h, b| (h ^ b as u32).wrapping_mul(0x01000193));
|
|
54
|
+
Ok(DigestOutput { bytes: input.text.len() as i64, hash: format!("{hash:08x}") })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
${remote ? `
|
|
58
|
+
#[tokio::main]
|
|
59
|
+
async fn main() -> std::io::Result<()> {
|
|
60
|
+
weft::serve(weft::router(contract::functions(${pascal(name)}))).await
|
|
61
|
+
}
|
|
62
|
+
` : `
|
|
63
|
+
weft::native!(contract::functions(${pascal(name)}));
|
|
64
|
+
`}`
|
|
65
|
+
|
|
66
|
+
const fn = (name, binding) => `import { query } from '@weft-js/start/data'
|
|
67
|
+
import { z } from 'zod'
|
|
68
|
+
import { ${binding} } from './rust'
|
|
69
|
+
|
|
70
|
+
// \`crates/${name}\` is its body. Change the schema and \`weft check\` stops at the Rust it broke.
|
|
71
|
+
export const digest = query({
|
|
72
|
+
input: z.object({ text: z.string().max(1_000_000) }),
|
|
73
|
+
output: z.object({ bytes: z.int(), hash: z.string() }),
|
|
74
|
+
impl: ${binding},
|
|
75
|
+
})
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
/** The path Cargo gets. `crate/` is in a packed @weft-js/start; a linked one is in this repository, next to the crates. */
|
|
79
|
+
async function weftCrate(root, dir) {
|
|
80
|
+
const { startDir } = await start(root, 'vite')
|
|
81
|
+
const found = [join(startDir, '..', 'crate'), join(startDir, '..', '..', '..', 'crates', 'weft')].find(d => existsSync(join(d, 'Cargo.toml')))
|
|
82
|
+
if (!found) throw refused('this @weft-js/start has no `weft` crate in it. Nothing was changed.')
|
|
83
|
+
return relative(dir, found).replaceAll('\\', '/')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function addRust(root, name = 'media', { remote = false, dryRun = false, install, installSpecs }) {
|
|
87
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw usage(`\`${name}\` isn't a service name: lower case, then letters, digits, \`-\` or \`_\``)
|
|
88
|
+
if (!existsSync(join(root, 'package.json'))) throw refused('`weft add rust` needs a package.json. Nothing was changed.')
|
|
89
|
+
const dir = join(root, 'crates', name)
|
|
90
|
+
const module = join(root, 'src', 'server', `${name}.ts`)
|
|
91
|
+
const taken = [dir, module].filter(existsSync).map(f => relative(root, f))
|
|
92
|
+
if (taken.length) throw refused(`${taken.join(', ')} already exist${taken.length > 1 ? '' : 's'}. Pick another name: \`weft add rust <name>\`. Nothing was changed.`)
|
|
93
|
+
const binding = camel(name)
|
|
94
|
+
const factory = remote ? 'remote' : 'native'
|
|
95
|
+
const call = remote ? `remote('rust://${name}')` : `native('${name}')`
|
|
96
|
+
const rustFile = join(root, 'src', 'server', 'rust.ts')
|
|
97
|
+
const had = existsSync(rustFile) ? readFileSync(rustFile, 'utf8') : '// The only place Rust targets are named.\n'
|
|
98
|
+
if (new RegExp(`\\b${binding}\\b`).test(had)) throw refused(`src/server/rust.ts already names \`${binding}\`. Nothing was changed.`)
|
|
99
|
+
const imported = new RegExp(`import\\s*\\{[^}]*\\b${factory}\\b[^}]*\\}\\s*from\\s*['"]@weft-js/start/rust['"]`).test(had)
|
|
100
|
+
const nextRust = `${imported ? had : had.replace(/^((?:\/\/.*\n)*)/, `$1import { ${factory} } from '@weft-js/start/rust'\n`)}${had.endsWith('\n') ? '' : '\n'}\nexport const ${binding} = ${call}\n`
|
|
101
|
+
|
|
102
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
103
|
+
const needsZod = !pkg.dependencies?.zod && !pkg.devDependencies?.zod
|
|
104
|
+
const gitignore = join(root, '.gitignore')
|
|
105
|
+
const ignored = existsSync(gitignore) && /^\/?target\/?$|^\*\*\/target\/?$/m.test(readFileSync(gitignore, 'utf8'))
|
|
106
|
+
const actions = [`create crates/${name}`, `create src/server/${name}.ts`, `add \`${binding}\` to src/server/rust.ts`, ...(needsZod && install ? ['install zod'] : []), ...(ignored ? [] : ['ignore target/']), 'this crate needs Rust (`cargo`, from rustup.rs); the rest of weft does not', 'next: `weft check`, then `weft dev`']
|
|
107
|
+
if (dryRun) return { ok: true, feature: 'rust', service: name, kind: factory, actions, dryRun: true }
|
|
108
|
+
|
|
109
|
+
const crate = await weftCrate(root, dir)
|
|
110
|
+
const write = (file, text) => {
|
|
111
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
112
|
+
writeFileSync(file, text)
|
|
113
|
+
}
|
|
114
|
+
write(join(dir, 'Cargo.toml'), manifest(name, crate, remote))
|
|
115
|
+
write(join(dir, 'build.rs'), `fn main() {\n weft::build::generate("${name}");\n}\n`)
|
|
116
|
+
write(join(dir, 'rust-toolchain.toml'), `[toolchain]\nchannel = "${TOOLCHAIN}"\n${remote ? '' : 'targets = ["wasm32-unknown-unknown"]\n'}`)
|
|
117
|
+
write(join(dir, 'src', remote ? 'main.rs' : 'lib.rs'), body(lib(name), remote))
|
|
118
|
+
write(module, fn(name, binding))
|
|
119
|
+
write(rustFile, nextRust)
|
|
120
|
+
if (!ignored) writeFileSync(gitignore, `${existsSync(gitignore) ? readFileSync(gitignore, 'utf8').replace(/\n?$/, '\n') : ''}target/\n`)
|
|
121
|
+
if (needsZod && install) installSpecs(['zod'])
|
|
122
|
+
return { ok: true, feature: 'rust', service: name, kind: factory, actions }
|
|
123
|
+
}
|