@weft-js/cli 0.1.1 → 0.1.2
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 -14
- package/bin/weft.mjs +8 -1
- package/docs/async.md +16 -6
- package/docs/browser.md +22 -8
- package/docs/config.md +39 -9
- package/docs/core.md +30 -0
- package/docs/deploy.md +33 -8
- package/docs/design.md +12 -3
- package/docs/entrypoints.md +79 -0
- package/docs/fonts.md +12 -2
- package/docs/icons.md +13 -7
- package/docs/lists.md +3 -1
- package/docs/meta.md +13 -3
- package/docs/plugins.md +32 -9
- package/docs/props.md +13 -2
- package/docs/reactivity.md +26 -0
- package/docs/rules.md +19 -12
- package/docs/rust.md +47 -17
- package/docs/start-short.md +3 -2
- package/docs/start.md +226 -52
- package/docs/state.md +33 -7
- package/docs/style.md +31 -8
- package/docs/surface.md +61 -15
- package/docs/testing.md +15 -5
- package/docs/theme.md +182 -0
- package/docs/ui-state.md +19 -6
- package/docs/verify.md +13 -0
- package/package.json +13 -4
- package/src/add/config.mjs +93 -0
- package/src/add/errors.mjs +2 -0
- package/src/add/setup.mjs +88 -0
- package/src/add-rust.mjs +82 -27
- package/src/add.mjs +209 -181
- package/src/agents-md.mjs +144 -43
- package/src/alias.mjs +16 -5
- package/src/audit/page.mjs +313 -0
- package/src/audit/report.mjs +69 -0
- package/src/audit/utils.mjs +5 -0
- package/src/audit.mjs +159 -206
- package/src/browser/cli.mjs +24 -5
- package/src/browser/client.mjs +189 -25
- package/src/browser/commands.mjs +166 -51
- package/src/catalog/audit.mjs +129 -0
- package/src/catalog/auth.mjs +40 -0
- package/src/catalog/compiler.mjs +337 -0
- package/src/catalog/contracts.mjs +18 -0
- package/src/catalog/database.mjs +52 -0
- package/src/catalog/deploy.mjs +117 -0
- package/src/catalog/design.mjs +159 -0
- package/src/catalog/forms.mjs +25 -0
- package/src/catalog/hydration.mjs +29 -0
- package/src/catalog/icons.mjs +24 -0
- package/src/catalog/media.mjs +163 -0
- package/src/catalog/metadata.mjs +116 -0
- package/src/catalog/plugins.mjs +228 -0
- package/src/catalog/realtime.mjs +195 -0
- package/src/catalog/rust.mjs +75 -0
- package/src/catalog/server-functions.mjs +92 -0
- package/src/catalog/state.mjs +39 -0
- package/src/catalog/style-groups.mjs +54 -0
- package/src/catalog/tests.mjs +127 -0
- package/src/catalog/text.mjs +200 -0
- package/src/catalog/ui-state.mjs +42 -0
- package/src/catalog.mjs +67 -460
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +198 -208
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +222 -250
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +94 -16
- package/src/deploy/files.mjs +100 -21
- package/src/deploy/fit.mjs +127 -23
- package/src/deploy/hosts.mjs +90 -20
- package/src/deploy/index/cli.mjs +20 -0
- package/src/deploy/index/environment.mjs +69 -0
- package/src/deploy/index/steps.mjs +87 -0
- package/src/deploy/index.mjs +228 -114
- package/src/deploy/needs.mjs +88 -31
- package/src/design-checks/accessible-names.mjs +62 -0
- package/src/design-checks/contrast.mjs +43 -0
- package/src/design-checks/csp.mjs +60 -0
- package/src/design-checks/files.mjs +59 -0
- package/src/design-checks/heading-links.mjs +61 -0
- package/src/design-checks/interaction.mjs +65 -0
- package/src/design-checks/jsx.mjs +67 -0
- package/src/design-checks/layout-values.mjs +68 -0
- package/src/design-checks/native-controls.mjs +48 -0
- package/src/design-checks/outline.mjs +39 -0
- package/src/design-checks/reporter.mjs +37 -0
- package/src/design-checks/rules.mjs +274 -0
- package/src/design-checks/values.mjs +262 -0
- package/src/design-checks.mjs +10 -400
- package/src/diag.mjs +32 -8
- package/src/docs.mjs +138 -43
- package/src/doctor.mjs +20 -8
- package/src/files.mjs +18 -5
- package/src/fix.mjs +10 -2
- package/src/gate/after.mjs +40 -0
- package/src/gate/before.mjs +54 -0
- package/src/gate/classify.mjs +72 -0
- package/src/gate/mutation.mjs +209 -0
- package/src/gate/resolve-cases.mjs +22 -0
- package/src/gate/result.mjs +63 -0
- package/src/gate/run.mjs +75 -0
- package/src/gate.mjs +104 -202
- package/src/infer/box-props.cjs +140 -0
- package/src/infer/core-boxes.cjs +46 -0
- package/src/infer/derived-writes.cjs +95 -0
- package/src/infer/engine/checks.cjs +193 -0
- package/src/infer/engine/flow.cjs +293 -0
- package/src/infer/engine/report.cjs +158 -0
- package/src/infer/engine/utils.cjs +15 -0
- package/src/infer/engine.cjs +108 -368
- package/src/infer/facts/sites.cjs +114 -0
- package/src/infer/facts.cjs +210 -117
- package/src/infer/hosts.cjs +69 -22
- package/src/infer/lsp.mjs +149 -40
- package/src/infer/session.cjs +73 -17
- package/src/infer/shadow.cjs +13 -5
- package/src/infer/worker.cjs +57 -9
- package/src/jsx-scan.mjs +63 -21
- package/src/load-config/schema.mjs +281 -0
- package/src/load-config.mjs +137 -138
- package/src/map.mjs +93 -26
- package/src/mcp/browser-tools.mjs +155 -0
- package/src/mcp.mjs +147 -69
- package/src/meta-table.mjs +43 -8
- package/src/mutate.mjs +120 -36
- package/src/plugin-checks.mjs +52 -23
- package/src/plugins.mjs +125 -39
- package/src/project.mjs +170 -79
- package/src/props-checks.mjs +24 -6
- package/src/props-listener.mjs +27 -9
- package/src/react-checks.mjs +35 -4
- package/src/server-state-checks.mjs +34 -0
- package/src/share-checks.mjs +121 -37
- package/src/site.mjs +33 -6
- package/src/slots.mjs +104 -19
- package/src/start/build.mjs +116 -0
- package/src/start/routes.mjs +38 -0
- package/src/start.mjs +193 -95
- package/src/state-checks/core-state.mjs +223 -0
- package/src/state-checks/modules.mjs +17 -0
- package/src/state-checks/scan.mjs +232 -0
- package/src/state-checks/server-graph.mjs +65 -0
- package/src/state-checks.mjs +109 -133
- package/src/style/find.mjs +121 -0
- package/src/style.mjs +133 -127
- package/src/surface-checks.mjs +212 -46
- package/src/tailwind.mjs +5 -4
- package/src/tasks.mjs +189 -27
- package/src/test-rules/context.mjs +109 -0
- package/src/test-rules/file-rules.mjs +279 -0
- package/src/test-rules/rule-helpers.mjs +244 -0
- package/src/test-rules/syntax.mjs +223 -0
- package/src/test-rules/test-rules.mjs +127 -0
- package/src/testdiff.mjs +94 -32
- package/src/tests.mjs +31 -483
- package/src/text-checks.mjs +229 -69
- package/src/tokens.mjs +12 -4
- package/src/trust/scan.mjs +95 -0
- package/src/trust.mjs +214 -102
- package/src/weft.mjs +41 -9
- package/src/workspace.mjs +96 -19
- package/ts-plugin/context.js +57 -0
- package/ts-plugin/index.js +4 -124
- package/ts-plugin/mapping.js +65 -0
- package/ts-plugin/proxy.js +144 -0
package/src/browser/client.mjs
CHANGED
|
@@ -13,7 +13,9 @@ export async function devUrl(root, given) {
|
|
|
13
13
|
if (given) return given.replace(/\/$/, '')
|
|
14
14
|
const file = join(root, '.weft', 'dev.json')
|
|
15
15
|
if (existsSync(file)) {
|
|
16
|
-
try {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(file, 'utf8')).url.replace(/\/$/, '')
|
|
18
|
+
} catch {}
|
|
17
19
|
}
|
|
18
20
|
const { config } = await loadConfig(root).catch(() => ({ config: {} }))
|
|
19
21
|
return (config.environments?.dev?.url ?? 'http://localhost:5173').replace(/\/$/, '')
|
|
@@ -23,8 +25,15 @@ const fail = (code, message, extra) => Object.assign(new Error(message), { code,
|
|
|
23
25
|
|
|
24
26
|
async function request(url, init) {
|
|
25
27
|
try {
|
|
26
|
-
const res = await fetch(`${url}/__weft/agent`, {
|
|
27
|
-
|
|
28
|
+
const res = await fetch(`${url}/__weft/agent`, {
|
|
29
|
+
...init,
|
|
30
|
+
headers: { 'x-weft-agent': '1', 'content-type': 'application/json' },
|
|
31
|
+
})
|
|
32
|
+
if (res.status === 404)
|
|
33
|
+
throw fail(
|
|
34
|
+
'E_NO_BRIDGE',
|
|
35
|
+
`${url} is not a weft dev server (no /__weft/agent): serve the app with the weft() Vite plugin`,
|
|
36
|
+
)
|
|
28
37
|
return await res.json()
|
|
29
38
|
} catch (e) {
|
|
30
39
|
if (e.code) throw e
|
|
@@ -35,7 +44,10 @@ async function request(url, init) {
|
|
|
35
44
|
/** Run one command in a tab (the focused one, else the most recent). */
|
|
36
45
|
export async function call(root, cmd, args = {}, { url, tab, timeout } = {}) {
|
|
37
46
|
const base = await devUrl(root, url)
|
|
38
|
-
const out = await request(base, {
|
|
47
|
+
const out = await request(base, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
body: JSON.stringify({ cmd, args, tab, timeout }),
|
|
50
|
+
})
|
|
39
51
|
if (!out.ok) throw fail(out.code ?? 'E_PAGE', out.error, { tabs: out.tabs })
|
|
40
52
|
return out.result
|
|
41
53
|
}
|
|
@@ -47,55 +59,195 @@ export async function tabs(root, { url } = {}) {
|
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
/** Wait until a tab is connected (after `launch` or `navigate`). */
|
|
50
|
-
export async function waitForTab(root, { url, ms = 15_000 } = {}) {
|
|
62
|
+
export async function waitForTab(root, { url, ms = 15_000, check } = {}) {
|
|
51
63
|
const until = Date.now() + ms
|
|
52
64
|
for (;;) {
|
|
65
|
+
check?.()
|
|
66
|
+
let connected
|
|
53
67
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
68
|
+
connected = await tabs(root, { url })
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (Date.now() > until) throw e
|
|
71
|
+
}
|
|
72
|
+
check?.()
|
|
73
|
+
if (connected?.tabs.length) return connected
|
|
57
74
|
if (Date.now() > until) throw fail('E_NO_TAB', 'no tab connected in time')
|
|
58
|
-
await new Promise(r => {
|
|
75
|
+
await new Promise((r) => {
|
|
76
|
+
setTimeout(r, 150)
|
|
77
|
+
})
|
|
59
78
|
}
|
|
60
79
|
}
|
|
61
80
|
|
|
62
81
|
// ---- a host page when the agent has no browser ------------------------------------------------------------------
|
|
63
82
|
|
|
64
83
|
const chromes = {
|
|
65
|
-
darwin: [
|
|
66
|
-
|
|
67
|
-
|
|
84
|
+
darwin: [
|
|
85
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
86
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
87
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
88
|
+
],
|
|
89
|
+
linux: [
|
|
90
|
+
'/usr/bin/google-chrome',
|
|
91
|
+
'/usr/bin/google-chrome-stable',
|
|
92
|
+
'/usr/bin/chromium',
|
|
93
|
+
'/usr/bin/chromium-browser',
|
|
94
|
+
'/snap/bin/chromium',
|
|
95
|
+
],
|
|
96
|
+
win32: [
|
|
97
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
98
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
99
|
+
],
|
|
68
100
|
}
|
|
69
101
|
|
|
70
102
|
export function findChrome() {
|
|
71
103
|
const env = process.env.WEFT_CHROME ?? process.env.CHROME_PATH
|
|
72
104
|
if (env && existsSync(env)) return env
|
|
73
|
-
return (chromes[process.platform] ?? []).find(p => existsSync(p)) ?? null
|
|
105
|
+
return (chromes[process.platform] ?? []).find((p) => existsSync(p)) ?? null
|
|
74
106
|
}
|
|
75
107
|
|
|
76
|
-
const stateFile = root => join(root, '.weft', 'browser', 'chrome.json')
|
|
108
|
+
const stateFile = (root) => join(root, '.weft', 'browser', 'chrome.json')
|
|
109
|
+
|
|
110
|
+
// Report only URL structure, never credentials, query parameters or fragments.
|
|
111
|
+
function safeUrl(value) {
|
|
112
|
+
try {
|
|
113
|
+
const url = new URL(value)
|
|
114
|
+
url.username = ''
|
|
115
|
+
url.password = ''
|
|
116
|
+
url.search = ''
|
|
117
|
+
url.hash = ''
|
|
118
|
+
return url.href.slice(0, 500)
|
|
119
|
+
} catch {
|
|
120
|
+
return '[invalid URL]'
|
|
121
|
+
}
|
|
122
|
+
}
|
|
77
123
|
|
|
78
|
-
|
|
124
|
+
async function devtoolsJson(port, path, signal) {
|
|
125
|
+
const response = await fetch(`http://127.0.0.1:${port}${path}`, { signal })
|
|
126
|
+
if (!response.ok || !response.body) throw new Error('DevTools endpoint unavailable')
|
|
127
|
+
const reader = response.body.getReader()
|
|
128
|
+
const chunks = []
|
|
129
|
+
let size = 0
|
|
130
|
+
try {
|
|
131
|
+
for (;;) {
|
|
132
|
+
const { value, done } = await reader.read()
|
|
133
|
+
if (done) break
|
|
134
|
+
size += value.length
|
|
135
|
+
if (size > 32_768) throw new Error('DevTools response too large')
|
|
136
|
+
chunks.push(value)
|
|
137
|
+
}
|
|
138
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
139
|
+
} finally {
|
|
140
|
+
await reader.cancel().catch(() => {})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Append bounded, failure-only DevTools evidence without replacing the launch error. */
|
|
145
|
+
export async function diagnoseLaunch(error, profile, expected) {
|
|
146
|
+
try {
|
|
147
|
+
const port = readFileSync(join(profile, 'DevToolsActivePort'), 'utf8').split('\n')[0]
|
|
148
|
+
if (!/^\d{1,5}$/.test(port) || +port < 1 || +port > 65535) throw new Error('invalid port')
|
|
149
|
+
const signal = AbortSignal.timeout(750)
|
|
150
|
+
const [version, targets] = await Promise.all([
|
|
151
|
+
devtoolsJson(port, '/json/version', signal).catch(() => null),
|
|
152
|
+
devtoolsJson(port, '/json/list', signal).catch(() => null),
|
|
153
|
+
])
|
|
154
|
+
const browser =
|
|
155
|
+
typeof version?.Browser === 'string'
|
|
156
|
+
? (version.Browser.match(/^(?:Chrome|Chromium|HeadlessChrome|Edg)\/[0-9.]+/)?.[0] ??
|
|
157
|
+
'unavailable')
|
|
158
|
+
: 'unavailable'
|
|
159
|
+
const pages = Array.isArray(targets)
|
|
160
|
+
? targets
|
|
161
|
+
.filter((target) => target?.type === 'page')
|
|
162
|
+
.slice(0, 10)
|
|
163
|
+
.map((target) => ({
|
|
164
|
+
url: safeUrl(target.url),
|
|
165
|
+
state:
|
|
166
|
+
typeof target.attached === 'boolean'
|
|
167
|
+
? target.attached
|
|
168
|
+
? 'attached'
|
|
169
|
+
: 'not attached'
|
|
170
|
+
: target.webSocketDebuggerUrl
|
|
171
|
+
? 'debuggable'
|
|
172
|
+
: 'no debugger',
|
|
173
|
+
}))
|
|
174
|
+
: 'unavailable'
|
|
175
|
+
error.message += `; DevTools: ${JSON.stringify({ browser, expected: safeUrl(expected), pages })}`
|
|
176
|
+
} catch {
|
|
177
|
+
error.message += `; DevTools unavailable; expected: ${safeUrl(expected)}`
|
|
178
|
+
}
|
|
179
|
+
return error
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Start a headless Chrome on `route` (a plain page; @weft-js/core/agent connects back to the dev server).
|
|
184
|
+
* @param {string} root
|
|
185
|
+
* @param {string} [route]
|
|
186
|
+
* @param {{ url?: string | undefined, headed?: boolean | undefined }} [options]
|
|
187
|
+
*/
|
|
79
188
|
export async function launch(root, route = '/', { url, headed = false } = {}) {
|
|
80
189
|
const exe = findChrome()
|
|
81
|
-
if (!exe)
|
|
190
|
+
if (!exe)
|
|
191
|
+
throw fail(
|
|
192
|
+
'E_NO_CHROME',
|
|
193
|
+
'no Chrome or Chromium found; open the app in your own browser instead, or set WEFT_CHROME',
|
|
194
|
+
)
|
|
82
195
|
close(root)
|
|
83
196
|
const base = await devUrl(root, url)
|
|
84
197
|
const profile = join(root, '.weft', 'browser', 'profile')
|
|
85
198
|
mkdirSync(profile, { recursive: true })
|
|
86
|
-
const args = [
|
|
87
|
-
|
|
199
|
+
const args = [
|
|
200
|
+
`--user-data-dir=${profile}`,
|
|
201
|
+
'--remote-debugging-port=0',
|
|
202
|
+
'--no-first-run',
|
|
203
|
+
'--no-default-browser-check',
|
|
204
|
+
'--disable-background-timer-throttling',
|
|
205
|
+
'--disable-renderer-backgrounding',
|
|
206
|
+
...(headed ? [] : ['--headless=new']),
|
|
207
|
+
...(process.getuid?.() === 0
|
|
208
|
+
? ['--no-sandbox']
|
|
209
|
+
: []) /* Chrome won't start as root with its sandbox: a container, CI */,
|
|
210
|
+
`${base}${route.startsWith('/') ? route : `/${route}`}`,
|
|
211
|
+
]
|
|
212
|
+
const child = spawn(exe, args, { detached: true, stdio: ['ignore', 'ignore', 'pipe'] })
|
|
213
|
+
let stderr = '',
|
|
214
|
+
failure = null
|
|
215
|
+
child.stderr.on('data', (chunk) => {
|
|
216
|
+
stderr = (stderr + chunk.toString()).slice(-4096)
|
|
217
|
+
})
|
|
218
|
+
child.on('error', (error) => {
|
|
219
|
+
failure = `spawn: ${error.message}`
|
|
220
|
+
})
|
|
221
|
+
child.on('exit', (code, signal) => {
|
|
222
|
+
failure = `exit: ${code}, signal: ${signal}`
|
|
223
|
+
})
|
|
88
224
|
child.unref()
|
|
225
|
+
child.stderr.unref?.()
|
|
89
226
|
writeFileSync(stateFile(root), JSON.stringify({ pid: child.pid, profile, exe }))
|
|
90
|
-
|
|
91
|
-
|
|
227
|
+
try {
|
|
228
|
+
const t = await waitForTab(root, {
|
|
229
|
+
url,
|
|
230
|
+
check: () => {
|
|
231
|
+
if (failure) throw fail('E_CHROME', `Chrome ${failure}; stderr: ${stderr}`)
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
return { launched: true, pid: child.pid, headless: !headed, ...t }
|
|
235
|
+
} catch (error) {
|
|
236
|
+
await diagnoseLaunch(error, profile, args.at(-1))
|
|
237
|
+
close(root)
|
|
238
|
+
child.stderr.destroy()
|
|
239
|
+
error.message += ` (Chrome ${failure ?? 'still running'}; stderr: ${stderr})`
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
92
242
|
}
|
|
93
243
|
|
|
94
244
|
export function close(root) {
|
|
95
245
|
const file = stateFile(root)
|
|
96
246
|
if (!existsSync(file)) return { closed: false }
|
|
97
247
|
const { pid } = JSON.parse(readFileSync(file, 'utf8'))
|
|
98
|
-
try {
|
|
248
|
+
try {
|
|
249
|
+
process.kill(pid)
|
|
250
|
+
} catch {}
|
|
99
251
|
rmSync(file, { force: true })
|
|
100
252
|
return { closed: true, pid }
|
|
101
253
|
}
|
|
@@ -103,16 +255,28 @@ export function close(root) {
|
|
|
103
255
|
/** A PNG of the launched Chrome's page (DevTools protocol). An agent's own browser takes its own screenshots. */
|
|
104
256
|
export async function screenshot(root, out) {
|
|
105
257
|
const file = stateFile(root)
|
|
106
|
-
if (!existsSync(file))
|
|
258
|
+
if (!existsSync(file))
|
|
259
|
+
throw fail(
|
|
260
|
+
'E_NO_CHROME',
|
|
261
|
+
'screenshot needs `weft browser launch`; in your own browser, use its screenshot tool',
|
|
262
|
+
)
|
|
107
263
|
const { profile } = JSON.parse(readFileSync(file, 'utf8'))
|
|
108
264
|
const [port] = readFileSync(join(profile, 'DevToolsActivePort'), 'utf8').split('\n')
|
|
109
265
|
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
|
|
110
|
-
const page = targets.find(t => t.type === 'page')
|
|
266
|
+
const page = targets.find((t) => t.type === 'page')
|
|
111
267
|
if (!page) throw fail('E_NO_TAB', 'the launched Chrome has no page')
|
|
112
268
|
const ws = new WebSocket(page.webSocketDebuggerUrl)
|
|
113
|
-
await new Promise((ok, no) => {
|
|
269
|
+
await new Promise((ok, no) => {
|
|
270
|
+
ws.onopen = ok
|
|
271
|
+
ws.onerror = () => no(fail('E_CDP', 'could not reach the page over the DevTools protocol'))
|
|
272
|
+
})
|
|
114
273
|
const data = await new Promise((ok, no) => {
|
|
115
|
-
ws.onmessage = e => {
|
|
274
|
+
ws.onmessage = (e) => {
|
|
275
|
+
const m = JSON.parse(e.data)
|
|
276
|
+
if (m.id !== 1) return
|
|
277
|
+
if (m.error) no(fail('E_CDP', m.error.message))
|
|
278
|
+
else ok(m.result.data)
|
|
279
|
+
}
|
|
116
280
|
ws.send(JSON.stringify({ id: 1, method: 'Page.captureScreenshot', params: { format: 'png' } }))
|
|
117
281
|
})
|
|
118
282
|
ws.close()
|
package/src/browser/commands.mjs
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
1
|
// `weft browser` commands, shared by the CLI and the `weft mcp` browser tools. Each takes structured params and
|
|
2
|
-
// returns JSON; the page side is @weft-js/core/agent (packages/
|
|
2
|
+
// returns JSON; the page side is @weft-js/core/agent (packages/core/src/agent.ts).
|
|
3
3
|
|
|
4
4
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
5
5
|
import { dirname, isAbsolute, join } from 'node:path'
|
|
6
6
|
import { call, close, launch, screenshot, spill, tabs, waitForTab } from './client.mjs'
|
|
7
7
|
|
|
8
|
-
export const actions = [
|
|
9
|
-
|
|
8
|
+
export const actions = [
|
|
9
|
+
'click',
|
|
10
|
+
'dblclick',
|
|
11
|
+
'hover',
|
|
12
|
+
'focus',
|
|
13
|
+
'clear',
|
|
14
|
+
'fill',
|
|
15
|
+
'type',
|
|
16
|
+
'press',
|
|
17
|
+
'select',
|
|
18
|
+
'check',
|
|
19
|
+
'uncheck',
|
|
20
|
+
]
|
|
21
|
+
export const expectations = [
|
|
22
|
+
'visible',
|
|
23
|
+
'absent',
|
|
24
|
+
'text',
|
|
25
|
+
'exactText',
|
|
26
|
+
'value',
|
|
27
|
+
'checked',
|
|
28
|
+
'unchecked',
|
|
29
|
+
'disabled',
|
|
30
|
+
'enabled',
|
|
31
|
+
'focused',
|
|
32
|
+
'count',
|
|
33
|
+
]
|
|
10
34
|
|
|
11
|
-
const sleep =
|
|
35
|
+
const sleep = (ms) =>
|
|
36
|
+
new Promise((r) => {
|
|
37
|
+
setTimeout(r, ms)
|
|
38
|
+
})
|
|
12
39
|
|
|
13
40
|
/**
|
|
14
41
|
* @param {string} root project root
|
|
@@ -19,42 +46,54 @@ const sleep = ms => new Promise(r => { setTimeout(r, ms) })
|
|
|
19
46
|
export async function browser(root, cmd, p = {}, o = {}) {
|
|
20
47
|
const page = (name, args, extra) => call(root, name, args, { ...o, ...extra })
|
|
21
48
|
switch (cmd) {
|
|
22
|
-
case 'tabs':
|
|
23
|
-
|
|
24
|
-
case '
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
do {
|
|
31
|
-
await sleep(150)
|
|
32
|
-
try {
|
|
33
|
-
const info = await page('info', {}, { timeout: 2000 })
|
|
34
|
-
if (info.url !== before.url || p.route === new URL(before.url).pathname) return info
|
|
35
|
-
} catch {}
|
|
36
|
-
} while (Date.now() <= until)
|
|
37
|
-
return waitForTab(root, o)
|
|
38
|
-
}
|
|
49
|
+
case 'tabs':
|
|
50
|
+
return tabs(root, o)
|
|
51
|
+
case 'launch':
|
|
52
|
+
return launch(root, p.route ?? '/', { url: o.url, headed: p.headed })
|
|
53
|
+
case 'close':
|
|
54
|
+
return close(root)
|
|
55
|
+
case 'open':
|
|
56
|
+
return await openPage(root, p, o, page)
|
|
39
57
|
case 'snapshot': {
|
|
40
|
-
const out = await page('snapshot', {
|
|
58
|
+
const out = await page('snapshot', {
|
|
59
|
+
interactive: p.interactive,
|
|
60
|
+
scope: p.scope,
|
|
61
|
+
delta: p.delta,
|
|
62
|
+
graph: p.graph,
|
|
63
|
+
})
|
|
41
64
|
if (!p.full) {
|
|
42
65
|
if (out.tree) out.tree = spill(root, 'snapshot', out.tree)
|
|
43
66
|
if (out.graph) out.graph = spill(root, 'graph', out.graph)
|
|
44
67
|
}
|
|
45
68
|
return out
|
|
46
69
|
}
|
|
47
|
-
case 'act':
|
|
48
|
-
|
|
70
|
+
case 'act':
|
|
71
|
+
return page('act', { target: p.target, action: p.action, value: p.value })
|
|
72
|
+
case 'assert':
|
|
73
|
+
return page('assert', { target: p.target, expect: p.expect, timeout: p.timeout })
|
|
49
74
|
case 'graph': {
|
|
50
75
|
const out = await page('graph', { filter: p.filter })
|
|
51
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
graph: p.full ? out.graph : spill(root, 'graph', out.graph),
|
|
78
|
+
nodes: out.nodes.length,
|
|
79
|
+
}
|
|
52
80
|
}
|
|
53
|
-
case 'set':
|
|
54
|
-
|
|
55
|
-
case '
|
|
81
|
+
case 'set':
|
|
82
|
+
return page('set', { name: p.name, value: p.value })
|
|
83
|
+
case 'time':
|
|
84
|
+
return page('time', { op: p.op, ms: p.ms, reload: p.reload })
|
|
85
|
+
case 'net':
|
|
86
|
+
return page('net', { op: p.op, ms: p.ms })
|
|
56
87
|
case 'record': {
|
|
57
|
-
const out = await page('record', {
|
|
88
|
+
const out = await page('record', {
|
|
89
|
+
op: p.op,
|
|
90
|
+
text: p.text,
|
|
91
|
+
component: p.component,
|
|
92
|
+
from: p.from,
|
|
93
|
+
props: p.props,
|
|
94
|
+
imports: p.imports,
|
|
95
|
+
name: p.name,
|
|
96
|
+
})
|
|
58
97
|
if (p.op === 'code' && p.out) {
|
|
59
98
|
const file = isAbsolute(p.out) ? p.out : join(root, p.out)
|
|
60
99
|
mkdirSync(dirname(file), { recursive: true })
|
|
@@ -63,61 +102,137 @@ export async function browser(root, cmd, p = {}, o = {}) {
|
|
|
63
102
|
}
|
|
64
103
|
return out
|
|
65
104
|
}
|
|
66
|
-
case 'logs':
|
|
67
|
-
|
|
68
|
-
case '
|
|
69
|
-
|
|
70
|
-
case '
|
|
105
|
+
case 'logs':
|
|
106
|
+
return page('logs', {})
|
|
107
|
+
case 'overlay':
|
|
108
|
+
return page('overlay', { show: p.show })
|
|
109
|
+
case 'info':
|
|
110
|
+
return page('info', {})
|
|
111
|
+
case 'reload':
|
|
112
|
+
return page('reload', {})
|
|
113
|
+
case 'screenshot':
|
|
114
|
+
return screenshot(root, p.out && (isAbsolute(p.out) ? p.out : join(root, p.out)))
|
|
71
115
|
default:
|
|
72
116
|
throw Object.assign(new Error(`unknown browser command ${cmd}`), { code: 'E_USAGE' })
|
|
73
117
|
}
|
|
74
118
|
}
|
|
75
119
|
|
|
120
|
+
async function openPage(root, p, o, page) {
|
|
121
|
+
const before = await page('info', {})
|
|
122
|
+
await page('navigate', { to: p.route ?? '/' })
|
|
123
|
+
// Same tab id after the load (@weft-js/core/agent keeps it in sessionStorage); wait for the new document to say hello.
|
|
124
|
+
const until = Date.now() + 15_000
|
|
125
|
+
do {
|
|
126
|
+
await sleep(150)
|
|
127
|
+
try {
|
|
128
|
+
const info = await page('info', {}, { timeout: 2000 })
|
|
129
|
+
if (info.url !== before.url || p.route === new URL(before.url).pathname) return info
|
|
130
|
+
} catch {}
|
|
131
|
+
} while (Date.now() <= until)
|
|
132
|
+
return waitForTab(root, o)
|
|
133
|
+
}
|
|
134
|
+
|
|
76
135
|
/** `button "Add Banana"`, `label Coupon`, `text Cart`, `@e5` → a target, and how many words it took. */
|
|
77
136
|
export function parseTarget(words) {
|
|
78
137
|
const [a, b] = words
|
|
79
|
-
if (!a)
|
|
138
|
+
if (!a)
|
|
139
|
+
throw Object.assign(
|
|
140
|
+
new Error('missing target: <role> <name>, label <text>, text <text>, or @ref'),
|
|
141
|
+
{ code: 'E_USAGE' },
|
|
142
|
+
)
|
|
80
143
|
if (a.startsWith('@')) return [a, 1]
|
|
81
144
|
if (a === 'label') return [{ label: b }, 2]
|
|
82
145
|
if (a === 'text') return [{ text: b }, 2]
|
|
83
|
-
return [
|
|
146
|
+
return [
|
|
147
|
+
b === undefined || b === '-' ? { role: a } : { role: a, name: b },
|
|
148
|
+
b === undefined ? 1 : 2,
|
|
149
|
+
]
|
|
84
150
|
}
|
|
85
151
|
|
|
86
152
|
/** CLI words → params. */
|
|
87
153
|
export function cliParams(cmd, words, flags) {
|
|
88
|
-
const num = v => (v === undefined ? undefined : Number(v))
|
|
154
|
+
const num = (v) => (v === undefined ? undefined : Number(v))
|
|
89
155
|
switch (cmd) {
|
|
90
|
-
case 'launch':
|
|
91
|
-
case '
|
|
156
|
+
case 'launch':
|
|
157
|
+
case 'open':
|
|
158
|
+
return { route: words[0] ?? '/', headed: !!flags.headed }
|
|
159
|
+
case 'snapshot':
|
|
160
|
+
return {
|
|
161
|
+
interactive: !!flags.interactive,
|
|
162
|
+
delta: !!flags.delta,
|
|
163
|
+
graph: flags['graph-only'] ? 'only' : flags['no-graph'] ? false : (flags.graph ?? true),
|
|
164
|
+
scope: flags.scope ? parseScope(flags.scope) : undefined,
|
|
165
|
+
full: !!flags.full,
|
|
166
|
+
}
|
|
92
167
|
case 'act': {
|
|
93
168
|
const [target, n] = parseTarget(words)
|
|
94
169
|
const action = words[n] ?? 'click'
|
|
95
|
-
if (!actions.includes(action))
|
|
170
|
+
if (!actions.includes(action))
|
|
171
|
+
throw Object.assign(new Error(`unknown action ${action}: ${actions.join(', ')}`), {
|
|
172
|
+
code: 'E_USAGE',
|
|
173
|
+
})
|
|
96
174
|
return { target, action, value: words[n + 1] }
|
|
97
175
|
}
|
|
98
176
|
case 'assert': {
|
|
99
177
|
const [target, n] = parseTarget(words)
|
|
100
178
|
const kind = words[n] ?? 'visible'
|
|
101
|
-
if (!expectations.includes(kind))
|
|
179
|
+
if (!expectations.includes(kind))
|
|
180
|
+
throw Object.assign(new Error(`unknown expectation ${kind}: ${expectations.join(', ')}`), {
|
|
181
|
+
code: 'E_USAGE',
|
|
182
|
+
})
|
|
102
183
|
const v = words[n + 1]
|
|
103
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
target,
|
|
186
|
+
expect: {
|
|
187
|
+
[kind]:
|
|
188
|
+
kind === 'count'
|
|
189
|
+
? Number(v)
|
|
190
|
+
: ['text', 'exactText', 'value'].includes(kind)
|
|
191
|
+
? (v ?? '')
|
|
192
|
+
: true,
|
|
193
|
+
},
|
|
194
|
+
timeout: num(flags.timeout),
|
|
195
|
+
}
|
|
104
196
|
}
|
|
105
|
-
case 'graph':
|
|
197
|
+
case 'graph':
|
|
198
|
+
return { filter: words[0], full: !!flags.full }
|
|
106
199
|
case 'set': {
|
|
107
200
|
let value = words[1]
|
|
108
|
-
try {
|
|
201
|
+
try {
|
|
202
|
+
value = JSON.parse(words[1])
|
|
203
|
+
} catch {}
|
|
109
204
|
return { name: words[0], value }
|
|
110
205
|
}
|
|
111
|
-
case 'time':
|
|
112
|
-
|
|
113
|
-
case '
|
|
114
|
-
|
|
115
|
-
case '
|
|
116
|
-
|
|
206
|
+
case 'time':
|
|
207
|
+
return { op: words[0] ?? 'status', ms: num(words[1]), reload: !!flags.reload }
|
|
208
|
+
case 'net':
|
|
209
|
+
return { op: words[0] ?? 'status', ms: num(words[1]) }
|
|
210
|
+
case 'record':
|
|
211
|
+
return {
|
|
212
|
+
op: words[0] ?? 'code',
|
|
213
|
+
text: words.slice(1).join(' '),
|
|
214
|
+
component: flags.component,
|
|
215
|
+
from: flags.from,
|
|
216
|
+
props: flags.props,
|
|
217
|
+
name: flags.name,
|
|
218
|
+
out: flags.out,
|
|
219
|
+
}
|
|
220
|
+
case 'overlay':
|
|
221
|
+
return { show: words[0] === undefined ? undefined : words[0] !== 'off' }
|
|
222
|
+
case 'screenshot':
|
|
223
|
+
return { out: flags.out }
|
|
224
|
+
default:
|
|
225
|
+
return {}
|
|
117
226
|
}
|
|
118
227
|
}
|
|
119
228
|
|
|
120
|
-
const parseScope =
|
|
229
|
+
const parseScope = (s) =>
|
|
230
|
+
s.startsWith('@')
|
|
231
|
+
? s
|
|
232
|
+
: (() => {
|
|
233
|
+
const i = s.indexOf(':')
|
|
234
|
+
return i < 0 ? { role: s } : { role: s.slice(0, i), name: s.slice(i + 1) }
|
|
235
|
+
})()
|
|
121
236
|
|
|
122
237
|
export const browserUsage = `usage: weft browser <command> [--json] [--url <dev server>] [--tab <id>]
|
|
123
238
|
|