Skip to content

Commit f0778ae

Browse files
authored
docs: check CDP E2E scripts into scripts/e2e/ (#74)
The CDP-based end-to-end workflow documented in #73 referenced helper scripts that only existed under /tmp/ on the author's machine. Anyone trying to reproduce the workflow had no actual scripts to start from. Move the working scripts into the repo and rewrite AGENTS.md to point at them by relative path: - scripts/e2e/cdp.sh: the Runtime.evaluate helper (websocat + python3, PORT env var, exits non-zero on CDP exception or HTTP/WS failure). - scripts/e2e/cdp-e2e-all.js: multi-store upload smoke test. - scripts/e2e/cdp-mermaid-e2e.js: mermaid round-trip; STORE_ID parameterised at the top of the file. - scripts/e2e/cdp-e2e-supplemental.js: 13-case suite; STORE_ID and BUCKET_HOST_RE parameterised at the top of the file so the assertions no longer hardcode 'atbug.oss-...'. - scripts/e2e/README.md: full workflow, footguns, canonical ID table, cleanup expectations. AGENTS.md drops the embedded cdp.sh source, the /tmp/cdp-*.js references, the duplicated canonical-ID table, and the 'reload plugin via CDP' code block - all of which now live in scripts/e2e/README.md. Instead it points readers at scripts/e2e/ with a short summary of the workflow and footguns worth knowing without opening the README. eslint.config.mjs adds scripts/** to globalIgnores so the helper scripts don't trip type-aware lint rules.
1 parent a8a3c4e commit f0778ae

7 files changed

Lines changed: 646 additions & 85 deletions

File tree

AGENTS.md

Lines changed: 13 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -201,99 +201,27 @@ Current test files:
201201

202202
### End-to-End Testing via Chrome DevTools Protocol
203203

204-
Unit tests can't catch Electron-specific runtime issues (e.g. Chromium rejecting an explicit `Host` header in `requestUrl`, which broke COS in 1.6.2 release candidate). We drive a **real Obsidian instance** over CDP to exercise the full publish flow against live cloud credentials.
204+
Unit tests can't catch Electron-specific runtime issues (e.g. Chromium rejecting an explicit `Host` header in `requestUrl`, which broke COS in the 1.6.2 release candidate). The repo ships a small suite of CDP-driven scripts under [`scripts/e2e/`](scripts/e2e/) that drive a **real Obsidian instance** to exercise the full publish flow against live cloud credentials.
205205

206-
#### Setup
206+
See [`scripts/e2e/README.md`](scripts/e2e/README.md) for the full workflow. Quick summary:
207207

208-
1. Launch Obsidian with the CDP endpoint enabled:
208+
1. Launch Obsidian with `--remote-debugging-port=9223` (e.g. `open -a Obsidian --args --remote-debugging-port=9223`).
209+
2. Install the built plugin into a test vault that has live credentials for the providers you want to exercise. macOS TCC blocks `~/Documents` for non-Obsidian processes; place the test vault under `~/iCloud Drive/` or another accessible location.
210+
3. Run a script via the helper:
209211
```bash
210-
open -a Obsidian --args --remote-debugging-port=9223
212+
./scripts/e2e/cdp.sh "$(cat scripts/e2e/cdp-e2e-all.js)"
211213
```
212-
2. Install the built plugin into a test vault that has live credentials for the providers you want to exercise:
213-
```bash
214-
npm run build
215-
cp main.js manifest.json styles.css \
216-
"$VAULT/.obsidian/plugins/image-upload-toolkit/"
217-
```
218-
Note: macOS TCC blocks `~/Documents` for non-Obsidian processes; place the test vault under `~/iCloud Drive/` or another accessible location.
219-
3. Enable the plugin in the vault (or hot-reload it via CDP — see "Reload Plugin via CDP" below).
220-
221-
#### The `cdp.sh` Helper
222-
223-
Requires `jq` and `websocat` on `$PATH` (`brew install jq websocat`). A minimal shell wrapper that posts a single `Runtime.evaluate` call to the active page and prints the JSON result. The pattern is:
224-
225-
```bash
226-
#!/usr/bin/env bash
227-
# /tmp/cdp.sh — usage: cdp.sh '<js expression>'
228-
TARGET=$(curl -s http://localhost:9223/json/list \
229-
| jq -r '[.[] | select(.type=="page")][0].webSocketDebuggerUrl')
230-
EXPR=$1
231-
jq -nc --arg e "$EXPR" '{
232-
id: 1, method: "Runtime.evaluate",
233-
params: { expression: $e, awaitPromise: true, returnByValue: true }
234-
}' | websocat -n1 "$TARGET"
235-
```
236-
237-
Anything that evaluates to a JSON-serializable value comes back in `result.result.value`. Wrap multi-statement scripts in an async IIFE so `awaitPromise: true` can wait on the result.
238-
239-
#### What You Have Access To Inside CDP
240-
241-
The evaluated expression runs in the Obsidian renderer, so the full app is in scope:
242-
243-
- `app`, `app.vault`, `app.workspace`, `app.commands`, `app.plugins`
244-
- `app.plugins.plugins["image-upload-toolkit"]` — live plugin instance
245-
- `.settings` — current settings object (mutating it persists nothing until `saveData`)
246-
- `.setupImageUploader()` — sync; rebuild the uploader after settings mutation
247-
- `app.commands.executeCommandById("image-upload-toolkit:publish-page")` — invokes the publish command (it's `checkCallback`-based, so don't call `.callback` directly)
248-
- `navigator.clipboard.readText()` — read what publish wrote to the clipboard
249-
- `activeDocument.querySelector(".modal.upload-progress-modal")` — assert the progress modal opened
250-
- `app.workspace.activeLeaf.view.editor.getValue()` — read the current editor buffer (do NOT use `app.vault.read(file)` after `editor.setValue()`; the latter doesn't auto-persist to disk)
251-
252-
Avoid `require("obsidian")` and dynamic `import()` of the bundle from inside CDP — `obsidian` is an esbuild external and resolves to nothing at runtime in this context. Use the plugin instance for everything you need from the API.
253-
254-
#### Reload Plugin via CDP
255-
256-
After copying a new build into the vault:
257-
258-
```bash
259-
/tmp/cdp.sh '(async () => {
260-
await app.plugins.disablePlugin("image-upload-toolkit");
261-
await app.plugins.enablePlugin("image-upload-toolkit");
262-
return app.plugins.plugins["image-upload-toolkit"].manifest.version;
263-
})()'
264-
```
265-
266-
#### Sample Test Scripts
267-
268-
These scripts are not checked into the repo — they're working scratch under `/tmp/` during a release cycle. Treat the entries below as patterns to copy when you need to reproduce or extend coverage.
269-
270-
- **Multi-store upload smoke test** (`/tmp/cdp-e2e-all.js`): iterates over `["ALIYUN_OSS", "AWS_S3", "Imagekit", "TENCENTCLOUD_COS", "IMGUR"]`, mutates `settings.imageStore`, calls `setupImageUploader()`, writes a sentinel PNG to the vault, runs the publish command, reads the clipboard, asserts the URL host matches the provider.
271-
- **Mermaid round-trip** (`/tmp/cdp-mermaid-e2e.js`): creates a note with a `mermaid` fence, runs publish, asserts the clipboard contains an uploaded image URL and the original document still contains the source fence.
272-
- **Supplemental coverage** (`/tmp/cdp-e2e-supplemental.js`): 13 cases covering settings panel rendering, progress modal lifecycle, web image download/skip, multi-block mermaid + theme/scale, `replaceOriginalDoc`, wiki-link images, hosted-URL dedupe, `ignoreProperties` toggle, and `imageAltText` toggle.
273-
274-
#### Canonical IDs and Setting Keys
275214

276-
When mutating settings programmatically, use the exact casing from `src/imageStore.ts`:
215+
The helper (`scripts/e2e/cdp.sh`) requires `websocat` and `python3` on `PATH`. The expression runs in the Obsidian renderer with full access to `app`, `app.plugins.plugins["image-upload-toolkit"]`, `app.vault`, `app.commands.executeCommandById(...)`, `navigator.clipboard`, and `activeDocument`.
277216

278-
| Store | `ImageStore.id` | Settings key |
279-
|--------------|---------------------|--------------------------|
280-
| Imgur | `IMGUR` | `imgurAnonymousSetting` |
281-
| Aliyun OSS | `ALIYUN_OSS` | `ossSetting` |
282-
| ImageKit | `Imagekit` | `imagekitSetting` |
283-
| AWS S3 | `AWS_S3` | `awsS3Setting` |
284-
| Tencent COS | `TENCENTCLOUD_COS` | `cosSetting` |
285-
| Qiniu Kodo | `QINIU_KUDO` | `kodoSetting` |
286-
| GitHub | `GITHUB` | `githubSetting` |
287-
| Gyazo | `GYAZO` | `gyazoSetting` |
288-
| Cloudflare R2| `CLOUDFLARE_R2` | `r2Setting` |
289-
| Backblaze B2 | `BACKBLAZE_B2` | `b2Setting` |
217+
Footguns worth knowing without opening the README:
290218

291-
#### Cleanup
219+
- `require("obsidian")` and dynamic `import()` of the bundle don't work inside CDP — `obsidian` is an esbuild external and resolves to nothing at runtime in this context. Use the plugin instance.
220+
- `editor.setValue()` does NOT persist to disk. When asserting on `replaceOriginalDoc`, read `leaf.view.editor.getValue()`, not `app.vault.read(file)`.
221+
- Commands are `checkCallback`-based — use `app.commands.executeCommandById(...)`, not `.callback()`.
222+
- The progress modal opens asynchronously; poll `activeDocument.querySelector(".modal.upload-progress-modal")` for a few hundred ms.
292223

293-
The publish flow uploads to **real buckets**. Test scripts should:
294-
- Prefix test filenames (e.g. `__iut-<timestamp>.png`) so they're easy to identify and prune from the bucket.
295-
- Snapshot `plugin.settings` before mutating and restore it after the run.
296-
- Delete any temp notes/images created in the vault via `app.vault.delete(file)`.
224+
The scripts upload to **real buckets**. They prefix sentinel filenames (`iut-*`, `__iut-*`), snapshot/restore `plugin.settings`, and delete temp notes via `app.vault.delete(file)`.
297225

298226
### Manual Testing Checklist
299227

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export default tseslint.config(
8484
"__mocks__/**",
8585
"src/**/*.test.ts",
8686
"tests/**",
87+
"scripts/**",
8788
]),
8889
);
8990

scripts/e2e/README.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# CDP-based End-to-End Tests
2+
3+
These scripts drive a real Obsidian instance over the [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) to validate the publish flow against live cloud credentials. Use them when:
4+
5+
- You changed an uploader and want to confirm it still talks to the real bucket.
6+
- You're prepping a release and want to verify the user-visible publish flow end to end (UI modal, clipboard output, editor rewrite, mermaid conversion, etc).
7+
- You suspect an Electron-specific runtime issue that unit tests can't catch (e.g. `requestUrl` header validation).
8+
9+
These are intentionally **not** wired into CI — they hit real buckets and require manual credentials.
10+
11+
## Prerequisites
12+
13+
- A test vault with the plugin installed and live credentials configured for whatever stores you want to exercise.
14+
- Obsidian launched with the CDP endpoint enabled:
15+
```bash
16+
open -a Obsidian --args --remote-debugging-port=9223
17+
```
18+
- `websocat` and `python3` on `PATH`:
19+
```bash
20+
brew install websocat
21+
```
22+
23+
## The helper
24+
25+
[`cdp.sh`](cdp.sh) wraps a single `Runtime.evaluate` call to the active page. Pass any JavaScript expression as the first argument; the expression runs in the Obsidian renderer with full access to `app`, `app.plugins`, `app.vault`, `navigator.clipboard`, `activeDocument`, etc.
26+
27+
```bash
28+
# Inline expression
29+
./scripts/e2e/cdp.sh 'app.plugins.plugins["image-upload-toolkit"].manifest.version'
30+
31+
# Load a script file
32+
./scripts/e2e/cdp.sh "$(cat scripts/e2e/cdp-e2e-all.js)"
33+
34+
# Custom port
35+
PORT=9333 ./scripts/e2e/cdp.sh '...'
36+
```
37+
38+
Multi-statement scripts must be wrapped in an async IIFE that returns a JSON-serializable value, because `awaitPromise: true` is passed and the result is fetched via `returnByValue: true`.
39+
40+
## Scripts
41+
42+
| File | Purpose |
43+
|------|---------|
44+
| [`cdp.sh`](cdp.sh) | Generic `Runtime.evaluate` helper |
45+
| [`cdp-e2e-all.js`](cdp-e2e-all.js) | Multi-store upload smoke test (iterates stores, uploads a 1x1 PNG sentinel via each) |
46+
| [`cdp-mermaid-e2e.js`](cdp-mermaid-e2e.js) | Mermaid round-trip: creates a fence, runs publish, asserts upload + fence removal |
47+
| [`cdp-e2e-supplemental.js`](cdp-e2e-supplemental.js) | 13-case suite covering settings panel, progress modal, web image download/skip, multi-mermaid, theme/scale, `replaceOriginalDoc`, wiki-link, hosted-URL dedupe, `ignoreProperties`, `imageAltText` |
48+
49+
Each script has a header comment block documenting its assumptions and any constants you may need to edit (e.g. `STORE_ID`, `BUCKET_HOST_RE`).
50+
51+
## Plugin reload via CDP
52+
53+
After copying a new build into the vault, hot-reload the plugin:
54+
55+
```bash
56+
./scripts/e2e/cdp.sh '(async () => {
57+
await app.plugins.disablePlugin("image-upload-toolkit");
58+
await app.plugins.enablePlugin("image-upload-toolkit");
59+
return app.plugins.plugins["image-upload-toolkit"].manifest.version;
60+
})()'
61+
```
62+
63+
## Footguns
64+
65+
- **`require("obsidian")` and dynamic `import()` of the bundle don't work** inside the CDP expression — `obsidian` is an esbuild external and resolves to nothing at runtime in this context. Use the plugin instance (`app.plugins.plugins["image-upload-toolkit"]`) for everything you need from the API.
66+
- **`editor.setValue()` does NOT persist to disk.** When asserting on `replaceOriginalDoc` behavior, read `leaf.view.editor.getValue()`, not `app.vault.read(file)`.
67+
- **Commands are `checkCallback`-based.** Use `app.commands.executeCommandById("image-upload-toolkit:publish-page")`. Don't call `.callback` directly.
68+
- **Progress modal is async.** Don't query it once — poll `activeDocument.querySelector(".modal.upload-progress-modal")` for a few hundred ms after triggering publish.
69+
- **macOS TCC blocks `~/Documents`** for non-Obsidian processes. Place test vaults under `~/iCloud Drive/` or another accessible location.
70+
71+
## Cleanup expectations
72+
73+
The scripts upload to real buckets. They:
74+
75+
- Prefix all sentinel filenames with `iut-` or `__iut-` so they're easy to identify and prune.
76+
- Snapshot `plugin.settings` before mutating and restore it after the run.
77+
- Delete temp notes/images created in the vault via `app.vault.delete(file)`.
78+
79+
Residual sentinel files in your bucket are harmless but accumulate over time — periodically prune them with whatever bucket-management tool you use.
80+
81+
## Canonical IDs and setting keys
82+
83+
When mutating settings programmatically, use the exact casing from [`src/imageStore.ts`](../../src/imageStore.ts):
84+
85+
| Store | `ImageStore.id` | Settings key |
86+
|---------------|--------------------|-------------------------|
87+
| Imgur | `IMGUR` | `imgurAnonymousSetting` |
88+
| Aliyun OSS | `ALIYUN_OSS` | `ossSetting` |
89+
| ImageKit | `Imagekit` | `imagekitSetting` |
90+
| AWS S3 | `AWS_S3` | `awsS3Setting` |
91+
| Tencent COS | `TENCENTCLOUD_COS` | `cosSetting` |
92+
| Qiniu Kodo | `QINIU_KUDO` | `kodoSetting` |
93+
| GitHub | `GITHUB` | `githubSetting` |
94+
| Gyazo | `GYAZO` | `gyazoSetting` |
95+
| Cloudflare R2 | `CLOUDFLARE_R2` | `r2Setting` |
96+
| Backblaze B2 | `BACKBLAZE_B2` | `b2Setting` |

scripts/e2e/cdp-e2e-all.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Multi-store upload smoke test for image-upload-toolkit.
2+
//
3+
// What it does:
4+
// - Iterates a list of ImageStore IDs configured in the running vault.
5+
// - For each store, mutates plugin.settings.imageStore, rebuilds the
6+
// uploader, and uploads a 1x1 PNG sentinel directly via uploader.upload().
7+
// - Reports per-store {ok, url|err} JSON.
8+
// - Restores the original imageStore before returning.
9+
//
10+
// Usage:
11+
// ./scripts/e2e/cdp.sh "$(cat scripts/e2e/cdp-e2e-all.js)"
12+
//
13+
// Pre-reqs:
14+
// - The vault running in the CDP-attached Obsidian must have valid
15+
// credentials configured for every store listed in `stores` below.
16+
// Stores you don't have creds for will fail in the "upload" phase
17+
// and won't pollute anything else.
18+
// - Sentinel filenames are prefixed `iut-e2e-` so they are easy to
19+
// identify and prune from your bucket after the run.
20+
21+
(async () => {
22+
const plugin = app.plugins.plugins["image-upload-toolkit"];
23+
if (!plugin) throw new Error("image-upload-toolkit plugin not loaded");
24+
25+
const originalStore = plugin.settings.imageStore;
26+
27+
// 1x1 transparent PNG
28+
const b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
29+
const bin = atob(b64);
30+
const bytes = new Uint8Array(bin.length);
31+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
32+
33+
// Adjust this list to match the stores you have creds for. Canonical
34+
// IDs come from src/imageStore.ts (note casing!).
35+
const stores = ["ALIYUN_OSS", "AWS_S3", "Imagekit", "IMGUR", "TENCENTCLOUD_COS"];
36+
const results = [];
37+
38+
for (const store of stores) {
39+
plugin.settings.imageStore = store;
40+
try {
41+
plugin.setupImageUploader();
42+
} catch (e) {
43+
results.push({ store, ok: false, phase: "setup", err: String(e?.message || e) });
44+
continue;
45+
}
46+
const up = plugin.imageUploader;
47+
if (!up) {
48+
results.push({ store, ok: false, err: "no uploader after setup" });
49+
continue;
50+
}
51+
const fname = `iut-e2e-${store.toLowerCase()}-${Date.now()}.png`;
52+
try {
53+
const file = new File([bytes], fname, { type: "image/png" });
54+
const url = await up.upload(file, fname);
55+
results.push({ store, ok: true, url });
56+
} catch (e) {
57+
results.push({ store, ok: false, phase: "upload", err: String(e?.message || e).slice(0, 200) });
58+
}
59+
}
60+
61+
plugin.settings.imageStore = originalStore;
62+
plugin.setupImageUploader();
63+
return JSON.stringify(results, null, 2);
64+
})()

0 commit comments

Comments
 (0)