@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.
Files changed (171) hide show
  1. package/README.md +38 -14
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +33 -8
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +159 -206
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -43
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +137 -138
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +193 -95
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. package/ts-plugin/proxy.js +144 -0
@@ -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 { return JSON.parse(readFileSync(file, 'utf8')).url.replace(/\/$/, '') } catch {}
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`, { ...init, headers: { 'x-weft-agent': '1', 'content-type': 'application/json' } })
27
- if (res.status === 404) throw fail('E_NO_BRIDGE', `${url} is not a weft dev server (no /__weft/agent): serve the app with the weft() Vite plugin`)
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, { method: 'POST', body: JSON.stringify({ cmd, args, tab, timeout }) })
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
- const t = await tabs(root, { url })
55
- if (t.tabs.length) return t
56
- } catch (e) { if (Date.now() > until) throw e }
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 => { setTimeout(r, 150) })
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: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'],
66
- linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'],
67
- win32: ['C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'],
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
- /** Start a headless Chrome on `route` (a plain page; @weft-js/core/agent in it connects back to the dev server). */
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) throw fail('E_NO_CHROME', 'no Chrome or Chromium found; open the app in your own browser instead, or set WEFT_CHROME')
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 = [`--user-data-dir=${profile}`, '--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', ...(headed ? [] : ['--headless=new']), ...(process.getuid?.() === 0 ? ['--no-sandbox'] : []) /* Chrome won't start as root with its sandbox: a container, CI */, `${base}${route.startsWith('/') ? route : `/${route}`}`]
87
- const child = spawn(exe, args, { detached: true, stdio: 'ignore' })
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
- const t = await waitForTab(root, { url })
91
- return { launched: true, pid: child.pid, headless: !headed, ...t }
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 { process.kill(pid) } catch {}
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)) throw fail('E_NO_CHROME', 'screenshot needs `weft browser launch`; in your own browser, use its screenshot tool')
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) => { ws.onopen = ok; ws.onerror = () => no(fail('E_CDP', 'could not reach the page over the DevTools protocol')) })
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 => { const m = JSON.parse(e.data); if (m.id !== 1) return; if (m.error) no(fail('E_CDP', m.error.message)); else ok(m.result.data) }
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()
@@ -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/weft/src/agent.ts).
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 = ['click', 'dblclick', 'hover', 'focus', 'clear', 'fill', 'type', 'press', 'select', 'check', 'uncheck']
9
- export const expectations = ['visible', 'absent', 'text', 'exactText', 'value', 'checked', 'unchecked', 'disabled', 'enabled', 'focused', 'count']
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 = ms => new Promise(r => { setTimeout(r, ms) })
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': return tabs(root, o)
23
- case 'launch': return launch(root, p.route ?? '/', { url: o.url, headed: p.headed })
24
- case 'close': return close(root)
25
- case 'open': {
26
- const before = await page('info', {})
27
- await page('navigate', { to: p.route ?? '/' })
28
- // Same tab id after the load (@weft-js/core/agent keeps it in sessionStorage); wait for the new document to say hello.
29
- const until = Date.now() + 15_000
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', { interactive: p.interactive, scope: p.scope, delta: p.delta, graph: p.graph })
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': return page('act', { target: p.target, action: p.action, value: p.value })
48
- case 'assert': return page('assert', { target: p.target, expect: p.expect, timeout: p.timeout })
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 { graph: p.full ? out.graph : spill(root, 'graph', out.graph), nodes: out.nodes.length }
76
+ return {
77
+ graph: p.full ? out.graph : spill(root, 'graph', out.graph),
78
+ nodes: out.nodes.length,
79
+ }
52
80
  }
53
- case 'set': return page('set', { name: p.name, value: p.value })
54
- case 'time': return page('time', { op: p.op, ms: p.ms, reload: p.reload })
55
- case 'net': return page('net', { op: p.op, ms: p.ms })
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', { op: p.op, text: p.text, component: p.component, from: p.from, props: p.props, imports: p.imports, name: p.name })
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': return page('logs', {})
67
- case 'overlay': return page('overlay', { show: p.show })
68
- case 'info': return page('info', {})
69
- case 'reload': return page('reload', {})
70
- case 'screenshot': return screenshot(root, p.out && (isAbsolute(p.out) ? p.out : join(root, p.out)))
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) throw Object.assign(new Error('missing target: <role> <name>, label <text>, text <text>, or @ref'), { code: 'E_USAGE' })
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 [b === undefined || b === '-' ? { role: a } : { role: a, name: b }, b === undefined ? 1 : 2]
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': case 'open': return { route: words[0] ?? '/', headed: !!flags.headed }
91
- case 'snapshot': return { interactive: !!flags.interactive, delta: !!flags.delta, graph: flags['graph-only'] ? 'only' : flags['no-graph'] ? false : flags.graph ?? true, scope: flags.scope ? parseScope(flags.scope) : undefined, full: !!flags.full }
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)) throw Object.assign(new Error(`unknown action ${action}: ${actions.join(', ')}`), { code: 'E_USAGE' })
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)) throw Object.assign(new Error(`unknown expectation ${kind}: ${expectations.join(', ')}`), { code: 'E_USAGE' })
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 { target, expect: { [kind]: kind === 'count' ? Number(v) : ['text', 'exactText', 'value'].includes(kind) ? v ?? '' : true }, timeout: num(flags.timeout) }
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': return { filter: words[0], full: !!flags.full }
197
+ case 'graph':
198
+ return { filter: words[0], full: !!flags.full }
106
199
  case 'set': {
107
200
  let value = words[1]
108
- try { value = JSON.parse(words[1]) } catch {}
201
+ try {
202
+ value = JSON.parse(words[1])
203
+ } catch {}
109
204
  return { name: words[0], value }
110
205
  }
111
- case 'time': return { op: words[0] ?? 'status', ms: num(words[1]), reload: !!flags.reload }
112
- case 'net': return { op: words[0] ?? 'status', ms: num(words[1]) }
113
- case 'record': return { op: words[0] ?? 'code', text: words.slice(1).join(' '), component: flags.component, from: flags.from, props: flags.props, name: flags.name, out: flags.out }
114
- case 'overlay': return { show: words[0] === undefined ? undefined : words[0] !== 'off' }
115
- case 'screenshot': return { out: flags.out }
116
- default: return {}
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 = s => (s.startsWith('@') ? s : (() => { const i = s.indexOf(':'); return i < 0 ? { role: s } : { role: s.slice(0, i), name: s.slice(i + 1) } })())
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