@wasao/kagemusha 0.1.1 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +168 -79
  2. package/dist/commands/add.d.ts +6 -0
  3. package/dist/commands/add.d.ts.map +1 -0
  4. package/dist/commands/add.js +26 -0
  5. package/dist/commands/add.js.map +1 -0
  6. package/dist/commands/capture.d.ts +3 -2
  7. package/dist/commands/capture.d.ts.map +1 -1
  8. package/dist/commands/capture.js +256 -20
  9. package/dist/commands/capture.js.map +1 -1
  10. package/dist/commands/discover.d.ts +2 -0
  11. package/dist/commands/discover.d.ts.map +1 -0
  12. package/dist/commands/discover.js +62 -0
  13. package/dist/commands/discover.js.map +1 -0
  14. package/dist/commands/edit.d.ts +1 -1
  15. package/dist/commands/edit.d.ts.map +1 -1
  16. package/dist/commands/edit.js +82 -26
  17. package/dist/commands/edit.js.map +1 -1
  18. package/dist/commands/init.d.ts +1 -1
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +240 -105
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/list.d.ts +2 -0
  23. package/dist/commands/list.d.ts.map +1 -0
  24. package/dist/commands/list.js +33 -0
  25. package/dist/commands/list.js.map +1 -0
  26. package/dist/commands/login.d.ts +6 -0
  27. package/dist/commands/login.d.ts.map +1 -0
  28. package/dist/commands/login.js +131 -0
  29. package/dist/commands/login.js.map +1 -0
  30. package/dist/commands/validate.js +1 -1
  31. package/dist/commands/validate.js.map +1 -1
  32. package/dist/editor/inject-script/annotations.d.ts +11 -0
  33. package/dist/editor/inject-script/annotations.d.ts.map +1 -0
  34. package/dist/editor/inject-script/annotations.js +409 -0
  35. package/dist/editor/inject-script/annotations.js.map +1 -0
  36. package/dist/editor/inject-script/bridge.d.ts +13 -0
  37. package/dist/editor/inject-script/bridge.d.ts.map +1 -0
  38. package/dist/editor/inject-script/bridge.js +33 -0
  39. package/dist/editor/inject-script/bridge.js.map +1 -0
  40. package/dist/editor/inject-script/crop.d.ts +9 -0
  41. package/dist/editor/inject-script/crop.d.ts.map +1 -0
  42. package/dist/editor/inject-script/crop.js +236 -0
  43. package/dist/editor/inject-script/crop.js.map +1 -0
  44. package/dist/editor/inject-script/dom.d.ts +7 -0
  45. package/dist/editor/inject-script/dom.d.ts.map +1 -0
  46. package/dist/editor/inject-script/dom.js +32 -0
  47. package/dist/editor/inject-script/dom.js.map +1 -0
  48. package/dist/editor/inject-script/index.d.ts +2 -0
  49. package/dist/editor/inject-script/index.d.ts.map +1 -0
  50. package/dist/editor/inject-script/index.js +56 -0
  51. package/dist/editor/inject-script/index.js.map +1 -0
  52. package/dist/editor/inject-script/record.d.ts +5 -0
  53. package/dist/editor/inject-script/record.d.ts.map +1 -0
  54. package/dist/editor/inject-script/record.js +398 -0
  55. package/dist/editor/inject-script/record.js.map +1 -0
  56. package/dist/editor/inject-script/selector.d.ts +6 -0
  57. package/dist/editor/inject-script/selector.d.ts.map +1 -0
  58. package/dist/editor/inject-script/selector.js +112 -0
  59. package/dist/editor/inject-script/selector.js.map +1 -0
  60. package/dist/editor/inject-script/state.d.ts +27 -0
  61. package/dist/editor/inject-script/state.d.ts.map +1 -0
  62. package/dist/editor/inject-script/state.js +26 -0
  63. package/dist/editor/inject-script/state.js.map +1 -0
  64. package/dist/editor/inject-script/svg.d.ts +7 -0
  65. package/dist/editor/inject-script/svg.d.ts.map +1 -0
  66. package/dist/editor/inject-script/svg.js +39 -0
  67. package/dist/editor/inject-script/svg.js.map +1 -0
  68. package/dist/editor/inject-script/toolbar.d.ts +14 -0
  69. package/dist/editor/inject-script/toolbar.d.ts.map +1 -0
  70. package/dist/editor/inject-script/toolbar.js +240 -0
  71. package/dist/editor/inject-script/toolbar.js.map +1 -0
  72. package/dist/editor/inject-script/types.d.ts +102 -0
  73. package/dist/editor/inject-script/types.d.ts.map +1 -0
  74. package/dist/editor/inject-script/types.js +5 -0
  75. package/dist/editor/inject-script/types.js.map +1 -0
  76. package/dist/editor/inject-script.js +1276 -353
  77. package/dist/index.js +34 -16
  78. package/dist/index.js.map +1 -1
  79. package/dist/lib/annotate.d.ts +2 -2
  80. package/dist/lib/annotate.d.ts.map +1 -1
  81. package/dist/lib/annotate.js +35 -43
  82. package/dist/lib/annotate.js.map +1 -1
  83. package/dist/lib/auth.d.ts +18 -0
  84. package/dist/lib/auth.d.ts.map +1 -0
  85. package/dist/lib/auth.js +45 -0
  86. package/dist/lib/auth.js.map +1 -0
  87. package/dist/lib/aws-error.d.ts +7 -0
  88. package/dist/lib/aws-error.d.ts.map +1 -0
  89. package/dist/lib/aws-error.js +74 -0
  90. package/dist/lib/aws-error.js.map +1 -0
  91. package/dist/lib/canonical.d.ts +54 -0
  92. package/dist/lib/canonical.d.ts.map +1 -0
  93. package/dist/lib/canonical.js +152 -0
  94. package/dist/lib/canonical.js.map +1 -0
  95. package/dist/lib/config.d.ts +2 -0
  96. package/dist/lib/config.d.ts.map +1 -1
  97. package/dist/lib/config.js +23 -13
  98. package/dist/lib/config.js.map +1 -1
  99. package/dist/lib/crawl.d.ts +1 -1
  100. package/dist/lib/crawl.d.ts.map +1 -1
  101. package/dist/lib/crawl.js +213 -20
  102. package/dist/lib/crawl.js.map +1 -1
  103. package/dist/lib/definition.d.ts +2 -0
  104. package/dist/lib/definition.d.ts.map +1 -0
  105. package/dist/lib/definition.js +6 -0
  106. package/dist/lib/definition.js.map +1 -0
  107. package/dist/lib/diff.d.ts +71 -0
  108. package/dist/lib/diff.d.ts.map +1 -0
  109. package/dist/lib/diff.js +40 -0
  110. package/dist/lib/diff.js.map +1 -0
  111. package/dist/lib/login-error.d.ts +10 -0
  112. package/dist/lib/login-error.d.ts.map +1 -0
  113. package/dist/lib/login-error.js +13 -0
  114. package/dist/lib/login-error.js.map +1 -0
  115. package/dist/lib/page-ready.d.ts +18 -0
  116. package/dist/lib/page-ready.d.ts.map +1 -0
  117. package/dist/lib/page-ready.js +19 -0
  118. package/dist/lib/page-ready.js.map +1 -0
  119. package/dist/lib/screenshot.d.ts +11 -2
  120. package/dist/lib/screenshot.d.ts.map +1 -1
  121. package/dist/lib/screenshot.js +73 -64
  122. package/dist/lib/screenshot.js.map +1 -1
  123. package/dist/lib/staging.d.ts +7 -0
  124. package/dist/lib/staging.d.ts.map +1 -0
  125. package/dist/lib/staging.js +21 -0
  126. package/dist/lib/staging.js.map +1 -0
  127. package/dist/types.d.ts +10 -23
  128. package/dist/types.d.ts.map +1 -1
  129. package/package.json +20 -3
  130. package/dist/commands/preview.d.ts +0 -6
  131. package/dist/commands/preview.d.ts.map +0 -1
  132. package/dist/commands/preview.js +0 -33
  133. package/dist/commands/preview.js.map +0 -1
  134. package/dist/commands/run.d.ts +0 -6
  135. package/dist/commands/run.d.ts.map +0 -1
  136. package/dist/commands/run.js +0 -39
  137. package/dist/commands/run.js.map +0 -1
  138. package/dist/editor/editor/editor.html +0 -313
  139. package/dist/editor/editor/inject.ts +0 -385
  140. package/dist/editor/editor.html +0 -338
  141. package/dist/editor/inject-script.cjs +0 -398
  142. package/dist/editor/inject-script.cjs.map +0 -1
  143. package/dist/editor/inject-script.d.cts +0 -2
  144. package/dist/editor/inject-script.d.cts.map +0 -1
  145. package/dist/editor/inject-script.d.ts +0 -2
  146. package/dist/editor/inject-script.d.ts.map +0 -1
  147. package/dist/editor/inject-script.js.map +0 -1
  148. package/dist/editor/inject.d.ts +0 -2
  149. package/dist/editor/inject.d.ts.map +0 -1
  150. package/dist/editor/inject.js +0 -385
  151. package/dist/editor/inject.js.map +0 -1
  152. package/dist/lib/upload.d.ts +0 -9
  153. package/dist/lib/upload.d.ts.map +0 -1
  154. package/dist/lib/upload.js +0 -43
  155. package/dist/lib/upload.js.map +0 -1
package/README.md CHANGED
@@ -5,123 +5,212 @@ The shadow warrior for your documentation.
5
5
 
6
6
  ## What it does
7
7
 
8
- When you push code, Kagemusha automatically captures screenshots of your app and uploads them to S3. No more manually taking screenshots and updating help articles.
8
+ `kagemusha capture` walks your app with Playwright, diffs each screenshot against the canonical version on S3, and pushes only what changed. Your help articles embed a stable URL once and the image refreshes itself on every merge to main.
9
9
 
10
- - **Auto-discover pages** — Crawls your app to find pages, select which ones to capture
11
- - **Playwright-powered** — Supports login, click actions, element selection, and full-page capture
12
- - **Annotations** — Add arrows, rectangles, and labels to screenshots
13
- - **S3 upload** — Screenshots are uploaded with stable URLs for embedding in help centers
14
- - **Local mode** — Save screenshots locally for review before uploading
15
- - **GitHub Actions ready** — Runs on every merge to main
10
+ - **One verb (`capture`)** — capture diff push, all in one command
11
+ - **S3-first** — `<id>/latest.png` is the canonical, embedded directly into help articles
12
+ - **Component-level capture** — Playwright-powered, full-page / crop, pre-capture actions, element hiding
13
+ - **Visual editor** — draw rectangles, arrows, labels; pick crop range by drag (`kagemusha edit`)
14
+ - **Login once** — store `storageState` locally, or run a scripted login on every CI run
15
+ - **Slack-ready** — `reports/summary.json` ships before/after URLs so notifications include image previews
16
16
 
17
17
  ## Quick Start
18
18
 
19
19
  ```bash
20
- # Install
21
20
  npm install -D @wasao/kagemusha
22
21
 
23
- # Interactive setup (generates config, discovers pages, creates workflow)
22
+ # Interactive setup: config login → discover pages workflow
24
23
  npx kagemusha init
25
24
 
26
- # Preview screenshots locally
27
- npx kagemusha preview
25
+ # Capture, diff vs canonical, publish what changed
26
+ npx kagemusha capture
28
27
 
29
- # Run full pipeline (capture upload)
30
- npx kagemusha run
28
+ # Preview only no canonical update
29
+ npx kagemusha capture --dry-run
31
30
  ```
32
31
 
33
- ## How it works
34
-
35
- 1. `npx kagemusha init` scans your app and lets you pick which pages to screenshot
36
- 2. Config and definition files are generated in your repo
37
- 3. On every merge to main, GitHub Actions runs `npx kagemusha run`
38
- 4. Screenshots are captured and uploaded to S3
39
- 5. Help center articles reference the S3 URLs — images stay up to date automatically
40
-
41
32
  ## Commands
42
33
 
43
34
  | Command | Description |
44
- |---------|-------------|
45
- | `kagemusha init` | Interactive setup |
46
- | `kagemusha run` | Full pipeline: capture + upload |
47
- | `kagemusha capture` | Capture screenshots only |
48
- | `kagemusha preview` | Preview in browser |
49
- | `kagemusha validate` | Validate config files |
50
- | `kagemusha compare` | VRT diff detection (coming soon) |
51
- | `kagemusha publish` | Publish to Intercom/Zendesk (coming soon) |
35
+ |---|---|
36
+ | `kagemusha init` | Interactive setup (config + login + discover + workflow) |
37
+ | `kagemusha login` | Refresh the saved login session (interactive or scripted) |
38
+ | `kagemusha discover` | Re-crawl the app and add newly-found pages to definitions |
39
+ | `kagemusha add <path>` | Add a single screenshot definition |
40
+ | `kagemusha list` | List all definitions |
41
+ | `kagemusha edit --id <id>` | Open the visual editor (crop range + annotations) |
42
+ | `kagemusha capture` | Capture diff → publish (use `--dry-run` to preview) |
43
+ | `kagemusha validate` | Validate config and definition files |
44
+
45
+ ## Configuration
46
+
47
+ `init` generates these files:
48
+
49
+ ```
50
+ kagemusha.config.yaml # base URL, viewport, publish destination
51
+ .kagemusha/definitions.json # one entry per screenshot
52
+ .kagemusha/login.mjs # optional scripted login (CI-friendly)
53
+ .github/workflows/kagemusha.yml
54
+ ```
55
+
56
+ `kagemusha.config.yaml`:
57
+
58
+ ```yaml
59
+ app:
60
+ baseUrl: https://your-app.example.com
61
+ screenshot:
62
+ defaultViewport: { width: 1440, height: 900, deviceScaleFactor: 2 }
63
+ defaultDiffThreshold: 0.005 # 0.5% pixel diff = flagged
64
+ publish:
65
+ destination: s3 # or "local" for testing
66
+ cdnBucket: your-bucket
67
+ cdnBaseUrl: https://your-bucket.s3.ap-northeast-1.amazonaws.com
68
+ ```
69
+
70
+ Definition (`.kagemusha/definitions.json`):
71
+
72
+ ```json
73
+ [
74
+ {
75
+ "id": "dashboard",
76
+ "url": "/dashboard",
77
+ "capture": { "mode": "fullPage" },
78
+ "hideElements": [".intercom-launcher"],
79
+ "decorations": [
80
+ { "type": "rect", "target": { "x": 32, "y": 120, "width": 310, "height": 120 } }
81
+ ]
82
+ }
83
+ ]
84
+ ```
52
85
 
53
- ## Config
86
+ Run `kagemusha edit --id dashboard` to set the crop range and add decorations visually.
54
87
 
55
- `kagemusha init` generates these files:
88
+ ## Avoiding loading-state screenshots
56
89
 
90
+ After `page.goto` kagemusha waits for `load` event + 3s of best-effort `networkidle` + 500ms hydration buffer. This handles most pages; SPAs with component-level skeletons may still capture mid-loading. Add a `beforeCapture` step per definition:
91
+
92
+ ```json
93
+ {
94
+ "id": "analytics-overview",
95
+ "url": "/analytics/overview",
96
+ "beforeCapture": [
97
+ { "action": "waitForSelector", "selector": "text=Overview", "timeout": 15000 },
98
+ { "action": "wait", "ms": 3000 }
99
+ ]
100
+ }
57
101
  ```
58
- kagemusha.config.yaml # App URL, auth, save destination
59
- .kagemusha/definitions/*.json # One file per screenshot
102
+
103
+ Wait for a known page-specific element (page title, chart canvas, first table row, etc.), then a short buffer. Playwright's `text=` selector matches any rendered text.
104
+
105
+ ## Authentication
106
+
107
+ If your app needs login, `init` generates a `.kagemusha/login.mjs` skeleton:
108
+
109
+ ```js
110
+ /** @param {import('playwright-chromium').Page} page */
111
+ export const login = async (page) => {
112
+ await page.goto("/login");
113
+ // Pick env names that fit your project (NOT EMAIL/PASSWORD, NOT KAGEMUSHA_*).
114
+ await page.fill('input[name="email"]', process.env.MY_APP_EMAIL ?? "");
115
+ await page.fill('input[name="password"]', process.env.MY_APP_PASSWORD ?? "");
116
+ await page.click('button[type="submit"]');
117
+ await page.waitForURL((url) => !url.pathname.startsWith("/login"));
118
+ };
60
119
  ```
61
120
 
62
- ### Screenshot definition example
121
+ `kagemusha capture` auto-runs this on first invocation when no saved session exists, so CI just needs `npx kagemusha capture` — no separate login step. `baseURL` is set from your config, so relative paths work.
122
+
123
+ For SSO / MFA / OAuth where scripting is impossible: run `kagemusha login` locally to save `.kagemusha/auth-state.json`, then `base64 -i .kagemusha/auth-state.json | pbcopy` and store the result as a `KAGEMUSHA_STORAGE_STATE` GitHub Secret. The generated workflow shows the restore step (commented out).
124
+
125
+ ## Deploying to GitHub Actions
126
+
127
+ `init` generates `.github/workflows/kagemusha.yml`. Required secrets:
128
+
129
+ - `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — IAM with `s3:GetObject` and `s3:PutObject` on your bucket, or use OIDC with `aws-actions/configure-aws-credentials@v4`
130
+ - Login credentials (named whatever your `login.mjs` reads)
131
+ - `SLACK_WEBHOOK_URL` — optional, see Notifications below
132
+
133
+ Region is auto-detected from `publish.cdnBaseUrl` (`*.s3.<region>.amazonaws.com`), so no `AWS_REGION` env needed.
134
+
135
+ The workflow triggers on `push: main` and runs `kagemusha capture` automatically.
136
+
137
+ ## Notifications
138
+
139
+ `kagemusha capture` writes `reports/summary.json` with before/after URLs (when destination is S3 and a real push happened):
63
140
 
64
141
  ```json
65
142
  {
66
- "id": "dashboard",
67
- "name": "dashboard",
68
- "url": "/dashboard",
69
- "capture": { "mode": "fullPage" },
70
- "hideElements": [".intercom-launcher"],
71
- "decorations": [
143
+ "schemaVersion": "1",
144
+ "timestamp": "2026-05-15T12:34:56.789Z",
145
+ "dryRun": false,
146
+ "canonical": "https://your-bucket.s3.ap-northeast-1.amazonaws.com",
147
+ "counts": { "changed": 1, "unchanged": 5, "new": 2, "missing": 0 },
148
+ "results": [
72
149
  {
73
- "type": "rect",
74
- "target": { "x": 32, "y": 120, "width": 310, "height": 120 },
75
- "style": { "color": "#FF0000", "strokeWidth": 2 }
76
- }
150
+ "id": "engagements-overview",
151
+ "status": "changed",
152
+ "reason": "pixel-diff",
153
+ "diffPercentage": 2.34,
154
+ "urls": {
155
+ "before": "https://.../engagements-overview/previous.png",
156
+ "after": "https://.../engagements-overview/latest.png"
157
+ }
158
+ },
159
+ { "id": "new-page", "status": "new", "urls": { "after": "https://.../new-page/latest.png" } },
160
+ { "id": "dashboard", "status": "unchanged" }
77
161
  ]
78
162
  }
79
163
  ```
80
164
 
81
- ## GitHub Actions
165
+ The schema is **part of kagemusha's public API** — additive changes stay on `schemaVersion: "1"`, removals/renames bump it. kagemusha intentionally does not publish a pre-rendered diff image; consumers compare `before` vs `after` raw images (= Slack auto-unfurls both side by side).
166
+
167
+ Slack notification (the generated workflow includes this; just set `SLACK_WEBHOOK_URL`):
82
168
 
83
169
  ```yaml
84
- name: Kagemusha
85
- on:
86
- pull_request:
87
- types: [closed]
88
- branches: [main]
89
-
90
- jobs:
91
- screenshots:
92
- if: github.event.pull_request.merged == true
93
- runs-on: ubuntu-latest
94
- steps:
95
- - uses: actions/checkout@v4
96
- - uses: actions/setup-node@v4
97
- with:
98
- node-version: 20
99
- - run: npm ci
100
- - run: npx kagemusha run
101
- env:
102
- AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
103
- AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
170
+ - name: Slack notify
171
+ env:
172
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
173
+ run: |
174
+ [ -n "$SLACK_WEBHOOK_URL" ] || exit 0
175
+ jq -c -f .kagemusha/notify-slack.jq reports/summary.json | while IFS= read -r payload; do
176
+ [ -z "$payload" ] && continue
177
+ curl -sS -X POST "$SLACK_WEBHOOK_URL" \
178
+ -H 'Content-Type: application/json' \
179
+ --data "$payload"
180
+ done
104
181
  ```
105
182
 
106
- ## Try it locally
183
+ One message per changed/new screenshot — Slack unfurls the `before`/`after` URLs per-message, so each post shows its own image previews instead of a single message that just lists URLs as plain text.
184
+
185
+ The message format is defined in `.kagemusha/notify-slack.jq` (generated by `kagemusha init`). Each line of jq output becomes one Slack `chat.postMessage` body, so you can customize freely (add `blocks`, override `channel`, etc). Test locally:
107
186
 
108
187
  ```bash
109
- cd example
110
- bun install
111
- bun run serve # Start sample app
112
- bunx kagemusha init # Set up kagemusha
113
- bunx kagemusha preview # See screenshots
188
+ jq -c -f .kagemusha/notify-slack.jq reports/summary.json
114
189
  ```
115
190
 
116
- ## Roadmap
191
+ The same `summary.json` works for Discord (swap `text` → `content`) or PR comments via `actions/github-script` — copy the jq file and tweak.
192
+
193
+ ## Positioning
194
+
195
+ **kagemusha is NOT a PR-gating VRT tool.** For per-commit baselines and PR diff review with hosted HTML reports, use [reg-suit](https://github.com/reg-viz/reg-suit), [Chromatic](https://www.chromatic.com/), or [Percy](https://percy.io/).
196
+
197
+ kagemusha is for **post-merge auto-update of help center screenshots**, where stable embeddable URLs matter more than per-commit baseline correctness.
198
+
199
+ ## Releasing
200
+
201
+ Automated via [release-please](https://github.com/googleapis/release-please).
202
+
203
+ 1. **Write PRs with [Conventional Commits](https://www.conventionalcommits.org/) titles**:
204
+ - `feat: ...` → minor bump (= 0.2.0 → 0.3.0)
205
+ - `fix: ...` → patch bump (= 0.2.0 → 0.2.1)
206
+ - `feat!: ...` or `BREAKING CHANGE:` in body → major (= 1.0.0+)
207
+ - `chore: ...` / `docs: ...` → no version bump
208
+ - Squash merge propagates the PR title as the commit on `main`.
209
+ 2. **release-please opens a "Release PR" automatically** whenever there's anything to release. It bumps `package.json`, updates `.release-please-manifest.json`, and regenerates `CHANGELOG.md`.
210
+ 3. **Merge the Release PR** → release-please tags `v0.X.Y` and publishes a GitHub Release.
211
+ 4. **`release.yml` triggers on `release: published`** → builds and runs `pnpm publish --provenance` to npm. Requires the `NPM_TOKEN` secret.
117
212
 
118
- - [x] Screenshot capture with Playwright
119
- - [x] Annotations (rect, arrow, label)
120
- - [x] S3 upload
121
- - [x] Auto-discover pages
122
- - [ ] Visual regression testing (VRT)
123
- - [ ] Intercom / Zendesk integration
124
- - [ ] AI-powered text updates
213
+ The `kagemusha` CLI reads its version from `package.json` at runtime, so release-please only needs to touch one file.
125
214
 
126
215
  ## License
127
216
 
@@ -0,0 +1,6 @@
1
+ interface AddOptions {
2
+ id?: string;
3
+ }
4
+ export declare const addCommand: (pagePath: string, options: AddOptions) => Promise<void>;
5
+ export {};
6
+ //# sourceMappingURL=add.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add.d.ts","sourceRoot":"","sources":["../../src/commands/add.ts"],"names":[],"mappings":"AAQA,UAAU,UAAU;IACnB,EAAE,CAAC,EAAE,MAAM,CAAC;CACZ;AAED,eAAO,MAAM,UAAU,GACtB,UAAU,MAAM,EAChB,SAAS,UAAU,KACjB,OAAO,CAAC,IAAI,CAyBd,CAAC"}
@@ -0,0 +1,26 @@
1
+ import chalk from "chalk";
2
+ import { findProjectRoot, loadDefinitions, saveDefinitions, } from "../lib/config.js";
3
+ import { deriveIdFromPath } from "../lib/definition.js";
4
+ export const addCommand = async (pagePath, options) => {
5
+ const projectRoot = findProjectRoot();
6
+ const definitions = loadDefinitions(projectRoot);
7
+ const baseId = options.id ?? deriveIdFromPath(pagePath);
8
+ // If ID already exists, append a suffix
9
+ let id = baseId;
10
+ let suffix = 2;
11
+ while (definitions.some((d) => d.id === id)) {
12
+ id = `${baseId}-${suffix}`;
13
+ suffix++;
14
+ }
15
+ definitions.push({
16
+ id,
17
+ name: id,
18
+ url: pagePath,
19
+ capture: { mode: "fullPage" },
20
+ hideElements: [],
21
+ decorations: [],
22
+ });
23
+ saveDefinitions(definitions, projectRoot);
24
+ console.log(chalk.green(`\n✅ Added ${id}\n`));
25
+ };
26
+ //# sourceMappingURL=add.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add.js","sourceRoot":"","sources":["../../src/commands/add.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,EACN,eAAe,EACf,eAAe,EACf,eAAe,GACf,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAMxD,MAAM,CAAC,MAAM,UAAU,GAAG,KAAK,EAC9B,QAAgB,EAChB,OAAmB,EACH,EAAE;IAClB,MAAM,WAAW,GAAG,eAAe,EAAE,CAAC;IACtC,MAAM,WAAW,GAAG,eAAe,CAAC,WAAW,CAAC,CAAC;IAEjD,MAAM,MAAM,GAAG,OAAO,CAAC,EAAE,IAAI,gBAAgB,CAAC,QAAQ,CAAC,CAAC;IAExD,wCAAwC;IACxC,IAAI,EAAE,GAAG,MAAM,CAAC;IAChB,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,OAAO,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,CAAC;QAC7C,EAAE,GAAG,GAAG,MAAM,IAAI,MAAM,EAAE,CAAC;QAC3B,MAAM,EAAE,CAAC;IACV,CAAC;IAED,WAAW,CAAC,IAAI,CAAC;QAChB,EAAE;QACF,IAAI,EAAE,EAAE;QACR,GAAG,EAAE,QAAQ;QACb,OAAO,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE;QAC7B,YAAY,EAAE,EAAE;QAChB,WAAW,EAAE,EAAE;KACf,CAAC,CAAC;IAEH,eAAe,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;IAC1C,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,aAAa,EAAE,IAAI,CAAC,CAAC,CAAC;AAC/C,CAAC,CAAC"}
@@ -1,8 +1,9 @@
1
1
  interface CaptureOptions {
2
2
  ids?: string;
3
- all?: boolean;
3
+ dryRun?: boolean;
4
4
  open?: boolean;
5
+ threshold?: string;
5
6
  }
6
- export declare function captureCommand(options: CaptureOptions): Promise<void>;
7
+ export declare const captureCommand: (options: CaptureOptions) => Promise<void>;
7
8
  export {};
8
9
  //# sourceMappingURL=capture.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../../src/commands/capture.ts"],"names":[],"mappings":"AAKA,UAAU,cAAc;IACvB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;CACf;AAED,wBAAsB,cAAc,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAkD3E"}
1
+ {"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../../src/commands/capture.ts"],"names":[],"mappings":"AAsBA,UAAU,cAAc;IACvB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AA+CD,eAAO,MAAM,cAAc,GAC1B,SAAS,cAAc,KACrB,OAAO,CAAC,IAAI,CAUd,CAAC"}
@@ -1,9 +1,56 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
1
4
  import chalk from "chalk";
2
- import { annotateScreenshots } from "../lib/annotate.js";
5
+ import { hasAuthState, resolveLoginScriptPath } from "../lib/auth.js";
6
+ import { handleAwsError } from "../lib/aws-error.js";
7
+ import { createS3Canonical, getCanonicalPath, getOutputDir, } from "../lib/canonical.js";
3
8
  import { findProjectRoot, loadConfig, loadDefinitions } from "../lib/config.js";
9
+ import { diffImages } from "../lib/diff.js";
4
10
  import { captureScreenshots } from "../lib/screenshot.js";
5
- export async function captureCommand(options) {
6
- console.log(chalk.bold("\n🥷 Kagemusha Capture screenshots\n"));
11
+ import { cleanupStaging, ensureStagingDirs, getStagingDir, getStagingPath, } from "../lib/staging.js";
12
+ // Schema version of `reports/summary.json`. Bump on any breaking change.
13
+ const SUMMARY_SCHEMA_VERSION = "1";
14
+ const writeSummaryReport = (projectRoot, report) => {
15
+ const reportPath = path.join(projectRoot, "reports", "summary.json");
16
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
17
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
18
+ };
19
+ // Open a file with the OS's default viewer (Preview on macOS, etc.).
20
+ // `detached + unref` lets the kagemusha process exit while the viewer keeps
21
+ // running. We deliberately avoid `shell: true` so paths with spaces don't get
22
+ // re-split — argv is passed straight through to the program.
23
+ const openInDefaultApp = (filePath) => {
24
+ if (process.platform === "darwin") {
25
+ spawn("open", [filePath], { detached: true, stdio: "ignore" }).unref();
26
+ }
27
+ else if (process.platform === "win32") {
28
+ // `start` is a cmd.exe builtin; we invoke cmd directly. The empty
29
+ // string after `start` is the (required) window title placeholder.
30
+ spawn("cmd", ["/c", "start", "", filePath], {
31
+ detached: true,
32
+ stdio: "ignore",
33
+ }).unref();
34
+ }
35
+ else {
36
+ spawn("xdg-open", [filePath], { detached: true, stdio: "ignore" }).unref();
37
+ }
38
+ };
39
+ export const captureCommand = async (options) => {
40
+ try {
41
+ await runCapture(options);
42
+ }
43
+ catch (e) {
44
+ if (handleAwsError(e)) {
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+ throw e;
49
+ }
50
+ };
51
+ const runCapture = async (options) => {
52
+ const dryRun = options.dryRun === true;
53
+ console.log(chalk.bold(`\n🥷 Kagemusha — Capture${dryRun ? " (dry-run)" : ""}\n`));
7
54
  const projectRoot = findProjectRoot();
8
55
  const config = loadConfig(projectRoot);
9
56
  let definitions = loadDefinitions(projectRoot);
@@ -12,27 +59,216 @@ export async function captureCommand(options) {
12
59
  definitions = definitions.filter((d) => ids.includes(d.id));
13
60
  }
14
61
  if (definitions.length === 0) {
15
- console.log(chalk.yellow("No screenshot definitions found."));
62
+ console.log(chalk.yellow("No screenshot definitions to capture.\n"));
16
63
  return;
17
64
  }
18
- console.log(chalk.blue(`📸 Capturing ${definitions.length} screenshot(s)...`));
19
- const results = await captureScreenshots(config, definitions, projectRoot);
20
- console.log(chalk.blue("🎨 Drawing annotations..."));
21
- const annotated = await annotateScreenshots(definitions, results, projectRoot);
22
- console.log(chalk.bold.green(`\n✅ Done! Screenshots saved to screenshots/\n`));
23
- for (const r of annotated) {
24
- console.log(chalk.gray(` ${r.id} ${r.annotatedPath}`));
65
+ // Auto-login: if a login script exists (auth.scriptPath, or default
66
+ // .kagemusha/login.mjs) but no saved session is on disk, run it before
67
+ // capturing. This is what makes CI work with no pre-baked storage state.
68
+ if (!hasAuthState(projectRoot) &&
69
+ resolveLoginScriptPath(config, projectRoot)) {
70
+ console.log(chalk.blue("🔐 No saved session found, running login script...\n"));
71
+ const { loginCommand } = await import("./login.js");
72
+ await loginCommand();
73
+ // loginCommand absorbs LoginError and sets exitCode internally.
74
+ // If no auth-state was produced, abort capture so we don't screenshot
75
+ // the login screen.
76
+ if (!hasAuthState(projectRoot)) {
77
+ process.exitCode = 1;
78
+ return;
79
+ }
25
80
  }
81
+ ensureStagingDirs(projectRoot);
82
+ const threshold = options.threshold
83
+ ? Number.parseFloat(options.threshold)
84
+ : (config.screenshot.defaultDiffThreshold ?? 0.005);
85
+ const remote = createS3Canonical(config);
86
+ const outputDir = getOutputDir(config, projectRoot);
87
+ const stagingDir = getStagingDir(projectRoot);
88
+ if (remote) {
89
+ console.log(chalk.gray(` canonical: ${remote.label()}`));
90
+ }
91
+ else {
92
+ console.log(chalk.gray(` canonical: ${outputDir} (local)`));
93
+ }
94
+ // 1. Capture into staging (annotated)
95
+ console.log(chalk.blue(`\n📸 Capturing ${definitions.length} screenshot(s) to staging...\n`));
96
+ const failures = await captureScreenshots(config, definitions, projectRoot, {
97
+ outputDir: stagingDir,
98
+ });
99
+ const failureReasons = new Map(failures.map((f) => [f.id, f.reason]));
100
+ const results = [];
101
+ // Track final paths (where the user can find each capture after the run)
102
+ const finalPathFor = new Map();
103
+ const pendingPushes = [];
104
+ for (const def of definitions) {
105
+ const canonicalPath = getCanonicalPath(config, projectRoot, def.id);
106
+ const stagingPath = getStagingPath(projectRoot, def.id);
107
+ if (!fs.existsSync(stagingPath)) {
108
+ results.push({
109
+ id: def.id,
110
+ status: "missing",
111
+ reason: failureReasons.get(def.id),
112
+ });
113
+ continue;
114
+ }
115
+ // Pull canonical from remote (S3) into outputDir; for local mode just check existence
116
+ const fetchResult = remote
117
+ ? await remote.fetch(def.id, canonicalPath)
118
+ : fs.existsSync(canonicalPath)
119
+ ? "ok"
120
+ : "not-found";
121
+ const queuePush = (push) => {
122
+ if (dryRun) {
123
+ finalPathFor.set(def.id, push.stagingPath);
124
+ }
125
+ else {
126
+ pendingPushes.push(push);
127
+ }
128
+ };
129
+ // New: no canonical yet — adopt staging as canonical (unless dry-run)
130
+ if (fetchResult === "not-found") {
131
+ const result = { id: def.id, status: "new" };
132
+ results.push(result);
133
+ queuePush({
134
+ id: def.id,
135
+ stagingPath,
136
+ canonicalPath,
137
+ onUrls: (urls) => {
138
+ if (urls)
139
+ result.urls = urls;
140
+ },
141
+ });
142
+ continue;
143
+ }
144
+ const result = await diffImages(canonicalPath, stagingPath);
145
+ if (result.match) {
146
+ results.push({ id: def.id, status: "unchanged" });
147
+ fs.rmSync(stagingPath, { force: true });
148
+ finalPathFor.set(def.id, canonicalPath);
149
+ }
150
+ else if (result.reason === "layout-diff") {
151
+ const item = {
152
+ id: def.id,
153
+ status: "changed",
154
+ reason: "layout-diff",
155
+ canonical: result.canonical,
156
+ staging: result.staging,
157
+ };
158
+ results.push(item);
159
+ queuePush({
160
+ id: def.id,
161
+ stagingPath,
162
+ canonicalPath,
163
+ onUrls: (urls) => {
164
+ if (urls)
165
+ item.urls = urls;
166
+ },
167
+ });
168
+ }
169
+ else {
170
+ const item = {
171
+ id: def.id,
172
+ status: "changed",
173
+ reason: "pixel-diff",
174
+ diffPercentage: result.diffPercentage,
175
+ };
176
+ results.push(item);
177
+ queuePush({
178
+ id: def.id,
179
+ stagingPath,
180
+ canonicalPath,
181
+ onUrls: (urls) => {
182
+ if (urls)
183
+ item.urls = urls;
184
+ },
185
+ });
186
+ }
187
+ }
188
+ // Parallel promote — push to remote + copy to local outputDir.
189
+ // Promise.all keeps S3 throughput high while node manages local fs serially.
190
+ if (pendingPushes.length > 0) {
191
+ fs.mkdirSync(outputDir, { recursive: true });
192
+ await Promise.all(pendingPushes.map(async ({ id, stagingPath, canonicalPath, onUrls }) => {
193
+ // Push to remote first so a failure doesn't leave local ahead of S3
194
+ const urls = remote ? await remote.push(id, stagingPath) : undefined;
195
+ fs.copyFileSync(stagingPath, canonicalPath);
196
+ fs.rmSync(stagingPath, { force: true });
197
+ finalPathFor.set(id, canonicalPath);
198
+ onUrls(urls);
199
+ }));
200
+ }
201
+ // 3. Print summary
202
+ const changed = results.filter((r) => r.status === "changed");
203
+ const unchanged = results.filter((r) => r.status === "unchanged");
204
+ const newly = results.filter((r) => r.status === "new");
205
+ const missing = results.filter((r) => r.status === "missing");
206
+ for (const r of results) {
207
+ if (r.status === "unchanged") {
208
+ console.log(chalk.gray(` ✓ ${r.id}`));
209
+ }
210
+ else if (r.status === "new") {
211
+ const action = dryRun ? "would be added" : "added to canonical";
212
+ console.log(chalk.cyan(` + ${r.id} (${action})`));
213
+ }
214
+ else if (r.status === "missing") {
215
+ const detail = r.reason ? `: ${r.reason}` : "";
216
+ console.log(chalk.red(` ✗ ${r.id} (capture failed${detail})`));
217
+ }
218
+ else if (r.status === "changed") {
219
+ const detail = r.reason === "pixel-diff"
220
+ ? `${r.diffPercentage.toFixed(2)}%`
221
+ : `layout-diff: ${r.canonical.width}×${r.canonical.height} → ${r.staging.width}×${r.staging.height}`;
222
+ const action = dryRun ? "→ would update" : "→ updated";
223
+ console.log(chalk.yellow(` ~ ${r.id} (${detail}) ${chalk.gray(action)}`));
224
+ }
225
+ }
226
+ // 4. Cleanup staging — applied entries are already removed individually,
227
+ // so this only matters when nothing changed (everything was unchanged).
228
+ if (changed.length === 0 && newly.length === 0) {
229
+ cleanupStaging(projectRoot);
230
+ }
231
+ console.log("");
232
+ console.log(chalk.bold(`changed: ${changed.length} / unchanged: ${unchanged.length} / new: ${newly.length}` +
233
+ (missing.length > 0 ? ` / missing: ${missing.length}` : "")));
234
+ if (dryRun && (changed.length > 0 || newly.length > 0)) {
235
+ console.log(chalk.gray(`\nDrop --dry-run to update canonical${remote ? ` (${remote.label()})` : ""}.`));
236
+ }
237
+ // 5. Write the structured report.
238
+ // `reports/summary.json` is part of kagemusha's PUBLIC API — see README
239
+ // "Notifications" section. Bump `schemaVersion` (or kagemusha major) on
240
+ // breaking changes to the shape.
241
+ writeSummaryReport(projectRoot, {
242
+ schemaVersion: SUMMARY_SCHEMA_VERSION,
243
+ timestamp: new Date().toISOString(),
244
+ dryRun,
245
+ canonical: remote ? remote.label() : `${outputDir} (local)`,
246
+ counts: {
247
+ changed: changed.length,
248
+ unchanged: unchanged.length,
249
+ new: newly.length,
250
+ missing: missing.length,
251
+ },
252
+ results,
253
+ });
254
+ // 6. Open changed/new results in default viewer
26
255
  if (options.open) {
27
- const { chromium } = await import("playwright-chromium");
28
- const browser = await chromium.launch({ headless: false });
29
- const context = await browser.newContext();
30
- for (const result of annotated) {
31
- const page = await context.newPage();
32
- await page.goto(`file://${result.annotatedPath}`);
256
+ for (const r of [...changed, ...newly]) {
257
+ const p = finalPathFor.get(r.id);
258
+ if (p && fs.existsSync(p)) {
259
+ openInDefaultApp(p);
260
+ }
261
+ }
262
+ }
263
+ // Exit code: 1 if dry-run found pixel diffs over threshold (CI gate use case)
264
+ if (dryRun) {
265
+ const overThreshold = changed.filter((r) => r.status === "changed" &&
266
+ r.reason === "pixel-diff" &&
267
+ r.diffPercentage / 100 > threshold);
268
+ if (overThreshold.length > 0) {
269
+ process.exitCode = 1;
33
270
  }
34
- console.log(chalk.gray("\nPress Ctrl+C to close preview.\n"));
35
- await new Promise(() => { });
36
271
  }
37
- }
272
+ console.log("");
273
+ };
38
274
  //# sourceMappingURL=capture.js.map