wei-wuwei 0.2.0 → 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 +12 -0
- package/README.md +13 -4
- package/bin/wuwei.js +444 -84
- package/package.json +1 -1
- package/skills/wuwei-plan-phases/skill.md +2 -1
- package/skills/wuwei-review/references/prep.md +1 -1
- package/skills/wuwei-review/skill.md +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ 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
|
+
|
|
11
|
+
## 0.2.1
|
|
12
|
+
|
|
13
|
+
- `wuwei review-collect`: finds the latest Claude Code session that
|
|
14
|
+
dispatched a wuwei lens, copies each lens's transcript and report into
|
|
15
|
+
the review bundle's `reports/`, and prints what each one spent — turns,
|
|
16
|
+
context built, cached re-reads, and where the reading went. wuwei-review
|
|
17
|
+
§7 uses it instead of the collator re-typing each report.
|
|
18
|
+
|
|
7
19
|
## 0.2.0
|
|
8
20
|
|
|
9
21
|
Execute runs at a fixed tier; the model policy now covers review only.
|
package/README.md
CHANGED
|
@@ -67,12 +67,13 @@ node bin/wuwei.js status <project> # read-only: streams/claims from .wuwei, dr
|
|
|
67
67
|
node bin/wuwei.js update # git pull if behind, diff against what install wrote, apply on yes
|
|
68
68
|
node bin/wuwei.js review-prep <base> # wuwei-review's prep: dispatcher checks + scan bundle (or --pr <n>)
|
|
69
69
|
node bin/wuwei.js claim [stream] [NN] # list eligible phases, or claim one: cut, claim, push
|
|
70
|
+
node bin/wuwei.js review-collect # each lens's transcript + report into the bundle, and what each one spent
|
|
70
71
|
node bin/wuwei.js digest-check <f> # a review digest's arithmetic before it is published
|
|
71
72
|
node bin/wuwei.js plan-check [stream] # plan-phases step 11's checkable half, before sign-off
|
|
72
73
|
node bin/wuwei.js worktree <lens> <sha> # a throwaway worktree no other lens shares (--prune)
|
|
73
74
|
node bin/wuwei.js mutate <f>:<n> --from … --to … --run … --in <wt> # one mutation: landed, baseline, red count, restored
|
|
74
75
|
node bin/wuwei.js sites <pattern> [--symbol <name>] # a set derived from the tree, graph and grep both
|
|
75
|
-
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
|
|
76
77
|
node bin/wuwei.js uninstall # removes exactly what install wrote, nothing else
|
|
77
78
|
```
|
|
78
79
|
|
|
@@ -92,8 +93,16 @@ npm in the background, and when a newer version is out it says so on stderr
|
|
|
92
93
|
(at most hourly; never from a clone or in CI; `WUWEI_NO_UPDATE_CHECK=1` turns
|
|
93
94
|
it off).
|
|
94
95
|
|
|
95
|
-
Releasing
|
|
96
|
-
`
|
|
96
|
+
Releasing is merging. Every merge to main that changes what the package ships
|
|
97
|
+
(`bin`, `skills`, `agents`, `templates`, `wuwei.config.json`) is released by
|
|
98
|
+
`.github/workflows/publish.yml`: the version is bumped by the merged commits'
|
|
99
|
+
Conventional Commit types — `feat` minor, `type!:` or a `BREAKING CHANGE:`
|
|
100
|
+
footer major (minor while under 1.0), anything else patch — `CHANGELOG.md`
|
|
101
|
+
gains a section from the merged PR titles, and the release commit, the
|
|
102
|
+
`v<version>` tag, the npm publish and the GitHub Release follow. A docs- or
|
|
103
|
+
tests-only merge releases nothing; *Run workflow* (`workflow_dispatch`) with a
|
|
104
|
+
bump releases now. A version already set in `package.json` that npm has not
|
|
105
|
+
seen goes out as is.
|
|
97
106
|
|
|
98
107
|
## Daily use
|
|
99
108
|
|
|
@@ -189,7 +198,7 @@ wuwei/
|
|
|
189
198
|
├── agents/ # canonical subagent definitions
|
|
190
199
|
│ └── _template/
|
|
191
200
|
├── templates/ # PR / review templates, installed per-repo
|
|
192
|
-
├── bin/wuwei.js # CLI: build · check · list · init · install · update · status · review-prep · claim · digest-check · plan-check · worktree · mutate · sites · verify · uninstall
|
|
201
|
+
├── bin/wuwei.js # CLI: build · check · list · init · install · update · status · review-prep · review-collect · claim · digest-check · plan-check · worktree · mutate · sites · verify · uninstall
|
|
193
202
|
│ # the per-tool emitters live here too, not in adapters/
|
|
194
203
|
├── adapters/ # notes only — see bin/wuwei.js for the actual emitters
|
|
195
204
|
├── docs/
|
package/bin/wuwei.js
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* wuwei claim [<stream>] [<phase>] [--take-over] [--by <id>] [--dry]
|
|
23
23
|
* wuwei-execute-phase step 1: list what is eligible,
|
|
24
24
|
* or claim one — cut, claim, push
|
|
25
|
+
* wuwei review-collect [--session <id>] each subagent's transcript and report into
|
|
26
|
+
* the bundle's reports/, and what each one spent
|
|
25
27
|
* wuwei digest-check <file> wuwei-review §7's arithmetic before publishing: count,
|
|
26
28
|
* rows, credits, file:line, Action names, redaction
|
|
27
29
|
* wuwei worktree <lens> [<sha>] a throwaway worktree no other lens shares; --prune
|
|
@@ -29,9 +31,10 @@
|
|
|
29
31
|
* baseline vs mutated, failing tests, restored, recorded
|
|
30
32
|
* wuwei sites <pattern> [--symbol <name>] a set from the tree, with its derivation;
|
|
31
33
|
* graph and grep both for a symbol
|
|
32
|
-
* wuwei verify [<page>…] [--phase NN] every data-bearing page through its states
|
|
33
|
-
* (Playwright,
|
|
34
|
-
*
|
|
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
|
|
35
38
|
* wuwei plan-check [<stream>] wuwei-plan-phases step 11's checkable half
|
|
36
39
|
* wuwei uninstall [<path>] remove exactly what install wrote
|
|
37
40
|
* wuwei -v | --version print the installed version
|
|
@@ -46,6 +49,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
46
49
|
import { createInterface } from 'node:readline/promises'
|
|
47
50
|
import { spawnSync, spawn } from 'node:child_process'
|
|
48
51
|
import { createRequire } from 'node:module'
|
|
52
|
+
import { createServer } from 'node:http'
|
|
49
53
|
import { createHash } from 'node:crypto'
|
|
50
54
|
|
|
51
55
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -1613,6 +1617,9 @@ const VERIFY_STATES = ['500', '403', '404', 'empty', 'pending', 'refetch-500', '
|
|
|
1613
1617
|
const BUSY = '[aria-busy="true"],[role="progressbar"],[data-slot*="skeleton"],[data-loading="true"],.skeleton,[data-skeleton],[class*="skeleton"],.animate-pulse,.animate-spin'
|
|
1614
1618
|
const SAYS_ERROR = /error|failed|could not|couldn't|unable|try again|retry|錯誤|失敗|無法/i
|
|
1615
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`
|
|
1616
1623
|
const SAYS_FORBIDDEN = /permission|forbidden|not allowed|denied|access|authori[sz]|權限|無權/i
|
|
1617
1624
|
|
|
1618
1625
|
function loadPlaywright(cwd) {
|
|
@@ -1622,7 +1629,7 @@ function loadPlaywright(cwd) {
|
|
|
1622
1629
|
return null
|
|
1623
1630
|
}
|
|
1624
1631
|
|
|
1625
|
-
const globRe = g => new RegExp('^' + g.replace(/[
|
|
1632
|
+
const globRe = g => new RegExp('^' + g.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*') + '$')
|
|
1626
1633
|
|
|
1627
1634
|
function fixtureFor(fixtures, method, url) {
|
|
1628
1635
|
const u = new URL(url)
|
|
@@ -1676,13 +1683,28 @@ async function cmdVerify(argv) {
|
|
|
1676
1683
|
"pages": [ { "name": "items", "path": "/items", "ready": "text=Items" } ],
|
|
1677
1684
|
"refetch": "focus", "refetchAfterMs": 600000, "refetchWaitMs": 30000,
|
|
1678
1685
|
"busy": "[data-slot=skeleton]", // optional: your loader, beyond the usual ones
|
|
1679
|
-
"errorWaitMs": 60000
|
|
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
|
|
1680
1699
|
}`)
|
|
1681
1700
|
process.exit(1)
|
|
1682
1701
|
}
|
|
1683
1702
|
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'))
|
|
1684
1703
|
const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
|
|
1685
|
-
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
|
|
1686
1708
|
// --phase NN: the pages ACCEPTANCE.md binds to that phase — the contract, not memory
|
|
1687
1709
|
if (flag('--phase')) {
|
|
1688
1710
|
const sdir = join(dest, '.wuwei', 'streams')
|
|
@@ -1697,7 +1719,151 @@ async function cmdVerify(argv) {
|
|
|
1697
1719
|
only.push(...want)
|
|
1698
1720
|
}
|
|
1699
1721
|
const missing = only.filter(n => !(cfg.pages ?? []).some(pg => pg.name === n))
|
|
1700
|
-
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) {
|
|
1701
1867
|
const pw = loadPlaywright(dest)
|
|
1702
1868
|
if (!pw) { console.error('✗ Playwright not found in this project or globally — npm i -D playwright'); process.exit(1) }
|
|
1703
1869
|
let server = null
|
|
@@ -1706,12 +1872,9 @@ async function cmdVerify(argv) {
|
|
|
1706
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) }
|
|
1707
1873
|
} else if (!(await waitForUrl(cfg.url, 5000))) { console.error(`✗ ${cfg.url} is not reachable — start the app, or set "start"`); process.exit(1) }
|
|
1708
1874
|
|
|
1709
|
-
const sha = quiet('git', ['rev-parse', 'HEAD']).stdout?.trim().slice(0, 12) || 'nogit'
|
|
1710
|
-
const outDir = join(dest, '.wuwei-cache', 'verify', sha)
|
|
1711
1875
|
const browser = await pw.chromium.launch()
|
|
1712
1876
|
const apiRes = [].concat(cfg.api ?? ['**/api/**']).map(globRe)
|
|
1713
1877
|
const isApi = url => apiRes.some(re => re.test(url) || re.test(new URL(url).pathname))
|
|
1714
|
-
const report = []
|
|
1715
1878
|
|
|
1716
1879
|
// one visit: routes every API call to its fixture, except the endpoint under test
|
|
1717
1880
|
const visit = async (page, { override, refetchFail, viewport } = {}) => {
|
|
@@ -1724,19 +1887,21 @@ async function cmdVerify(argv) {
|
|
|
1724
1887
|
}, cfg.auth)
|
|
1725
1888
|
const p = await ctx.newPage()
|
|
1726
1889
|
const seen = new Map(), unmocked = new Set(), errors = []
|
|
1727
|
-
let failing = false, refetched = 0
|
|
1890
|
+
let failing = false, refetched = 0, hits = 0
|
|
1891
|
+
const afterKeys = new Set(), glimpsed = new Set()
|
|
1728
1892
|
p.on('pageerror', e => errors.push(String(e.message ?? e).split('\n')[0]))
|
|
1729
1893
|
await p.route('**/*', async route => {
|
|
1730
1894
|
const req = route.request(), url = req.url()
|
|
1731
1895
|
if (!isApi(url)) return route.continue()
|
|
1732
1896
|
const fx = fixtureFor(cfg.fixtures, req.method(), url)
|
|
1733
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)
|
|
1734
1899
|
seen.set(fx.key, fx)
|
|
1735
1900
|
const hit = override && fx.key === override.key
|
|
1736
1901
|
if ((hit && override.state === 'pending')) return // never answered: the loading state
|
|
1737
|
-
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}` }) })
|
|
1738
1903
|
if (hit && override.state === 'empty') return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(emptied(fx.json)) })
|
|
1739
|
-
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}` }) })
|
|
1740
1905
|
return route.fulfill({ status: fx.status, contentType: 'application/json', body: JSON.stringify(fx.json) })
|
|
1741
1906
|
})
|
|
1742
1907
|
// the page's clock is ours: a fresh cache never refetches on focus (React Query's
|
|
@@ -1745,20 +1910,32 @@ async function cmdVerify(argv) {
|
|
|
1745
1910
|
await p.clock.install()
|
|
1746
1911
|
const busyNow = () => p.evaluate(sel => Boolean(document.querySelector(sel)) || /\bloading\b|載入中/i.test(document.body.innerText),
|
|
1747
1912
|
[BUSY, cfg.busy].filter(Boolean).join(',')).catch(() => false)
|
|
1748
|
-
|
|
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) => {
|
|
1749
1920
|
for (let t = 0; t < ms; t += 1000) {
|
|
1750
|
-
if (until && !(await until())) return
|
|
1921
|
+
if (t >= min && until && !(await until())) return
|
|
1751
1922
|
await p.clock.runFor(1000)
|
|
1752
1923
|
// real time for the fetch a timer just fired: networkidle, once reached, never waits again
|
|
1753
1924
|
await p.waitForTimeout(100)
|
|
1925
|
+
await glimpse()
|
|
1754
1926
|
}
|
|
1755
1927
|
}
|
|
1756
1928
|
await p.goto(new URL(page.path, cfg.url).href, { waitUntil: 'domcontentloaded' })
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
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)
|
|
1760
1937
|
await p.waitForLoadState('networkidle').catch(() => {})
|
|
1761
|
-
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)
|
|
1762
1939
|
}
|
|
1763
1940
|
if (refetchFail) {
|
|
1764
1941
|
failing = true
|
|
@@ -1768,78 +1945,260 @@ async function cmdVerify(argv) {
|
|
|
1768
1945
|
else await p.locator(cfg.refetch).first().click().catch(() => {})
|
|
1769
1946
|
await settle(cfg.refetchWaitMs ?? 30000)
|
|
1770
1947
|
}
|
|
1771
|
-
|
|
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')
|
|
1772
1952
|
const busy = await p.evaluate(sel => Boolean(document.querySelector(sel)), [BUSY, cfg.busy].filter(Boolean).join(','))
|
|
1773
1953
|
const retry = await p.getByRole('button', { name: /retry|try again|重試/i }).count()
|
|
1774
|
-
return {
|
|
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() }
|
|
1775
1956
|
}
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
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]
|
|
1788
2056
|
}
|
|
1789
|
-
const
|
|
1790
|
-
const
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
await v.ctx.close()
|
|
1803
|
-
const vLines = lines(v.text)
|
|
1804
|
-
// judged on what the state added to the screen: a nav item saying "Access" is not an answer
|
|
1805
|
-
const said = [...vLines].filter(l => !happyLines.has(l)).join('\n')
|
|
1806
|
-
const loading = v.busy || /loading|載入/i.test(said)
|
|
1807
|
-
const flags = v.errors.map(e => `page error: ${e}`)
|
|
1808
|
-
if (/^5/.test(state) && loading) flags.push(`still loading ${(cfg.errorWaitMs ?? 60000) / 1000}s on — client retries outlast the wait; raise "errorWaitMs"`)
|
|
1809
|
-
else if (/^5/.test(state) && !SAYS_ERROR.test(said)) flags.push('no error shown')
|
|
1810
|
-
if (state === '403' && !SAYS_FORBIDDEN.test(said)) flags.push('403 not told as a permission problem')
|
|
1811
|
-
if (state === '404' && v.retry) flags.push('Retry offered on a 404 — it can never succeed')
|
|
1812
|
-
if (state === '403' && v.retry) flags.push('Retry offered on a 403 — it can never succeed')
|
|
1813
|
-
if (state === 'pending' && !v.busy && !/loading|載入/i.test(v.text)) flags.push('no loading indicator')
|
|
1814
|
-
// a refetch that never happened says nothing about how a failed one looks;
|
|
1815
|
-
// what it did say is the lines it added — a relative time ticking is not an error
|
|
1816
|
-
const gone = [...happyLines].filter(l => !vLines.has(l) && !TICKS.test(l))
|
|
1817
|
-
const told = SAYS_ERROR.test(said) || (state === 'refetch-403' && SAYS_FORBIDDEN.test(said))
|
|
1818
|
-
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"`)
|
|
1819
|
-
else if (refetch && !told) flags.push('silent: the failed refetch shows nothing')
|
|
1820
|
-
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)})`)
|
|
1821
|
-
if (state === 'refetch-403' && v.refetched && told && !SAYS_FORBIDDEN.test(said)) flags.push('403 on refetch not told as a permission problem')
|
|
1822
|
-
rows.push({ endpoint: key, state, flags, shot: `${slug}.png`, text: v.text, said })
|
|
1823
|
-
}
|
|
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)
|
|
1824
2070
|
}
|
|
1825
|
-
|
|
1826
|
-
for (const e of rows.filter(r => r.state === 'empty' && r.said)) for (const r of rows)
|
|
1827
|
-
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)}")`)
|
|
1828
|
-
const md = [`# verify ${page.name} @ ${sha}`, '', `page: \`${page.path}\` · endpoints: ${endpoints.length} · states: ${VERIFY_STATES.join(', ')}`, '',
|
|
1829
|
-
'| endpoint | state | flags | on screen, beyond happy | shot |', '|---|---|---|---|---|',
|
|
1830
|
-
...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} |`)]
|
|
1831
|
-
writeFileSync(join(dir, 'matrix.md'), md.join('\n') + '\n')
|
|
1832
|
-
report.push({ page: page.name, rows, dir })
|
|
2071
|
+
return c
|
|
1833
2072
|
}
|
|
1834
|
-
await browser.close()
|
|
1835
|
-
if (server) try { process.kill(-server.pid) } catch {}
|
|
1836
2073
|
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
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 })
|
|
1841
2113
|
}
|
|
1842
|
-
|
|
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)) },
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
// ── review-collect: the lenses' own record, from Claude Code's transcripts ──
|
|
2121
|
+
// Claude Code keeps every subagent's transcript under ~/.claude/projects/<cwd,
|
|
2122
|
+
// non-alphanumerics as '-'>/<session>/subagents/agent-<id>.jsonl, its type in
|
|
2123
|
+
// the .meta.json beside it. §7 used to have the collator re-type each report
|
|
2124
|
+
// into the bundle — skipped in practice, and paid for in tokens when not. This
|
|
2125
|
+
// copies them, and reads what each one spent
|
|
2126
|
+
function transcriptStats(file) {
|
|
2127
|
+
const usage = new Map(), uses = new Map(), reads = new Map()
|
|
2128
|
+
const t = { turns: 0, built: 0, reread: 0, input: 0, output: 0, first: null, last: null, report: '' }
|
|
2129
|
+
const kind = (name, inp = {}) => name === 'Read' ? (/\.wuwei-cache/.test(inp.file_path ?? '') ? 'Read:bundle' : 'Read:tree')
|
|
2130
|
+
: name === 'Bash' ? (/codegraph/.test(inp.command ?? '') ? 'Bash:codegraph' : /\b(git )?grep\b|\brg\b/.test(inp.command ?? '') ? 'Bash:grep' : 'Bash:other') : name
|
|
2131
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
2132
|
+
let d; try { d = JSON.parse(line) } catch { continue }
|
|
2133
|
+
if (d.timestamp) { t.first ??= d.timestamp; t.last = d.timestamp }
|
|
2134
|
+
const m = d.message
|
|
2135
|
+
if (!m || typeof m !== 'object') continue
|
|
2136
|
+
// one message arrives as several lines while it streams: its last usage is its total
|
|
2137
|
+
if (d.type === 'assistant' && m.usage) usage.set(m.id, m.usage)
|
|
2138
|
+
for (const x of Array.isArray(m.content) ? m.content : []) {
|
|
2139
|
+
if (x.type === 'tool_use') {
|
|
2140
|
+
uses.set(x.id, kind(x.name, x.input))
|
|
2141
|
+
if (x.name === 'SubagentHandback' && x.input?.message) t.report = x.input.message
|
|
2142
|
+
} else if (x.type === 'tool_result') {
|
|
2143
|
+
const k = uses.get(x.tool_use_id) ?? '?', r = reads.get(k) ?? { n: 0, tokens: 0 }
|
|
2144
|
+
r.n++; r.tokens += Math.round(JSON.stringify(x.content ?? '').length / 4); reads.set(k, r)
|
|
2145
|
+
} else if (x.type === 'text' && d.type === 'assistant' && x.text?.trim()) t.lastText = x.text
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
for (const u of usage.values()) {
|
|
2149
|
+
t.turns++; t.built += u.cache_creation_input_tokens ?? 0; t.reread += u.cache_read_input_tokens ?? 0
|
|
2150
|
+
t.input += u.input_tokens ?? 0; t.output += u.output_tokens ?? 0
|
|
2151
|
+
}
|
|
2152
|
+
t.report ||= t.lastText ?? ''
|
|
2153
|
+
t.reads = [...reads].sort((a, b) => b[1].tokens - a[1].tokens)
|
|
2154
|
+
return t
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
function cmdReviewCollect(argv) {
|
|
2158
|
+
const flag = name => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1] }
|
|
2159
|
+
const projects = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'projects')
|
|
2160
|
+
const projDir = join(projects, process.cwd().replace(/[^A-Za-z0-9]/g, '-'))
|
|
2161
|
+
if (!existsSync(projDir)) { console.error(`✗ no Claude Code sessions recorded for ${process.cwd()} (${projDir}) — run it where the review ran (Codex has no subagent transcripts)`); process.exit(1) }
|
|
2162
|
+
const sessions = readdirSync(projDir, { withFileTypes: true }).filter(d => d.isDirectory() && existsSync(join(projDir, d.name, 'subagents')))
|
|
2163
|
+
.map(d => {
|
|
2164
|
+
const dir = join(projDir, d.name, 'subagents')
|
|
2165
|
+
const agents = readdirSync(dir).filter(f => f.endsWith('.meta.json')).map(f => {
|
|
2166
|
+
let meta = {}; try { meta = JSON.parse(readFileSync(join(dir, f), 'utf8')) } catch {}
|
|
2167
|
+
const jsonl = join(dir, f.replace(/\.meta\.json$/, '.jsonl'))
|
|
2168
|
+
return { meta, jsonl, mtime: existsSync(jsonl) ? statSync(jsonl).mtimeMs : 0 }
|
|
2169
|
+
}).filter(a => a.mtime)
|
|
2170
|
+
return { id: d.name, agents, wuwei: agents.some(a => /^wuwei-/.test(a.meta.agentType ?? '')), latest: Math.max(0, ...agents.map(a => a.mtime)) }
|
|
2171
|
+
})
|
|
2172
|
+
const session = flag('--session') ? sessions.find(s => s.id.startsWith(flag('--session')))
|
|
2173
|
+
: sessions.filter(s => s.wuwei).sort((a, b) => b.latest - a.latest)[0]
|
|
2174
|
+
if (!session) { console.error(`✗ ${flag('--session') ? `no session ${flag('--session')}` : 'no session here dispatched a wuwei lens'} under ${projDir}`); process.exit(1) }
|
|
2175
|
+
|
|
2176
|
+
const sha = flag('--sha') ?? quiet('git', ['rev-parse', '--verify', '--quiet', 'HEAD']).stdout?.trim().slice(0, 12) ?? 'nogit'
|
|
2177
|
+
const out = join(process.cwd(), '.wuwei-cache', 'review', sha, 'reports')
|
|
2178
|
+
mkdirSync(out, { recursive: true })
|
|
2179
|
+
const rows = [], used = new Map()
|
|
2180
|
+
for (const a of session.agents.map(a => ({ ...a, s: transcriptStats(a.jsonl) })).sort((x, y) => (x.s.first ?? '').localeCompare(y.s.first ?? ''))) {
|
|
2181
|
+
const type = a.meta.agentType ?? 'agent'
|
|
2182
|
+
const base = (type === 'general-purpose' && a.meta.description ? a.meta.description.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 30) : type).replace(/^wuwei-/, '')
|
|
2183
|
+
const n = (used.get(base) ?? 0) + 1; used.set(base, n)
|
|
2184
|
+
const name = n === 1 ? base : `${base}-${n}`
|
|
2185
|
+
writeFileSync(join(out, `${name}.jsonl`), readFileSync(a.jsonl))
|
|
2186
|
+
if (a.s.report) writeFileSync(join(out, `${name}.md`), a.s.report.trimEnd() + '\n')
|
|
2187
|
+
rows.push({ name, ...a.s })
|
|
2188
|
+
}
|
|
2189
|
+
const k = x => x >= 1e6 ? `${(x / 1e6).toFixed(1)}M` : x >= 1e3 ? `${Math.round(x / 1e3)}k` : String(x)
|
|
2190
|
+
const total = rows.reduce((t, r) => ({ built: t.built + r.built, reread: t.reread + r.reread }), { built: 0, reread: 0 })
|
|
2191
|
+
console.log(`session ${session.id} · ${rows.length} subagent(s) → ${out}\n`)
|
|
2192
|
+
// output tokens are left out: the transcript records a streaming chunk's count, not the message's
|
|
2193
|
+
console.log('| agent | turns | context built | re-read (cached) | where the reading went |\n|---|---|---|---|---|')
|
|
2194
|
+
for (const r of [...rows].sort((a, b) => (b.built + b.reread / 10) - (a.built + a.reread / 10)))
|
|
2195
|
+
console.log(`| ${r.name} | ${r.turns} | ${k(r.built)} | ${k(r.reread)} | ${r.reads.slice(0, 4).map(([kk, v]) => `${kk} ${v.n}× ≈${k(v.tokens)}`).join(' · ') || '—'} |`)
|
|
2196
|
+
console.log(`| **total** | | ${k(total.built)} | ${k(total.reread)} | |`)
|
|
2197
|
+
const byKind = new Map()
|
|
2198
|
+
for (const r of rows) for (const [kk, v] of r.reads) byKind.set(kk, (byKind.get(kk) ?? 0) + v.tokens)
|
|
2199
|
+
const top = [...byKind].sort((a, b) => b[1] - a[1]).slice(0, 3)
|
|
2200
|
+
if (top.length) console.log(`\nmost read, across the lenses: ${top.map(([kk, v]) => `${kk} ≈${k(v)}`).join(' · ')}`)
|
|
2201
|
+
console.log('re-read is billed at about a tenth of context built; a lens with many turns pays its context again on each')
|
|
1843
2202
|
}
|
|
1844
2203
|
|
|
1845
2204
|
// ── digest-check: wuwei-review §7's arithmetic, before publishing ─────
|
|
@@ -2818,6 +3177,7 @@ switch (cmd) {
|
|
|
2818
3177
|
case 'update': await cmdUpdate(dry, rest.includes('--no-fetch')); break
|
|
2819
3178
|
case 'status': cmdStatus(arg); break
|
|
2820
3179
|
case 'review-prep': cmdReviewPrep(rest); break
|
|
3180
|
+
case 'review-collect': cmdReviewCollect(rest); break
|
|
2821
3181
|
case 'verify': await cmdVerify(rest); break
|
|
2822
3182
|
case 'claim': cmdClaim(rest); break
|
|
2823
3183
|
case 'digest-check': cmdDigestCheck(rest); break
|
|
@@ -2827,6 +3187,6 @@ switch (cmd) {
|
|
|
2827
3187
|
case 'plan-check': cmdPlanCheck(rest); break
|
|
2828
3188
|
case 'uninstall': await cmdUninstall(arg); break
|
|
2829
3189
|
default:
|
|
2830
|
-
console.error(`unknown command '${cmd}'\nusage: wuwei [build|check|list|init [path]|install [path]|update|status [path]|review-prep [base] [--pr n] [--path p]|verify [page] [--phase NN]|claim [stream] [phase]|digest-check <digest.md>|worktree <lens> [sha]|mutate <file>:<line>|sites <pattern>|plan-check [stream]|uninstall [path]|--version] [--dry]`)
|
|
3190
|
+
console.error(`unknown command '${cmd}'\nusage: wuwei [build|check|list|init [path]|install [path]|update|status [path]|review-prep [base] [--pr n] [--path p]|review-collect|verify [page] [--phase NN]|claim [stream] [phase]|digest-check <digest.md>|worktree <lens> [sha]|mutate <file>:<line>|sites <pattern>|plan-check [stream]|uninstall [path]|--version] [--dry]`)
|
|
2831
3191
|
process.exit(1)
|
|
2832
3192
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wei-wuwei",
|
|
3
|
-
"version": "0.
|
|
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,
|
|
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
|
|
@@ -31,5 +31,5 @@ slices they need:
|
|
|
31
31
|
diff.patch git diff <base>...HEAD, three dots, verbatim
|
|
32
32
|
symbols.md definitions the diff references, from `codegraph node` — or "graph absent"
|
|
33
33
|
tools/<tool>.json each detected tool's read-only output over the change (§5), run once
|
|
34
|
-
reports/<lens>.md each lens's raw report
|
|
34
|
+
reports/<lens>.md each lens's raw report — `wuwei review-collect`, or written as returned
|
|
35
35
|
```
|
|
@@ -102,7 +102,7 @@ disk**; lenses pull the slices they need:
|
|
|
102
102
|
```
|
|
103
103
|
.wuwei-cache/review/<reviewed-sha>/ # gitignored — never inside the reviewed tree
|
|
104
104
|
PREP.md · INDEX.md (kind is a first pass by path — reclassify by content) · diff.patch
|
|
105
|
-
thread.json · tools/<tool>.json · reports/<lens>.md
|
|
105
|
+
thread.json · tools/<tool>.json · reports/<lens>.md + .jsonl (`wuwei review-collect`)
|
|
106
106
|
```
|
|
107
107
|
|
|
108
108
|
**No copies of the changed files** — each lens's context holds what it reads
|
|
@@ -346,9 +346,9 @@ finding closes.
|
|
|
346
346
|
|
|
347
347
|
## 7. Collate
|
|
348
348
|
|
|
349
|
-
The collator is the dispatching session.
|
|
350
|
-
|
|
351
|
-
|
|
349
|
+
The collator is the dispatching session. **`wuwei review-collect`** first
|
|
350
|
+
copies each lens's transcript and report into the bundle's `reports/` —
|
|
351
|
+
never re-typed (no subagents, as on Codex: write them yourself). Then one digest
|
|
352
352
|
against `.wuwei/templates/code-review.md`, opening with which
|
|
353
353
|
panels ran and why. **Whichever panels ran share one table** — code and
|
|
354
354
|
document on a mixed target, or the stream panel's five reporters plus §2 on
|