wei-wuwei 0.2.1 → 0.3.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/CHANGELOG.md CHANGED
@@ -4,6 +4,10 @@ One section per published version, newest first. `wuwei update` prints the
4
4
  sections between the installed version and the new one before it asks;
5
5
  `wuwei check` fails when package.json's version has no section here.
6
6
 
7
+ ## 0.3.0
8
+
9
+ - wuwei verify on mobile: Maestro on a simulator + wuwei's mock server (#62)
10
+
7
11
  ## 0.2.1
8
12
 
9
13
  - `wuwei review-collect`: finds the latest Claude Code session that
package/README.md CHANGED
@@ -73,7 +73,7 @@ node bin/wuwei.js plan-check [stream] # plan-phases step 11's checkable half, be
73
73
  node bin/wuwei.js worktree <lens> <sha> # a throwaway worktree no other lens shares (--prune)
74
74
  node bin/wuwei.js mutate <f>:<n> --from … --to … --run … --in <wt> # one mutation: landed, baseline, red count, restored
75
75
  node bin/wuwei.js sites <pattern> [--symbol <name>] # a set derived from the tree, graph and grep both
76
- node bin/wuwei.js verify [page] [--phase NN] # every data-bearing page × 500/403/404/empty/pending/failed refetch; --phase: ACCEPTANCE's ## Verify
76
+ node bin/wuwei.js verify [page] [--phase NN] # every data-bearing page × 500/403/404/empty/pending/failed refetch; --phase: ACCEPTANCE's ## Verify; mobile: Maestro + mock server
77
77
  node bin/wuwei.js uninstall # removes exactly what install wrote, nothing else
78
78
  ```
79
79
 
package/bin/wuwei.js CHANGED
@@ -31,9 +31,10 @@
31
31
  * baseline vs mutated, failing tests, restored, recorded
32
32
  * wuwei sites <pattern> [--symbol <name>] a set from the tree, with its derivation;
33
33
  * graph and grep both for a symbol
34
- * wuwei verify [<page>…] [--phase NN] every data-bearing page through its states
35
- * (Playwright, fixtures from .wuwei/verify.json): a matrix of
36
- * shots; --phase runs the pages ACCEPTANCE.md binds to it
34
+ * wuwei verify [<page>…] [--phase NN] [--endpoint <text>] [--states 500,404] every data-bearing page through its states
35
+ * (Playwright on web, Maestro + a mock server on mobile;
36
+ * fixtures from .wuwei/verify.json): a matrix of shots;
37
+ * --phase runs the pages ACCEPTANCE.md binds to it
37
38
  * wuwei plan-check [<stream>] wuwei-plan-phases step 11's checkable half
38
39
  * wuwei uninstall [<path>] remove exactly what install wrote
39
40
  * wuwei -v | --version print the installed version
@@ -48,6 +49,7 @@ import { fileURLToPath } from 'node:url'
48
49
  import { createInterface } from 'node:readline/promises'
49
50
  import { spawnSync, spawn } from 'node:child_process'
50
51
  import { createRequire } from 'node:module'
52
+ import { createServer } from 'node:http'
51
53
  import { createHash } from 'node:crypto'
52
54
 
53
55
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
@@ -1615,6 +1617,9 @@ const VERIFY_STATES = ['500', '403', '404', 'empty', 'pending', 'refetch-500', '
1615
1617
  const BUSY = '[aria-busy="true"],[role="progressbar"],[data-slot*="skeleton"],[data-loading="true"],.skeleton,[data-skeleton],[class*="skeleton"],.animate-pulse,.animate-spin'
1616
1618
  const SAYS_ERROR = /error|failed|could not|couldn't|unable|try again|retry|錯誤|失敗|無法/i
1617
1619
  const TICKS = /\b\d+\s*(s|sec|secs|seconds?|min|mins|minutes?|h|hours?)\s+ago\b|\d+\s*(秒|分鐘|小時)前|just now|剛剛/i
1620
+ const SAYS_NOT_FOUND = /not found|unknown|doesn't exist|does not exist|no longer|找不到|不存在/i
1621
+ // a request never answered, the loader still up: the app has no timeout
1622
+ const STUCK = cfg => `still loading ${(cfg.pendingStuckMs ?? 30000) / 1000}s on, the request never answered — no timeout, the wait never ends`
1618
1623
  const SAYS_FORBIDDEN = /permission|forbidden|not allowed|denied|access|authori[sz]|權限|無權/i
1619
1624
 
1620
1625
  function loadPlaywright(cwd) {
@@ -1624,7 +1629,7 @@ function loadPlaywright(cwd) {
1624
1629
  return null
1625
1630
  }
1626
1631
 
1627
- const globRe = g => new RegExp('^' + g.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*') + '$')
1632
+ const globRe = g => new RegExp('^' + g.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*') + '$')
1628
1633
 
1629
1634
  function fixtureFor(fixtures, method, url) {
1630
1635
  const u = new URL(url)
@@ -1678,13 +1683,28 @@ async function cmdVerify(argv) {
1678
1683
  "pages": [ { "name": "items", "path": "/items", "ready": "text=Items" } ],
1679
1684
  "refetch": "focus", "refetchAfterMs": 600000, "refetchWaitMs": 30000,
1680
1685
  "busy": "[data-slot=skeleton]", // optional: your loader, beyond the usual ones
1681
- "errorWaitMs": 60000 // simulated time client retries may hold the loader
1686
+ "errorWaitMs": 60000, // simulated time client retries may hold the loader
1687
+ "pendingStuckMs": 30000, // a loader still up this long on an unanswered request is a hang; 0 skips
1688
+ "driftPct": 1, "transientMs": 4000 // live numbers within 1% are the same line; watch a failure 4s for a toast
1689
+ }
1690
+ a mobile app (Maestro on a simulator; the app built against the mock server):
1691
+ {
1692
+ "mobile": { "appId": "com.example.app", "port": 48780, // iOS → http://localhost:48780, Android emulator → http://10.0.2.2:48780
1693
+ "login": ".maestro/login.yaml", // optional: run after each launch
1694
+ "device": "<udid>", "maestro": "<path>" }, // optional: which simulator; maestro if not on PATH or in ~/.maestro/bin
1695
+ "fixtures": { … as above … },
1696
+ "pages": [ { "name": "items", "link": "myapp://items", "ready": "Items" } ], // or "flow": ".maestro/open-items.yaml"
1697
+ "refetchAfterMs": 0, // real time in the background: build with staleTime 0, or wait it out
1698
+ "busy": "skeleton|spinner" // optional: a regex over ids and labels
1682
1699
  }`)
1683
1700
  process.exit(1)
1684
1701
  }
1685
1702
  const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'))
1686
1703
  const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
1687
- const only = argv.filter((a, i) => !a.startsWith('--') && !['--phase', '--stream'].includes(argv[i - 1]))
1704
+ const only = argv.filter((a, i) => !a.startsWith('--') && !['--phase', '--stream', '--endpoint', '--states'].includes(argv[i - 1]))
1705
+ // a narrow run: endpoints whose key contains any --endpoint text, states from --states
1706
+ const endpointFilter = argv.flatMap((a, i) => a === '--endpoint' ? [argv[i + 1]] : [])
1707
+ const stateFilter = flag('--states')?.split(',').map(s => s.trim()).filter(Boolean) ?? null
1688
1708
  // --phase NN: the pages ACCEPTANCE.md binds to that phase — the contract, not memory
1689
1709
  if (flag('--phase')) {
1690
1710
  const sdir = join(dest, '.wuwei', 'streams')
@@ -1699,7 +1719,151 @@ async function cmdVerify(argv) {
1699
1719
  only.push(...want)
1700
1720
  }
1701
1721
  const missing = only.filter(n => !(cfg.pages ?? []).some(pg => pg.name === n))
1702
- if (missing.length) { console.error(`✗ not in .wuwei/verify.json: ${missing.join(', ')} — add each page (path, ready) and the fixtures it calls, from the API's schema`); process.exit(1) }
1722
+ if (missing.length) { console.error(`✗ not in .wuwei/verify.json: ${missing.join(', ')} — add each page (path or link, ready) and the fixtures it calls, from the API's schema`); process.exit(1) }
1723
+ const sha = quiet('git', ['rev-parse', '--verify', '--quiet', 'HEAD']).stdout?.trim().slice(0, 12) || 'nogit'
1724
+ const outDir = join(dest, '.wuwei-cache', 'verify', sha)
1725
+ const driver = cfg.mobile ? await verifyMobile(cfg, dest, outDir) : await verifyWeb(cfg, dest)
1726
+ const report = []
1727
+ const lines = t => new Set(t.split('\n').map(l => l.trim()).filter(l => l.length > 2))
1728
+ // a live number (an on-chain TVL) moves between any two opens: a line whose
1729
+ // only change is its numbers, each within driftPct (1%), is the same line —
1730
+ // $33,000 → $8,000 is not drift, it is the failure
1731
+ const NUM = /\d[\d,]*(?:\.\d+)?/g
1732
+ const nums = l => (l.match(NUM) ?? []).map(n => Number(n.replace(/,/g, '')))
1733
+ // and a relative time ("updated 2 min ago") is the clock, not the content: it is
1734
+ // taken out of a line, never the whole line with it
1735
+ const unticked = l => l.replace(new RegExp(TICKS.source, 'gi'), '').trim()
1736
+ const drifted = (a, b) => (a = unticked(a), b = unticked(b), a.replace(NUM, '#') === b.replace(NUM, '#')) && nums(a).every((x, i) => {
1737
+ const y = nums(b)[i]; return x === y || Math.abs(x - y) <= Math.max(Math.abs(x), Math.abs(y)) * (cfg.driftPct ?? 1) / 100 })
1738
+ const diff = (before, after) => {
1739
+ const said = [...after].filter(l => !before.has(l)), gone = []
1740
+ for (const l of [...before].filter(l => !after.has(l) && unticked(l))) {
1741
+ const i = said.findIndex(s => drifted(l, s)); if (i === -1) gone.push(l); else said.splice(i, 1)
1742
+ }
1743
+ return { said, gone }
1744
+ }
1745
+ const writeMatrix = ({ page, rows, endpoints, dir }, stopped) => {
1746
+ const md = [`# verify ${page.name} @ ${sha}`, '', `page: \`${page.path ?? page.link ?? page.flow}\` · endpoints: ${endpoints.length} · states: ${VERIFY_STATES.join(', ')}`,
1747
+ ...(stopped ? ['', `**stopped:** ${stopped}`] : []), '',
1748
+ '| endpoint | state | flags | on screen, beyond happy | shot |', '|---|---|---|---|---|',
1749
+ ...rows.map(r => `| ${r.endpoint} | ${r.state} | ${r.flags.length ? r.flags.join('; ') : '—'} | ${(r.note ?? (r.said ?? r.text).split('\n').find(l => l.trim()) ?? '—').slice(0, 60).replace(/\|/g, '\\|')} | ${r.shot} |`)]
1750
+ writeFileSync(join(dir, 'matrix.md'), md.join('\n') + '\n')
1751
+ }
1752
+ // stopped by hand: what ran so far is still a matrix
1753
+ let current = null
1754
+ process.once('SIGINT', () => {
1755
+ if (current) { writeMatrix(current, 'interrupted'); console.log(`\n✗ ${current.page.name}: interrupted after ${current.rows.length} state(s) — ${join(current.dir, 'matrix.md')}`) }
1756
+ process.exit(130)
1757
+ })
1758
+
1759
+ for (const page of (cfg.pages ?? []).filter(pg => !only.length || only.includes(pg.name))) {
1760
+ const dir = join(outDir, page.name)
1761
+ mkdirSync(dir, { recursive: true })
1762
+ const happy = await driver.visit(page, { happy: true })
1763
+ await happy.shot(join(dir, 'happy.png'))
1764
+ writeFileSync(join(dir, 'happy.txt'), happy.text)
1765
+ await happy.close()
1766
+ // a page that never opened makes every state below a picture of the wrong screen
1767
+ const abort = happy.flowError ? `the flow failed — ${happy.flowError}`
1768
+ : page.ready && !happy.ready ? `happy never showed "${page.ready}" — check the link or flow, login and fixtures (happy.png)` : null
1769
+ if (abort) {
1770
+ writeMatrix({ page, rows: [{ endpoint: '—', state: 'happy', flags: ['flow failed'], note: abort.slice(0, 60), shot: 'happy.png', text: '' }], endpoints: [], dir }, abort)
1771
+ report.push({ page: page.name, rows: [], dir, abort }); continue
1772
+ }
1773
+ for (const vp of driver.viewports ?? []) {
1774
+ const narrow = await driver.visit(page, { viewport: vp })
1775
+ await narrow.shot(join(dir, `happy-${vp.width}.png`))
1776
+ await narrow.close()
1777
+ }
1778
+ const endpoints = [...happy.seen.keys()]
1779
+ const happyLines = lines(happy.text)
1780
+ const rows = [{ endpoint: '—', state: 'happy', flags: [...[...happy.unmocked].map(u => `unmocked: ${u}`), ...happy.errors.map(e => `page error: ${e}`)], shot: 'happy.png', text: happy.text }]
1781
+ const served = new Map(happy.seen)
1782
+ current = { page, rows, endpoints, dir }
1783
+ // which endpoints the trigger refetches is the app's query config, not the
1784
+ // failing endpoint's: after one trigger, only those it re-requested run refetch states
1785
+ let triggered = false
1786
+ const refetchable = new Set(), invisible = new Set()
1787
+ // a filter that is a whole key means that key alone; otherwise any key containing it
1788
+ const picked = k => !endpointFilter.length || endpointFilter.some(f => endpoints.includes(f) ? k === f : k.includes(f))
1789
+ pageLoop: for (const key of endpoints.filter(picked)) {
1790
+ for (const state of VERIFY_STATES.filter(s => !stateFilter || stateFilter.includes(s))) {
1791
+ if (state === 'empty' && !emptied(served.get(key)?.json)) continue
1792
+ const refetch = state.match(/^refetch-(\d+)$/)
1793
+ if (refetch && triggered && !refetchable.has(key)) { rows.push({ endpoint: key, state, flags: [], note: `skipped — the trigger re-requested ${refetchable.size ? [...refetchable].join(', ') : 'nothing'}`, shot: '—', text: '' }); continue }
1794
+ const v = refetch ? await driver.visit(page, { refetchFail: { key, status: Number(refetch[1]) } }) : await driver.visit(page, { override: { key, state } })
1795
+ const slug = `${key.replace(/[^\w]+/g, '_').replace(/^_|_$/g, '')}--${state}`
1796
+ await v.shot(join(dir, `${slug}.png`))
1797
+ writeFileSync(join(dir, `${slug}.txt`), v.text)
1798
+ await v.close()
1799
+ if (v.flowError) {
1800
+ rows.push({ endpoint: key, state, flags: ['flow failed'], note: v.flowError.slice(0, 60), shot: `${slug}.png`, text: '' })
1801
+ const abort = `${key} · ${state}: the flow failed — ${v.flowError}`
1802
+ writeMatrix(current, abort); report.push({ page: page.name, rows, dir, abort, written: true }); break pageLoop
1803
+ }
1804
+ if (refetch) { triggered = true; for (const k of v.afterKeys ?? []) refetchable.add(k) }
1805
+ const vLines = lines(v.text)
1806
+ // judged on what the state added to the screen: a nav item saying "Access" is not an answer.
1807
+ // A toast gone by the time the screen settles still said it: glimpsed lines count
1808
+ const d = diff(happyLines, vLines)
1809
+ const known = [...happyLines, ...vLines]
1810
+ const glimpsed = [...(v.glimpsed ?? [])].filter(l => l.length > 2 && unticked(l) && !known.some(k => k === l || drifted(k, l)))
1811
+ // what the screen ended on decides loading; what it said along the way counts as said
1812
+ const said = [...d.said, ...glimpsed].join('\n'), gone = d.gone
1813
+ const loading = v.busy || /loading|載入/i.test(d.said.join('\n'))
1814
+ const flags = v.errors.map(e => `page error: ${e}`)
1815
+ // the screen as happy had it: this page shows nothing of this endpoint — not a missing error
1816
+ const same = !said && !gone.length
1817
+ // shows nothing of it when it fails outright, so a failed refetch of it shows nothing either
1818
+ if (same && refetch && invisible.has(key)) { rows.push({ endpoint: key, state, flags, note: 'no visible effect — this page shows nothing of it', shot: `${slug}.png`, text: v.text, said }); continue }
1819
+ if (same && !refetch) invisible.add(key)
1820
+ if (same && !refetch) { rows.push({ endpoint: key, state, flags: [...flags, ...(v.stuck ? [STUCK(cfg)] : [])], note: 'no visible effect — this page shows nothing of it', shot: `${slug}.png`, text: v.text, said }); continue }
1821
+ if (/^5/.test(state) && loading) flags.push(`still loading ${(cfg.errorWaitMs ?? driver.errorWaitMs) / 1000}s on — client retries outlast the wait; raise "errorWaitMs"`)
1822
+ else if (/^5/.test(state) && !SAYS_ERROR.test(said)) flags.push('no error shown')
1823
+ if (state === '403' && !SAYS_FORBIDDEN.test(said)) flags.push('403 not told as a permission problem')
1824
+ if (state === '404' && v.retry) flags.push('Retry offered on a 404 — it can never succeed')
1825
+ // a 404 that changed the screen — lines gone, or a control greyed out — and says nothing
1826
+ const changed = [...gone, ...d.said.filter(l => l.startsWith('[disabled]'))]
1827
+ if (state === '404' && changed.length && !SAYS_ERROR.test(said) && !SAYS_NOT_FOUND.test(said)) flags.push(`404: ${changed.length} change(s) on screen and nothing says why (${changed.slice(0, 2).join(' / ').slice(0, 60)})`)
1828
+ if (state === '403' && v.retry) flags.push('Retry offered on a 403 — it can never succeed')
1829
+ if (state === 'pending' && !v.busy && !/loading|載入/i.test(v.text)) flags.push('no loading indicator')
1830
+ if (state === 'pending' && v.stuck) flags.push(STUCK(cfg))
1831
+ // a refetch that never happened says nothing about how a failed one looks;
1832
+ // what it did say is the lines it added — a relative time ticking is not an error
1833
+ const told = SAYS_ERROR.test(said) || (state === 'refetch-403' && SAYS_FORBIDDEN.test(said))
1834
+ if (refetch && !v.refetched) flags.push(driver.notRefetched)
1835
+ else if (refetch && !told) flags.push('silent: the failed refetch shows nothing')
1836
+ else if (refetch && gone.length) flags.push(`refetch failure replaced loaded content: ${gone.length} line(s) gone (${gone.slice(0, 2).join(' / ').slice(0, 60)})`)
1837
+ if (state === 'refetch-403' && v.refetched && told && !SAYS_FORBIDDEN.test(said)) flags.push('403 on refetch not told as a permission problem')
1838
+ rows.push({ endpoint: key, state, flags, shot: `${slug}.png`, text: v.text, said })
1839
+ }
1840
+ }
1841
+ current = null
1842
+ if (report.at(-1)?.page === page.name) continue
1843
+ // a failure that reads exactly like no data: the user is told there is nothing, not that it broke
1844
+ for (const e of rows.filter(r => r.state === 'empty' && r.said)) for (const r of rows)
1845
+ if (r.endpoint === e.endpoint && /^\d+$/.test(r.state) && r.said === e.said) r.flags.push(`${r.state} shown as the empty state ("${e.said.split('\n')[0].slice(0, 40)}")`)
1846
+ writeMatrix({ page, rows, endpoints, dir })
1847
+ report.push({ page: page.name, rows, dir })
1848
+ }
1849
+ await driver.done()
1850
+
1851
+ for (const { page, rows, dir, abort } of report) {
1852
+ if (abort) { console.log(`✗ ${page}: stopped after ${rows.length} state(s) — ${abort}${rows.length ? ` · what ran: ${join(dir, 'matrix.md')}` : ''}`); continue }
1853
+ const flagged = rows.filter(r => r.flags.length), quiet = rows.filter(r => r.note && !r.flags.length)
1854
+ console.log(`${page}: ${rows.length} state(s), ${flagged.length} flagged — ${join(dir, 'matrix.md')}`)
1855
+ for (const r of flagged) console.log(` ${r.endpoint} · ${r.state}: ${r.flags.join('; ')}`)
1856
+ const noEffect = [...new Set(quiet.filter(r => r.note.startsWith('no visible')).map(r => r.endpoint))]
1857
+ if (noEffect.length) console.log(` no visible effect on this page: ${noEffect.join(', ')}`)
1858
+ const skipped = quiet.filter(r => r.note.startsWith('skipped'))
1859
+ if (skipped.length) console.log(` ${skipped.length} refetch state(s) skipped — ${skipped[0].note.replace(/^skipped — /, '')}`)
1860
+ }
1861
+ console.log('\nflags are leads, not findings: open the shot, and confirm against the spec what the page should say')
1862
+ if (report.some(r => r.abort)) process.exit(1)
1863
+ }
1864
+
1865
+ // the web half: Playwright routes the page's own requests to the fixtures
1866
+ async function verifyWeb(cfg, dest) {
1703
1867
  const pw = loadPlaywright(dest)
1704
1868
  if (!pw) { console.error('✗ Playwright not found in this project or globally — npm i -D playwright'); process.exit(1) }
1705
1869
  let server = null
@@ -1708,12 +1872,9 @@ async function cmdVerify(argv) {
1708
1872
  if (!(await waitForUrl(cfg.url, cfg.startTimeoutMs ?? 120000))) { console.error(`✗ ${cfg.url} did not come up after \`${cfg.start}\``); try { process.kill(-server.pid) } catch {} ; process.exit(1) }
1709
1873
  } else if (!(await waitForUrl(cfg.url, 5000))) { console.error(`✗ ${cfg.url} is not reachable — start the app, or set "start"`); process.exit(1) }
1710
1874
 
1711
- const sha = quiet('git', ['rev-parse', 'HEAD']).stdout?.trim().slice(0, 12) || 'nogit'
1712
- const outDir = join(dest, '.wuwei-cache', 'verify', sha)
1713
1875
  const browser = await pw.chromium.launch()
1714
1876
  const apiRes = [].concat(cfg.api ?? ['**/api/**']).map(globRe)
1715
1877
  const isApi = url => apiRes.some(re => re.test(url) || re.test(new URL(url).pathname))
1716
- const report = []
1717
1878
 
1718
1879
  // one visit: routes every API call to its fixture, except the endpoint under test
1719
1880
  const visit = async (page, { override, refetchFail, viewport } = {}) => {
@@ -1726,19 +1887,21 @@ async function cmdVerify(argv) {
1726
1887
  }, cfg.auth)
1727
1888
  const p = await ctx.newPage()
1728
1889
  const seen = new Map(), unmocked = new Set(), errors = []
1729
- let failing = false, refetched = 0
1890
+ let failing = false, refetched = 0, hits = 0
1891
+ const afterKeys = new Set(), glimpsed = new Set()
1730
1892
  p.on('pageerror', e => errors.push(String(e.message ?? e).split('\n')[0]))
1731
1893
  await p.route('**/*', async route => {
1732
1894
  const req = route.request(), url = req.url()
1733
1895
  if (!isApi(url)) return route.continue()
1734
1896
  const fx = fixtureFor(cfg.fixtures, req.method(), url)
1735
1897
  if (!fx) { unmocked.add(`${req.method()} ${new URL(url).pathname}`); return route.fulfill({ status: 501, body: 'unmocked by wuwei verify' }) }
1898
+ if (failing) afterKeys.add(fx.key)
1736
1899
  seen.set(fx.key, fx)
1737
1900
  const hit = override && fx.key === override.key
1738
1901
  if ((hit && override.state === 'pending')) return // never answered: the loading state
1739
- if (hit && /^\d+$/.test(override.state)) return route.fulfill({ status: Number(override.state), contentType: 'application/json', body: JSON.stringify({ message: `wuwei verify ${override.state}` }) })
1902
+ if (hit && /^\d+$/.test(override.state)) return hits++, route.fulfill({ status: Number(override.state), contentType: 'application/json', body: JSON.stringify({ message: `wuwei verify ${override.state}` }) })
1740
1903
  if (hit && override.state === 'empty') return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(emptied(fx.json)) })
1741
- if (refetchFail && fx.key === refetchFail.key && failing) return refetched++, route.fulfill({ status: refetchFail.status, contentType: 'application/json', body: JSON.stringify({ message: `wuwei verify refetch ${refetchFail.status}` }) })
1904
+ if (refetchFail && fx.key === refetchFail.key && failing) return refetched++, hits++, route.fulfill({ status: refetchFail.status, contentType: 'application/json', body: JSON.stringify({ message: `wuwei verify refetch ${refetchFail.status}` }) })
1742
1905
  return route.fulfill({ status: fx.status, contentType: 'application/json', body: JSON.stringify(fx.json) })
1743
1906
  })
1744
1907
  // the page's clock is ours: a fresh cache never refetches on focus (React Query's
@@ -1747,20 +1910,32 @@ async function cmdVerify(argv) {
1747
1910
  await p.clock.install()
1748
1911
  const busyNow = () => p.evaluate(sel => Boolean(document.querySelector(sel)) || /\bloading\b|載入中/i.test(document.body.innerText),
1749
1912
  [BUSY, cfg.busy].filter(Boolean).join(',')).catch(() => false)
1750
- const settle = async (ms, until) => {
1913
+ // a query with no loader retrying a 5xx is not done either: quiet = 8 simulated
1914
+ // seconds with no new hit on the failing endpoint (React Query backs off 1s, 2s, 4s)
1915
+ let lastHits = -1, quiet = 0
1916
+ const retrying = () => { if (hits !== lastHits) { lastHits = hits; quiet = 0 } else quiet++; return quiet < 8 }
1917
+ // what the screen said along the way: a toast gone before it settles still said it
1918
+ const glimpse = async () => { for (const l of (await p.locator('body').innerText().catch(() => '')).split('\n')) if (l.trim().length > 2) glimpsed.add(l.replace(/[ \t]+/g, ' ').trim()) }
1919
+ const settle = async (ms, until, min = 0) => {
1751
1920
  for (let t = 0; t < ms; t += 1000) {
1752
- if (until && !(await until())) return
1921
+ if (t >= min && until && !(await until())) return
1753
1922
  await p.clock.runFor(1000)
1754
1923
  // real time for the fetch a timer just fired: networkidle, once reached, never waits again
1755
1924
  await p.waitForTimeout(100)
1925
+ await glimpse()
1756
1926
  }
1757
1927
  }
1758
1928
  await p.goto(new URL(page.path, cfg.url).href, { waitUntil: 'domcontentloaded' })
1759
- if (override?.state === 'pending') await p.waitForTimeout(cfg.pendingWaitMs ?? 1500)
1760
- else {
1761
- if (page.ready) await p.locator(page.ready).first().waitFor({ timeout: 15000 }).catch(() => {})
1929
+ let ready = true, early = null, stuck = false
1930
+ if (override?.state === 'pending') {
1931
+ await p.waitForTimeout(cfg.pendingWaitMs ?? 1500)
1932
+ early = await busyNow()
1933
+ // the loader is right at first; still up long after, with the request never answered, is a hang
1934
+ if (cfg.pendingStuckMs !== 0) { await settle(cfg.pendingStuckMs ?? 30000); stuck = await busyNow() }
1935
+ } else {
1936
+ if (page.ready) ready = await p.locator(page.ready).first().waitFor({ timeout: 15000 }).then(() => true, () => false)
1762
1937
  await p.waitForLoadState('networkidle').catch(() => {})
1763
- if (override) await settle(cfg.errorWaitMs ?? 60000, busyNow)
1938
+ if (override) await settle(cfg.errorWaitMs ?? 60000, async () => (await busyNow()) || (/^5/.test(override.state) && retrying()), cfg.transientMs ?? 4000)
1764
1939
  }
1765
1940
  if (refetchFail) {
1766
1941
  failing = true
@@ -1770,80 +1945,178 @@ async function cmdVerify(argv) {
1770
1945
  else await p.locator(cfg.refetch).first().click().catch(() => {})
1771
1946
  await settle(cfg.refetchWaitMs ?? 30000)
1772
1947
  }
1773
- const text = (await p.locator('body').innerText().catch(() => '')).replace(/[ \t]+/g, ' ').trim()
1948
+ // a control that stopped accepting input changed the screen, whatever its text says
1949
+ const disabled = await p.evaluate(() => [...document.querySelectorAll('button:disabled,input:disabled,select:disabled,[aria-disabled="true"]')]
1950
+ .map(e => `[disabled] ${(e.innerText || e.value || e.getAttribute('aria-label') || e.name || e.tagName).trim()}`)).catch(() => [])
1951
+ const text = [(await p.locator('body').innerText().catch(() => '')).replace(/[ \t]+/g, ' ').trim(), ...disabled].join('\n')
1774
1952
  const busy = await p.evaluate(sel => Boolean(document.querySelector(sel)), [BUSY, cfg.busy].filter(Boolean).join(','))
1775
1953
  const retry = await p.getByRole('button', { name: /retry|try again|重試/i }).count()
1776
- return { p, ctx, text, busy, retry, seen, unmocked, errors, refetched }
1954
+ return { text, busy: early ?? busy, retry, seen, unmocked, errors, refetched, afterKeys, glimpsed, ready, stuck,
1955
+ shot: path => p.screenshot({ path, fullPage: true }), close: () => ctx.close() }
1777
1956
  }
1778
- const lines = t => new Set(t.split('\n').map(l => l.trim()).filter(l => l.length > 2))
1779
-
1780
- for (const page of (cfg.pages ?? []).filter(pg => !only.length || only.includes(pg.name))) {
1781
- const dir = join(outDir, page.name)
1782
- mkdirSync(dir, { recursive: true })
1783
- const happy = await visit(page)
1784
- await happy.p.screenshot({ path: join(dir, 'happy.png'), fullPage: true })
1785
- writeFileSync(join(dir, 'happy.txt'), happy.text)
1786
- for (const vp of cfg.viewports ?? [{ width: 390, height: 844 }]) {
1787
- const narrow = await visit(page, { viewport: vp })
1788
- await narrow.p.screenshot({ path: join(dir, `happy-${vp.width}.png`), fullPage: true })
1789
- await narrow.ctx.close()
1957
+ return {
1958
+ visit, viewports: cfg.viewports ?? [{ width: 390, height: 844 }], errorWaitMs: 60000,
1959
+ notRefetched: `not refetched by "${cfg.refetch ?? 'focus'}" ${Math.round((cfg.refetchAfterMs ?? 600000) / 60000)} min on — set "refetch" to what refetches it, or a longer "refetchAfterMs"`,
1960
+ done: async () => { await browser.close(); if (server) try { process.kill(-server.pid) } catch {} },
1961
+ }
1962
+ }
1963
+
1964
+ // the mobile half: Maestro drives the app on a simulator, and the app is built
1965
+ // against wuwei's mock server — a native app has no page.route to take over.
1966
+ // No clock either: the background wait is real, so a test build with staleTime 0
1967
+ // (or "refetchAfterMs" ≥ staleTime) is what lets a refetch happen at all
1968
+ async function verifyMobile(cfg, dest, outDir) {
1969
+ const m = cfg.mobile
1970
+ if (!m.appId) { console.error('✗ verify.json: mobile.appId is required'); process.exit(1) }
1971
+ const unreachable = (cfg.pages ?? []).filter(pg => !pg.link && !pg.flow).map(pg => pg.name)
1972
+ if (unreachable.length) { console.error(`✗ verify.json: ${unreachable.join(', ')} — a mobile page needs "link" (a deep link) or "flow" (a Maestro flow that opens it)`); process.exit(1) }
1973
+ // the installer puts it in ~/.maestro/bin, which a non-interactive shell's PATH often lacks
1974
+ const MAESTRO = m.maestro ?? (!quiet('maestro', ['--version']).error ? 'maestro'
1975
+ : existsSync(join(homedir(), '.maestro', 'bin', 'maestro')) ? join(homedir(), '.maestro', 'bin', 'maestro') : null)
1976
+ if (!MAESTRO) { console.error('✗ maestro not found on PATH or in ~/.maestro/bin — curl -Ls "https://get.maestro.mobile.dev" | bash'); process.exit(1) }
1977
+ // one directory per run: a second run at the same sha must not overwrite the flows an error names
1978
+ const flowsDir = join(outDir, 'flows', new Date().toISOString().replace(/[:.]/g, '-'))
1979
+ mkdirSync(flowsDir, { recursive: true })
1980
+ const sleep = ms => new Promise(r => setTimeout(r, ms))
1981
+ const device = m.device ? ['--device', m.device] : []
1982
+
1983
+ // one scenario at a time: what the next visit's requests get
1984
+ let sc = null
1985
+ const held = []
1986
+ const server = createServer((req, res) => {
1987
+ const reply = (status, json) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(json)) }
1988
+ const fx = fixtureFor(cfg.fixtures, req.method, `http://wuwei.local${req.url}`)
1989
+ if (!sc) return reply(503, { message: 'wuwei verify: no scenario' })
1990
+ if (!fx) { sc.unmocked.add(`${req.method} ${req.url.split('?')[0]}`); return reply(501, { message: 'unmocked by wuwei verify' }) }
1991
+ sc.seen.set(fx.key, fx)
1992
+ if (sc.failing) sc.afterKeys.add(fx.key)
1993
+ const { override, refetchFail } = sc
1994
+ const hit = override && fx.key === override.key
1995
+ if (hit && override.state === 'pending') return held.push(res) // never answered: the loading state
1996
+ if (hit && /^\d+$/.test(override.state)) return sc.hits++, sc.lastHit = Date.now(), reply(Number(override.state), { message: `wuwei verify ${override.state}` })
1997
+ if (hit && override.state === 'empty') return reply(200, emptied(fx.json))
1998
+ if (refetchFail && fx.key === refetchFail.key && sc.failing) return sc.refetched++, sc.lastHit = Date.now(), reply(refetchFail.status, { message: `wuwei verify refetch ${refetchFail.status}` })
1999
+ return reply(fx.status, fx.json)
2000
+ })
2001
+ await new Promise((ok, fail) => server.once('error', fail).listen(m.port ?? 48780, '127.0.0.1', ok))
2002
+ .catch(e => { console.error(`✗ mock server on port ${m.port ?? 48780}: ${e.message}`); process.exit(1) })
2003
+
2004
+ // never spawnSync here: a blocked event loop is a mock server that answers nothing
2005
+ const maestro = args => new Promise(done => {
2006
+ const c = spawn(MAESTRO, [...device, ...args], { stdio: ['ignore', 'pipe', 'pipe'] })
2007
+ let out = '', err = ''
2008
+ c.stdout.on('data', d => { out += d })
2009
+ c.stderr.on('data', d => { err += d })
2010
+ const timer = setTimeout(() => c.kill(), 180000)
2011
+ c.on('close', status => { clearTimeout(timer); done({ status, stdout: out, stderr: err }) })
2012
+ c.on('error', e => { clearTimeout(timer); done({ status: -1, stdout: out, stderr: String(e.message) }) })
2013
+ })
2014
+ // a flow that failed is never a silent pass: its file and the error lines, with
2015
+ // typed text (a login's password) kept out of the report
2016
+ let n = 0
2017
+ const run = async steps => {
2018
+ const file = join(flowsDir, `${String(++n).padStart(3, '0')}.yaml`)
2019
+ writeFileSync(file, `appId: ${m.appId}\n---\n${steps.join('\n')}\n`)
2020
+ const r = await maestro(['test', file])
2021
+ if (r.status === 0) return null
2022
+ // Maestro often prints only a summary; its FAILED line is in the newest ~/.maestro/tests/*/maestro.log
2023
+ const logs = join(homedir(), '.maestro', 'tests')
2024
+ const latest = existsSync(logs) ? readdirSync(logs).map(d => join(logs, d, 'maestro.log')).filter(existsSync).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0] : null
2025
+ const why = `${r.stderr}\n${r.stdout}\n${latest ? readFileSync(latest, 'utf8').split('\n').slice(-200).join('\n') : ''}`.split('\n')
2026
+ .filter(l => /error|fail|unrecognized|exception|not found/i.test(l) && !/input ?text/i.test(l))
2027
+ return `${file.slice(dest.length + 1)} exited ${r.status}: ${(why.slice(0, 2).join(' / ') || 'no error line').trim().slice(0, 200)}`
2028
+ }
2029
+ const abs = f => resolve(dest, f)
2030
+ const BUSY_RE = new RegExp(['progress|spinner|skeleton|activity.?indicator|loading', m.busy ?? cfg.busy].filter(Boolean).join('|'), 'i')
2031
+ // the screen as text: every node's text (or label) in tree order
2032
+ // the status bar — clock, battery, signal — changes between any two shots
2033
+ const CHROME = /^(\d{1,2}:\d{2}(\s?[AP]M)?|\d{1,3}\s?%|(battery|wi-?fi|signal|cellular|no sim|no signal|no service|searching|lte|5g|4g)\b.*)$/i
2034
+ const capture = async () => {
2035
+ const r = await maestro(['hierarchy'])
2036
+ let tree = null
2037
+ // log lines come before the JSON and after it: the tree is from the first line
2038
+ // that opens an object to the last that closes one
2039
+ try { const o = r.stdout; tree = JSON.parse(o.slice(o.search(/^\{/m), o.lastIndexOf('\n}') + 2)) } catch {}
2040
+ const ids = []
2041
+ // a node's own text, else its label — but a label over children that have text
2042
+ // is their concatenation (an RN accessibility group), so the children speak
2043
+ // and a label whose text the children do not carry (iOS Text nodes often have
2044
+ // none of their own) is kept: the group's label is then the only place the value is
2045
+ const walk = node => {
2046
+ const a = node?.attributes ?? {}
2047
+ ids.push(a['resource-id'] ?? '', a.accessibilityText ?? '', a.class ?? '')
2048
+ const kids = (node?.children ?? []).flatMap(walk)
2049
+ const label = (a.accessibilityText || a.value || '').trim()
2050
+ let rest = label
2051
+ for (const k of kids) rest = rest.replace(k, '')
2052
+ const own = (a.text || (/[\p{L}\p{N}]{2,}/u.test(rest) ? label : '') || '').trim()
2053
+ // a control that stopped accepting input changed the screen, whatever its text says
2054
+ const off = a.enabled === 'false' && (a.clickable === 'true' || a.text || label) ? [`[disabled] ${a.text || label || a['resource-id']}`] : []
2055
+ return [...(own && !CHROME.test(own) ? [own] : []), ...kids, ...off]
1790
2056
  }
1791
- const endpoints = [...happy.seen.keys()]
1792
- const happyLines = lines(happy.text)
1793
- const rows = [{ endpoint: '—', state: 'happy', flags: [...[...happy.unmocked].map(u => `unmocked: ${u}`), ...happy.errors.map(e => `page error: ${e}`)], shot: 'happy.png', text: happy.text }]
1794
- await happy.ctx.close()
1795
- const served = new Map(happy.seen)
1796
- for (const key of endpoints) {
1797
- for (const state of VERIFY_STATES) {
1798
- if (state === 'empty' && !emptied(served.get(key)?.json)) continue
1799
- const refetch = state.match(/^refetch-(\d+)$/)
1800
- const v = refetch ? await visit(page, { refetchFail: { key, status: Number(refetch[1]) } }) : await visit(page, { override: { key, state } })
1801
- const slug = `${key.replace(/[^\w]+/g, '_').replace(/^_|_$/g, '')}--${state}`
1802
- await v.p.screenshot({ path: join(dir, `${slug}.png`), fullPage: true })
1803
- writeFileSync(join(dir, `${slug}.txt`), v.text)
1804
- await v.ctx.close()
1805
- const vLines = lines(v.text)
1806
- // judged on what the state added to the screen: a nav item saying "Access" is not an answer
1807
- const said = [...vLines].filter(l => !happyLines.has(l)).join('\n')
1808
- const loading = v.busy || /loading|載入/i.test(said)
1809
- const flags = v.errors.map(e => `page error: ${e}`)
1810
- if (/^5/.test(state) && loading) flags.push(`still loading ${(cfg.errorWaitMs ?? 60000) / 1000}s on — client retries outlast the wait; raise "errorWaitMs"`)
1811
- else if (/^5/.test(state) && !SAYS_ERROR.test(said)) flags.push('no error shown')
1812
- if (state === '403' && !SAYS_FORBIDDEN.test(said)) flags.push('403 not told as a permission problem')
1813
- if (state === '404' && v.retry) flags.push('Retry offered on a 404 — it can never succeed')
1814
- if (state === '403' && v.retry) flags.push('Retry offered on a 403 — it can never succeed')
1815
- if (state === 'pending' && !v.busy && !/loading|載入/i.test(v.text)) flags.push('no loading indicator')
1816
- // a refetch that never happened says nothing about how a failed one looks;
1817
- // what it did say is the lines it added — a relative time ticking is not an error
1818
- const gone = [...happyLines].filter(l => !vLines.has(l) && !TICKS.test(l))
1819
- const told = SAYS_ERROR.test(said) || (state === 'refetch-403' && SAYS_FORBIDDEN.test(said))
1820
- if (refetch && !v.refetched) flags.push(`not refetched by "${cfg.refetch ?? 'focus'}" ${Math.round((cfg.refetchAfterMs ?? 600000) / 60000)} min on — set "refetch" to what refetches it, or a longer "refetchAfterMs"`)
1821
- else if (refetch && !told) flags.push('silent: the failed refetch shows nothing')
1822
- else if (refetch && gone.length) flags.push(`refetch failure replaced loaded content: ${gone.length} line(s) gone (${gone.slice(0, 2).join(' / ').slice(0, 60)})`)
1823
- if (state === 'refetch-403' && v.refetched && told && !SAYS_FORBIDDEN.test(said)) flags.push('403 on refetch not told as a permission problem')
1824
- rows.push({ endpoint: key, state, flags, shot: `${slug}.png`, text: v.text, said })
1825
- }
2057
+ const texts = walk(tree).filter((t, i, all) => all[i - 1] !== t)
2058
+ const text = texts.join('\n')
2059
+ return { text, busy: ids.some(i => i && BUSY_RE.test(i)) || /\bloading\b|載入中/i.test(text), retry: texts.filter(t => /^(retry|try again|重試)$/i.test(t)).length, ok: Boolean(tree) }
2060
+ }
2061
+ // every capture along the way is kept: a toast gone before the screen settles still said it
2062
+ let glimpsed = new Set()
2063
+ const settle = async (ms, until, min = 0) => {
2064
+ const t0 = Date.now()
2065
+ let c = await capture()
2066
+ for (const l of c.text.split('\n')) glimpsed.add(l)
2067
+ while (Date.now() - t0 < ms && (Date.now() - t0 < min || until(c))) {
2068
+ await sleep(1500); c = await capture()
2069
+ for (const l of c.text.split('\n')) glimpsed.add(l)
1826
2070
  }
1827
- // a failure that reads exactly like no data: the user is told there is nothing, not that it broke
1828
- for (const e of rows.filter(r => r.state === 'empty' && r.said)) for (const r of rows)
1829
- if (r.endpoint === e.endpoint && /^\d+$/.test(r.state) && r.said === e.said) r.flags.push(`${r.state} shown as the empty state ("${e.said.split('\n')[0].slice(0, 40)}")`)
1830
- const md = [`# verify ${page.name} @ ${sha}`, '', `page: \`${page.path}\` · endpoints: ${endpoints.length} · states: ${VERIFY_STATES.join(', ')}`, '',
1831
- '| endpoint | state | flags | on screen, beyond happy | shot |', '|---|---|---|---|---|',
1832
- ...rows.map(r => `| ${r.endpoint} | ${r.state} | ${r.flags.length ? r.flags.join('; ') : '—'} | ${((r.said ?? r.text).split('\n').find(l => l.trim()) ?? '—').slice(0, 60).replace(/\|/g, '\\|')} | ${r.shot} |`)]
1833
- writeFileSync(join(dir, 'matrix.md'), md.join('\n') + '\n')
1834
- report.push({ page: page.name, rows, dir })
2071
+ return c
1835
2072
  }
1836
- await browser.close()
1837
- if (server) try { process.kill(-server.pid) } catch {}
1838
2073
 
1839
- for (const { page, rows, dir } of report) {
1840
- const flagged = rows.filter(r => r.flags.length)
1841
- console.log(`${page}: ${rows.length} state(s), ${flagged.length} flagged — ${join(dir, 'matrix.md')}`)
1842
- for (const r of flagged) console.log(` ${r.endpoint} · ${r.state}: ${r.flags.join('; ')}`)
2074
+ const open = page => [
2075
+ `- launchApp:\n stopApp: true\n clearState: ${m.clearState === true}`,
2076
+ ...(m.login ? [`- runFlow: ${abs(m.login)}`] : []),
2077
+ ...(page.link ? [`- openLink: ${page.link}`] : page.flow ? [`- runFlow: ${abs(page.flow)}`] : []),
2078
+ ]
2079
+ const visit = async (page, { override, refetchFail, happy } = {}) => {
2080
+ sc = { override, refetchFail, failing: false, refetched: 0, afterKeys: new Set(), hits: 0, lastHit: 0, seen: new Map(), unmocked: new Set() }
2081
+ glimpsed = new Set()
2082
+ // a query with no loader retrying a 5xx is not done: 6s with no new hit is
2083
+ const retrying = () => Date.now() - sc.lastHit < 6000
2084
+ const done = (c, extra = {}) => ({ ...c, seen: sc.seen, unmocked: sc.unmocked, errors: [], refetched: sc.refetched, afterKeys: sc.afterKeys, glimpsed, ...extra,
2085
+ shot: async path => { await run([`- takeScreenshot: ${path.replace(/\.png$/, '')}`]) },
2086
+ // the held request is let go only after the shot, or the shot is of what came next
2087
+ close: async () => { for (const res of held.splice(0)) res.destroy() } })
2088
+ // no extendedWaitUntil: older Maestro rejects its `optional`, and a failed wait
2089
+ // reads like a failed launch. `ready` is polled from the hierarchy instead
2090
+ const opened = await run(open(page))
2091
+ if (opened) return done({ text: '', busy: false, retry: 0, ok: false }, { flowError: opened })
2092
+ let c, ready = true, stuck = false
2093
+ if (happy && page.ready) {
2094
+ c = await settle(15000, x => !x.text.includes(page.ready))
2095
+ ready = c.text.includes(page.ready)
2096
+ if (ready) c = await settle(15000, x => x.busy)
2097
+ } else if (override?.state === 'pending') {
2098
+ await sleep(cfg.pendingWaitMs ?? 3000); c = await capture()
2099
+ // the loader is right at first; still up long after, with the request never answered, is a hang
2100
+ if (c.busy && cfg.pendingStuckMs !== 0) { await sleep(cfg.pendingStuckMs ?? 30000); stuck = (await capture()).busy }
2101
+ } else c = await settle(override ? cfg.errorWaitMs ?? 30000 : 15000, x => x.busy || (/^5/.test(override?.state) && retrying()), override ? cfg.transientMs ?? 4000 : 0)
2102
+ if (refetchFail) {
2103
+ sc.failing = true
2104
+ const bg = await run(['- pressKey: Home'])
2105
+ await sleep(cfg.refetchAfterMs ?? 0)
2106
+ // back to the foreground: React Query hears it only through focusManager wired to AppState
2107
+ const fg = bg ?? await run(['- launchApp:\n stopApp: false'])
2108
+ if (bg || fg) return done(c, { flowError: bg ?? fg })
2109
+ c = await settle(cfg.refetchWaitMs ?? 30000, x => !sc.refetched || x.busy || retrying())
2110
+ }
2111
+ if (!c.ok) return done(c, { flowError: `no hierarchy from maestro — its output is not JSON (${MAESTRO} hierarchy)` })
2112
+ return done(c, { ready, stuck })
2113
+ }
2114
+ return {
2115
+ visit, errorWaitMs: 30000,
2116
+ notRefetched: `not refetched on returning to the foreground ${Math.round((cfg.refetchAfterMs ?? 0) / 1000)}s on — React Query on RN refetches then only with focusManager wired to AppState, and only a stale query (staleTime; "refetchAfterMs" past it)`,
2117
+ done: async () => { for (const res of held.splice(0)) res.destroy(); await new Promise(r => server.close(r)) },
1843
2118
  }
1844
- console.log('\nflags are leads, not findings: open the shot, and confirm against the spec what the page should say')
1845
2119
  }
1846
-
1847
2120
  // ── review-collect: the lenses' own record, from Claude Code's transcripts ──
1848
2121
  // Claude Code keeps every subagent's transcript under ~/.claude/projects/<cwd,
1849
2122
  // non-alphanumerics as '-'>/<session>/subagents/agent-<id>.jsonl, its type in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wei-wuwei",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "為無為,則無不治 — a source-of-truth skills & agents framework that compiles to Claude Code and Codex (Cursor and Gemini CLI planned). Write once, run in every agent, govern without forcing.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -173,7 +173,8 @@ tags: [planning, workflow]
173
173
  red blocks that phase)
174
174
  or manual UAT (steps + expected result). **A repo with no e2e harness is
175
175
  not a reason for UAT**: the first phase with a bound item adds one
176
- (Playwright, which `wuwei verify` runs on too) as its first task.
176
+ (Playwright on web, Maestro on mobile — what `wuwei verify` runs on) as
177
+ its first task.
177
178
  **`## Verify`** lists each page that shows fetched data — `- <page>:
178
179
  <route> · phase: NN`, the phase that builds it; its error, 403, empty,
179
180
  loading and failed-refetch states are `wuwei verify`'s, not UAT's. No