this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Playwright web e2e tests for login, identity, and settings

22 tests across 4 files covering: OAuth login flow (success + error
cases), seed phrase generation and confirmation (full flow + wrong
words), settings page (account info, preferences, toggle state),
auth guards, and infrastructure smoke tests. 3 parallel workers
with per-account isolation. Snapshot assertions for stable UI states.

Also updates test-data content and removes the manual blackbox test
plan (superseded by automated e2e coverage). [CL-360]

+530 -834
-675
AGENT-BLACKBOX-TEST.md
··· 1 - # Opake Black-Box Test Plan 2 - 3 - Manual end-to-end testing against live PDS instances. Covers every CLI command. 4 - 5 - Do not read the code in this library before executing this test. Ask the user for input instead. 6 - 7 - ## Requirements 8 - 9 - - **Accounts needed:** 2 minimum, 3 recommended 10 - - Account A: the primary user (uploads, creates keyrings, shares files) 11 - - Account B: a recipient (downloads shared files, joins keyrings) 12 - - Account C (optional): a third user to test multi-member keyrings and non-member rejection 13 - - Accounts can be on the same PDS or different PDSes. Cross-PDS is the harder path and should be tested if possible. 14 - - `cargo build` must succeed before starting. 15 - - Each test section is independent — run them in order for a clean state, or cherry-pick. 16 - 17 - ## Notation 18 - 19 - ``` 20 - A$ = run as account A (opake --as <A-handle> ...) 21 - B$ = run as account B (opake --as <B-handle> ...) 22 - C$ = run as account C (opake --as <C-handle> ...) 23 - ``` 24 - 25 - Substitute real handles/DIDs for `<A-handle>`, `<B-handle>`, etc. 26 - 27 - --- 28 - 29 - ## 1. Login and Account Management 30 - 31 - ### 1.1 Login 32 - 33 - ```bash 34 - opake login <A-handle> 35 - # resolves PDS, authenticates via OAuth, prints "Logged in as <handle>" 36 - # also publishes encryption public key (putRecord) 37 - 38 - opake login <B-handle> 39 - ``` 40 - 41 - **Verify:** 42 - - Login succeeds for both accounts 43 - - `opake accounts` shows both 44 - - One is marked as default 45 - 46 - ### 1.2 Accounts and switching 47 - 48 - ```bash 49 - opake accounts 50 - # lists all logged-in accounts with DID, handle, PDS URL, and default marker 51 - 52 - opake set-default <B-handle> 53 - opake accounts 54 - # B is now the default 55 - 56 - opake set-default <A-handle> 57 - ``` 58 - 59 - ### 1.3 Logout 60 - 61 - ```bash 62 - # don't actually log out yet — we need the accounts for later tests 63 - # but verify the flag exists: 64 - opake logout --help 65 - ``` 66 - 67 - ### 1.4 Resolve 68 - 69 - ```bash 70 - A$ opake resolve <B-handle> 71 - # prints DID, PDS URL, public key, algorithm 72 - 73 - A$ opake resolve <A-handle> 74 - # resolving yourself should also work 75 - ``` 76 - 77 - **Verify:** 78 - - Output includes DID, PDS URL, base64 public key, and `x25519` algorithm 79 - - Resolving a nonexistent handle errors cleanly 80 - 81 - --- 82 - 83 - ## 2. Upload and Download (Direct Encryption) 84 - 85 - ### 2.1 Upload a file 86 - 87 - ```bash 88 - echo "hello opake" > /tmp/test-direct.txt 89 - 90 - A$ opake upload /tmp/test-direct.txt 91 - # prints: test-direct.txt → at://did:plc:A/app.opake.document/<rkey> 92 - ``` 93 - 94 - Save the output AT-URI as `$DOC_URI`. 95 - 96 - ### 2.2 List documents 97 - 98 - ```bash 99 - A$ opake ls 100 - # shows test-direct.txt 101 - 102 - A$ opake ls -l 103 - # long format: size, mime type, tags, URI 104 - ``` 105 - 106 - **Verify:** 107 - - File appears in the list 108 - - Long format shows `text/plain`, size, and the AT-URI 109 - 110 - ### 2.3 Download own file 111 - 112 - ```bash 113 - A$ opake download test-direct.txt -o /tmp/test-direct-download.txt 114 - # prints: test-direct.txt → /tmp/test-direct-download.txt (12 bytes) 115 - 116 - diff /tmp/test-direct.txt /tmp/test-direct-download.txt 117 - # no output = identical 118 - ``` 119 - 120 - ### 2.4 Download by AT-URI 121 - 122 - ```bash 123 - A$ opake download $DOC_URI -o /tmp/test-direct-uri.txt 124 - diff /tmp/test-direct.txt /tmp/test-direct-uri.txt 125 - ``` 126 - 127 - ### 2.5 Download refuses to overwrite 128 - 129 - ```bash 130 - A$ opake download test-direct.txt -o /tmp/test-direct-download.txt 131 - # should error: "output file already exists" 132 - ``` 133 - 134 - ### 2.6 Upload with tags 135 - 136 - ```bash 137 - A$ opake upload /tmp/test-direct.txt --tags tax,2026 138 - # new document with tags 139 - 140 - A$ opake ls --tag tax 141 - # only tagged document appears 142 - ``` 143 - 144 - ### 2.7 Upload empty file 145 - 146 - ```bash 147 - touch /tmp/empty.bin 148 - A$ opake upload /tmp/empty.bin 149 - A$ opake download empty.bin -o /tmp/empty-download.bin 150 - # 0 bytes, no error 151 - ``` 152 - 153 - --- 154 - 155 - ## 3. Directories 156 - 157 - ### 3.1 Create directories 158 - 159 - ```bash 160 - A$ opake mkdir Photos 161 - # prints: Photos → at://did:plc:A/app.opake.directory/<rkey> 162 - 163 - A$ opake mkdir Archive 164 - ``` 165 - 166 - ### 3.2 Upload into a directory 167 - 168 - ```bash 169 - echo "beach photo" > /tmp/beach.jpg 170 - A$ opake upload /tmp/beach.jpg --dir Photos 171 - # prints: beach.jpg → at://... (in Photos) 172 - ``` 173 - 174 - ### 3.3 View the tree 175 - 176 - ```bash 177 - A$ opake tree 178 - # / 179 - # ├── Archive/ 180 - # ├── Photos/ 181 - # │ └── beach.jpg 182 - # └── test-direct.txt (if still present from section 2) 183 - ``` 184 - 185 - **Verify:** 186 - - Directories appear with trailing `/` 187 - - Documents appear as leaves 188 - - Indentation and box-drawing characters are correct 189 - 190 - ### 3.4 Cat a file (decrypt to stdout) 191 - 192 - ```bash 193 - A$ opake cat beach.jpg 194 - # prints "beach photo" to stdout (no file created) 195 - 196 - # path-based cat: 197 - A$ opake cat Photos/beach.jpg 198 - # same output 199 - ``` 200 - 201 - **Verify:** 202 - - Output is the decrypted plaintext 203 - - No prompt, no "saved to" message — just raw content 204 - 205 - ### 3.5 Move a file into a directory 206 - 207 - ```bash 208 - echo "meeting notes" > /tmp/notes.txt 209 - A$ opake upload /tmp/notes.txt 210 - A$ opake mv notes.txt Archive/ 211 - # prints: moved "notes.txt" → Archive/ 212 - 213 - A$ opake tree 214 - # Archive/ now contains notes.txt 215 - ``` 216 - 217 - ### 3.6 Rename a file 218 - 219 - ```bash 220 - A$ opake mv notes.txt meeting-notes.txt 221 - # prints: renamed "notes.txt" → "meeting-notes.txt" 222 - ``` 223 - 224 - ### 3.7 Move via path 225 - 226 - ```bash 227 - A$ opake mv Archive/meeting-notes.txt Photos/ 228 - # prints: moved "meeting-notes.txt" → Photos/ 229 - ``` 230 - 231 - ### 3.8 Rename a directory 232 - 233 - ```bash 234 - A$ opake mv Archive Old 235 - # prints: renamed "Archive" → "Old" 236 - ``` 237 - 238 - ### 3.9 Move into self is rejected 239 - 240 - ```bash 241 - A$ opake mv Photos Photos/ 242 - # should error: "cannot move a directory into itself" 243 - ``` 244 - 245 - --- 246 - 247 - ## 4. Delete 248 - 249 - ```bash 250 - A$ opake rm beach.jpg 251 - # prompts "delete beach.jpg? [y/N]" 252 - # type y 253 - 254 - A$ opake ls 255 - # file is gone 256 - ``` 257 - 258 - ### 4.1 Delete with --yes 259 - 260 - ```bash 261 - A$ opake upload /tmp/test-direct.txt 262 - A$ opake rm test-direct.txt -y 263 - # no prompt, immediate delete 264 - ``` 265 - 266 - ### 4.2 Delete by path 267 - 268 - ```bash 269 - echo "delete me" > /tmp/deleteme.txt 270 - A$ opake upload /tmp/deleteme.txt --dir Photos 271 - A$ opake rm Photos/deleteme.txt -y 272 - # deletes the document and removes it from Photos' entry list 273 - ``` 274 - 275 - ### 4.3 Delete an empty directory 276 - 277 - ```bash 278 - A$ opake rm Old -y 279 - # deletes the directory (must be empty) 280 - ``` 281 - 282 - ### 4.4 Delete non-empty directory without -r fails 283 - 284 - ```bash 285 - A$ opake rm Photos 286 - # should error: "directory is not empty (N documents, M subdirectories) — use -r to delete recursively" 287 - ``` 288 - 289 - ### 4.5 Recursive delete 290 - 291 - ```bash 292 - echo "sunset" > /tmp/sunset.jpg 293 - A$ opake upload /tmp/sunset.jpg --dir Photos 294 - A$ opake rm -r Photos 295 - # prompts: "delete Photos/? (1 documents, 0 subdirectories) [y/N]" 296 - # type y 297 - # prints: deleted at://... (1 documents, 1 directories) 298 - 299 - A$ opake tree 300 - # Photos is gone 301 - ``` 302 - 303 - --- 304 - 305 - ## 5. Sharing (Grants) 306 - 307 - ### 5.1 Upload a file to share 308 - 309 - ```bash 310 - echo "shared secret" > /tmp/shared-file.txt 311 - A$ opake upload /tmp/shared-file.txt 312 - ``` 313 - 314 - Save URI as `$SHARED_URI`. 315 - 316 - ### 5.2 Share with B 317 - 318 - ```bash 319 - A$ opake share shared-file.txt <B-handle> --note "for your eyes only" 320 - # prints: shared with <B-handle> → at://did:plc:A/app.opake.grant/<grant-rkey> 321 - ``` 322 - 323 - Save grant URI as `$GRANT_URI`. 324 - 325 - ### 5.3 List outgoing grants 326 - 327 - ```bash 328 - A$ opake shared 329 - # shows the grant: recipient, permissions, URI 330 - 331 - A$ opake shared -l 332 - # long format: document URI, grant URI, note 333 - ``` 334 - 335 - **Verify:** 336 - - Grant appears with B's DID as recipient 337 - - Note shows up in long format 338 - 339 - ### 5.4 Download via grant (cross-PDS) 340 - 341 - ```bash 342 - B$ opake download --grant $GRANT_URI -o /tmp/shared-download.txt 343 - # prints: shared-file.txt → /tmp/shared-download.txt (14 bytes) 344 - 345 - diff /tmp/shared-file.txt /tmp/shared-download.txt 346 - # identical 347 - ``` 348 - 349 - **Verify:** 350 - - Works even though the file lives on A's PDS 351 - - B never needs to be a "member" of anything 352 - 353 - ### 5.5 Revoke 354 - 355 - ```bash 356 - A$ opake revoke $GRANT_URI 357 - # prompts, type y 358 - # prints: revoked at://... 359 - 360 - A$ opake shared 361 - # grant is gone 362 - ``` 363 - 364 - ### 5.6 Download after revoke fails 365 - 366 - ```bash 367 - B$ opake download --grant $GRANT_URI -o /tmp/should-fail.txt 368 - # should error (404 or similar — grant record is deleted) 369 - ``` 370 - 371 - --- 372 - 373 - ## 5b. Metadata Management 374 - 375 - ### 5b.1 Show metadata 376 - 377 - ```bash 378 - A$ opake metadata show $FILENAME 379 - # prints: Name, MIME type, Size, Tags, Description 380 - ``` 381 - 382 - ### 5b.2 Rename 383 - 384 - ```bash 385 - A$ opake metadata rename $FILENAME new-name.txt 386 - # prints: Renamed to: new-name.txt 387 - A$ opake ls 388 - # verify: old name gone, new-name.txt present 389 - ``` 390 - 391 - ### 5b.3 Add and remove tags 392 - 393 - ```bash 394 - A$ opake metadata tag add new-name.txt finance 395 - # prints: Tags: finance 396 - A$ opake metadata tag add new-name.txt 2025 397 - # prints: Tags: finance, 2025 398 - A$ opake metadata tag remove new-name.txt finance 399 - # prints: Tags: 2025 400 - A$ opake metadata show new-name.txt 401 - # verify: Tags line shows "2025" only 402 - ``` 403 - 404 - ### 5b.4 Set and clear description 405 - 406 - ```bash 407 - A$ opake metadata describe new-name.txt "Annual tax return" 408 - # prints: Description updated. 409 - A$ opake metadata show new-name.txt 410 - # verify: Description: Annual tax return 411 - A$ opake metadata describe new-name.txt --clear 412 - # prints: Description cleared. 413 - A$ opake metadata show new-name.txt 414 - # verify: no Description line 415 - ``` 416 - 417 - ### 5b.5 Duplicate tag is idempotent 418 - 419 - ```bash 420 - A$ opake metadata tag add new-name.txt 2025 421 - A$ opake metadata tag add new-name.txt 2025 422 - A$ opake metadata show new-name.txt 423 - # verify: Tags shows "2025" once, not twice 424 - ``` 425 - 426 - --- 427 - 428 - ## 6. Keyrings 429 - 430 - ### 6.1 Create a keyring 431 - 432 - ```bash 433 - A$ opake keyring create family-photos 434 - # prints: family-photos → at://did:plc:A/app.opake.keyring/<kr-rkey> 435 - ``` 436 - 437 - ### 6.2 List keyrings 438 - 439 - ```bash 440 - A$ opake keyring ls 441 - # family-photos 1 member(s) 442 - 443 - A$ opake keyring ls -l 444 - # includes URI and rotation count (0) 445 - ``` 446 - 447 - ### 6.3 Upload under keyring 448 - 449 - ```bash 450 - echo "family photo metadata" > /tmp/photo.txt 451 - A$ opake upload /tmp/photo.txt --keyring family-photos 452 - ``` 453 - 454 - Save URI as `$KR_DOC_URI`. 455 - 456 - ### 6.4 Download own keyring-encrypted file 457 - 458 - ```bash 459 - A$ opake download photo.txt -o /tmp/photo-download.txt 460 - diff /tmp/photo.txt /tmp/photo-download.txt 461 - # identical 462 - ``` 463 - 464 - ### 6.5 Add member 465 - 466 - ```bash 467 - A$ opake keyring add-member family-photos <B-handle> 468 - # prints: added <B-handle> to family-photos 469 - 470 - A$ opake keyring ls 471 - # family-photos 2 member(s) 472 - ``` 473 - 474 - ### 6.6 Member download (cross-PDS) 475 - 476 - ```bash 477 - B$ opake download --keyring-member $KR_DOC_URI -o /tmp/kr-member-download.txt 478 - # prints: photo.txt → /tmp/kr-member-download.txt (22 bytes) 479 - 480 - diff /tmp/photo.txt /tmp/kr-member-download.txt 481 - # identical 482 - ``` 483 - 484 - **Verify:** 485 - - B fetched from A's PDS (unauthenticated) 486 - - Group key is now cached locally for B 487 - 488 - ### 6.7 Subsequent downloads use cached key 489 - 490 - Upload a second file under the same keyring as A: 491 - 492 - ```bash 493 - echo "second family photo" > /tmp/photo2.txt 494 - A$ opake upload /tmp/photo2.txt --keyring family-photos 495 - ``` 496 - 497 - Save URI as `$KR_DOC2_URI`. 498 - 499 - B downloads without `--keyring-member` (uses cached group key): 500 - 501 - ```bash 502 - B$ opake download --keyring-member $KR_DOC2_URI -o /tmp/photo2-download.txt 503 - diff /tmp/photo2.txt /tmp/photo2-download.txt 504 - ``` 505 - 506 - ### 6.8 Non-member is rejected 507 - 508 - ```bash 509 - C$ opake download --keyring-member $KR_DOC_URI -o /tmp/should-fail.txt 510 - # should error: "not a member of keyring" 511 - ``` 512 - 513 - If C is not available, skip this test. 514 - 515 - ### 6.9 Remove member 516 - 517 - ```bash 518 - A$ opake keyring remove-member family-photos <B-handle> 519 - # prompts about key rotation, type y 520 - # prints: removed <B-handle> from family-photos (key rotated) 521 - 522 - A$ opake keyring ls 523 - # family-photos 1 member(s) 524 - 525 - A$ opake keyring ls -l 526 - # rotation count is now 1 527 - ``` 528 - 529 - ### 6.10 Removed member cannot download new uploads 530 - 531 - After removal, upload a new file under the rotated keyring: 532 - 533 - ```bash 534 - echo "new secret after rotation" > /tmp/photo3.txt 535 - A$ opake upload /tmp/photo3.txt --keyring family-photos 536 - ``` 537 - 538 - B's cached group key is from rotation 0; the document is wrapped under rotation 1: 539 - 540 - ```bash 541 - B$ opake download --keyring-member <photo3-uri> -o /tmp/should-fail.txt 542 - # should error (key unwrap failure — B's cached group key is stale, 543 - # and B is no longer in the keyring members list) 544 - ``` 545 - 546 - --- 547 - 548 - ## 7. Error Cases 549 - 550 - ### 7.1 Download nonexistent file 551 - 552 - ```bash 553 - A$ opake download nonexistent-file.txt 554 - # should error: not found 555 - ``` 556 - 557 - ### 7.2 Invalid AT-URI 558 - 559 - ```bash 560 - A$ opake download "not-a-uri" 561 - # should error: AT-URI parse failure 562 - ``` 563 - 564 - ### 7.3 Wrong account downloads direct file 565 - 566 - ```bash 567 - B$ opake download $DOC_URI -o /tmp/wrong-account.txt 568 - # should error: no wrapped key for DID 569 - ``` 570 - 571 - (Only works if A still has a direct-encrypted file uploaded. Re-upload one if needed.) 572 - 573 - ### 7.4 --grant and --keyring-member conflict 574 - 575 - ```bash 576 - B$ opake download --grant at://x --keyring-member at://y 577 - # should error: clap conflict (cannot use both flags) 578 - ``` 579 - 580 - ### 7.5 --keyring-member on a direct-encrypted document 581 - 582 - ```bash 583 - B$ opake download --keyring-member $SHARED_URI -o /tmp/should-fail.txt 584 - # should error: "document uses direct encryption, not keyring" 585 - ``` 586 - 587 - (Use a URI for a direct-encrypted file on A's PDS.) 588 - 589 - --- 590 - 591 - ## 8. AppView 592 - 593 - The AppView is an Elixir/Phoenix service (`appview/`) that indexes grants and keyrings from the AT Protocol firehose. These tests require Docker (for PostgreSQL) or a running Postgres instance. 594 - 595 - ### 8.1 Start AppView (Docker) 596 - 597 - ```bash 598 - cd appview 599 - docker compose --profile full up --build -d 600 - # Entrypoint auto-creates DB and runs migrations 601 - sleep 5 602 - ``` 603 - 604 - ### 8.2 Health endpoint 605 - 606 - ```bash 607 - curl -s http://127.0.0.1:6100/api/health | jq . 608 - # { 609 - # "indexerConnected": true, 610 - # "cursorTime": "2026-...", 611 - # "cursorAgeSecs": <small number> 612 - # } 613 - ``` 614 - 615 - **Verify:** 616 - - `indexerConnected` is `true` 617 - - `cursorAgeSecs` is small (< 60) 618 - 619 - ### 8.3 Inbox and keyrings require auth 620 - 621 - ```bash 622 - curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:6100/api/inbox?did=did:plc:test 623 - # 401 624 - 625 - curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:6100/api/keyrings?did=did:plc:test 626 - # 401 627 - ``` 628 - 629 - ### 8.4 Share triggers indexing 630 - 631 - With the AppView still running, create a share grant using the CLI (from section 4.2): 632 - 633 - ```bash 634 - A$ opake share shared-file.txt <B-handle> 635 - ``` 636 - 637 - Wait a few seconds for the firehose to deliver the event. 638 - 639 - ### 8.5 Status (after indexing) 640 - 641 - ```bash 642 - docker compose exec appview bin/opake_appview eval "OpakeAppview.Release.status()" 643 - # Cursor: 644 - # Position: ... 645 - # Time: 2026-... 646 - # Lag: <small number>s 647 - # Counts: 648 - # Grants: <non-zero> 649 - # Keyrings: <number> 650 - ``` 651 - 652 - ### 8.6 Cleanup 653 - 654 - ```bash 655 - cd appview 656 - docker compose --profile full down -v 657 - ``` 658 - 659 - --- 660 - 661 - ## 9. Cleanup 662 - 663 - ```bash 664 - # remove test files (some may already be deleted from section 4) 665 - A$ opake rm photo.txt -y 2>/dev/null 666 - A$ opake rm photo2.txt -y 2>/dev/null 667 - A$ opake rm empty.bin -y 2>/dev/null 668 - # etc. 669 - 670 - rm /tmp/test-direct*.txt /tmp/shared-*.txt /tmp/photo*.txt /tmp/empty* /tmp/kr-* /tmp/should-fail.txt /tmp/beach.jpg /tmp/notes.txt /tmp/sunset.jpg /tmp/deleteme.txt 2>/dev/null 671 - 672 - # optionally logout test accounts 673 - opake logout <B-handle> 674 - opake logout <C-handle> 675 - ```
+2 -2
e2e-tests/bun.lock
··· 7 7 "devDependencies": { 8 8 "@playwright/test": "^1.58.2", 9 9 "@types/node": "^22", 10 - "fake-pds": "^0.3.0", 10 + "fake-pds": "^0.4.0", 11 11 "typescript": "^5.7", 12 12 "vitest": "^3.0", 13 13 }, ··· 162 162 163 163 "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], 164 164 165 - "fake-pds": ["fake-pds@0.3.0", "", {}, "sha512-UUZJd78CkCPmuwkkNj7fdtK1kfQcngv+yByyjVocYSyamNXOlonL2hgLEdYR1rNGQY/DcTuNypMplawQM9XC1Q=="], 165 + "fake-pds": ["fake-pds@0.4.0", "", {}, "sha512-MUCxxRkzqtjnCQou7ntcpnWvRkXzRP5av0u8A3IWIePjFOzxIs7DhtOdINdrs9xlRl1qgqf9EWK3gqVA28r3DQ=="], 166 166 167 167 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], 168 168
+1 -5
e2e-tests/global-setup.ts
··· 7 7 import { spawn, type ChildProcess } from "node:child_process"; 8 8 import { writeFileSync, unlinkSync, mkdirSync } from "node:fs"; 9 9 import path from "node:path"; 10 - 11 - export const TEST_ACCOUNTS = [ 12 - { did: "did:plc:alice", handle: "alice.test" }, 13 - { did: "did:plc:bob", handle: "bob.test", password: "bobsecret" }, 14 - ] as const; 10 + import { TEST_ACCOUNTS } from "./helpers/pds.js"; 15 11 16 12 const STATE_FILE = path.join(import.meta.dirname, ".e2e-state.json"); 17 13
+31 -7
e2e-tests/helpers/web-fixture.ts
··· 1 1 // Playwright test fixtures for web e2e tests. 2 2 // 3 - // Provides pdsUrl, webUrl, and resetPds to all web tests. 3 + // Provides pdsUrl, webUrl, resetPds, and browserLogin to all web tests. 4 4 // See global-setup.ts for server lifecycle. 5 5 6 - import { test as base, expect } from "@playwright/test"; 6 + import { test as base, expect, type Page } from "@playwright/test"; 7 7 import { readFileSync } from "node:fs"; 8 8 import path from "node:path"; 9 9 ··· 12 12 readonly webUrl: string; 13 13 } 14 14 15 + let cachedState: E2eState | null = null; 16 + 15 17 function loadState(): E2eState { 18 + if (cachedState) return cachedState; 16 19 const statePath = path.join(import.meta.dirname, "../.e2e-state.json"); 17 - return JSON.parse(readFileSync(statePath, "utf-8")) as E2eState; 20 + cachedState = JSON.parse(readFileSync(statePath, "utf-8")) as E2eState; 21 + return cachedState; 22 + } 23 + 24 + /** 25 + * Perform a full browser-based OAuth login against fake-pds. 26 + * Waits for the redirect chain to complete and the devices page to render. 27 + */ 28 + async function doLogin(page: Page, webUrl: string, handle: string): Promise<void> { 29 + await page.goto(`${webUrl}/devices/login`); 30 + await page.getByLabel("AT Protocol handle").fill(handle); 31 + await page.getByRole("button", { name: /Sign in/ }).click(); 32 + 33 + // Wait for OAuth redirect chain: login → PAR → authorize → callback → /devices 34 + await expect( 35 + page.getByText(/Setting things up|Welcome to Opake|You're all set/), 36 + ).toBeVisible({ timeout: 15_000 }); 18 37 } 19 38 20 39 interface WebFixtures { 21 40 pdsUrl: string; 22 41 webUrl: string; 23 42 resetPds: () => Promise<void>; 43 + browserLogin: (handle: string) => Promise<void>; 24 44 } 25 45 26 46 export const test = base.extend<WebFixtures>({ ··· 35 55 }, 36 56 37 57 resetPds: async ({ pdsUrl }, use) => { 38 - const reset = async () => { 58 + await use(async () => { 39 59 await fetch(`${pdsUrl}/_test/reset`, { method: "POST" }); 60 + }); 61 + }, 62 + 63 + browserLogin: async ({ page, webUrl }, use) => { 64 + const login = async (handle: string) => { 65 + await doLogin(page, webUrl, handle); 40 66 }; 41 - // Reset before the test to ensure clean state 42 - await reset(); 43 - await use(reset); 67 + await use(login); 44 68 }, 45 69 }); 46 70
+1 -1
e2e-tests/package.json
··· 12 12 "devDependencies": { 13 13 "@playwright/test": "^1.58.2", 14 14 "@types/node": "^22", 15 - "fake-pds": "^0.3.0", 15 + "fake-pds": "^0.4.0", 16 16 "typescript": "^5.7", 17 17 "vitest": "^3.0" 18 18 }
+3
e2e-tests/playwright.config.ts
··· 4 4 testDir: "tests/web", 5 5 timeout: 60_000, 6 6 globalSetup: "./global-setup.ts", 7 + // Tests share one fake-pds — use different accounts per file to avoid 8 + // state conflicts. Login tests use resetPds and run serially. 9 + workers: 3, 7 10 use: { 8 11 headless: true, 9 12 screenshot: "only-on-failure",
+145
e2e-tests/tests/web/identity-setup.test.ts
··· 1 + // Identity setup: seed phrase generation, display, and confirmation. 2 + 3 + import { test, expect } from "../../helpers/web-fixture.js"; 4 + 5 + test.describe("fresh account identity setup", () => { 6 + test.beforeEach(async ({ browserLogin }) => { 7 + await browserLogin("alice.test"); 8 + }); 9 + 10 + test("shows welcome screen for fresh account", async ({ page }) => { 11 + await expect(page.getByText(/Welcome to Opake/)).toBeVisible(); 12 + await expect(page.getByText(/Create my key/)).toBeVisible(); 13 + await expect(page).toHaveScreenshot("identity-fresh-welcome.png"); 14 + }); 15 + 16 + test("create-key button starts seed phrase generation", async ({ page }) => { 17 + await page.getByText(/Create my key/).click(); 18 + 19 + // Should show seed phrase grid (24 words) 20 + await expect(page.getByRole("list")).toBeVisible({ timeout: 10_000 }); 21 + }); 22 + 23 + test("seed phrase shows 24 words", async ({ page }) => { 24 + await page.getByText(/Create my key/).click(); 25 + 26 + await expect(page.getByRole("list")).toBeVisible({ timeout: 10_000 }); 27 + 28 + const items = page.getByRole("listitem"); 29 + await expect(items).toHaveCount(24); 30 + }); 31 + 32 + test("continue button disabled until checkbox checked", async ({ page }) => { 33 + await page.getByText(/Create my key/).click(); 34 + await expect(page.getByRole("list")).toBeVisible({ timeout: 10_000 }); 35 + 36 + const continueButton = page.getByRole("button", { name: /Continue/ }); 37 + await expect(continueButton).toBeDisabled(); 38 + 39 + // Check the checkbox 40 + await page.getByLabel(/I have written down/).check(); 41 + await expect(continueButton).toBeEnabled(); 42 + }); 43 + 44 + test("full seed phrase confirmation flow reaches ready state", async ({ 45 + page, 46 + }) => { 47 + // 1. Start key creation 48 + await page.getByText(/Create my key/).click(); 49 + await expect(page.getByRole("list")).toBeVisible({ timeout: 10_000 }); 50 + 51 + // 2. Read the 24 words from the grid (keyed by number, not DOM order — 52 + // CSS grid-cols-4 may reorder the elements visually) 53 + const items = page.getByRole("listitem"); 54 + const words: string[] = new Array(24).fill(""); 55 + const count = await items.count(); 56 + for (let i = 0; i < count; i++) { 57 + const text = await items.nth(i).textContent(); 58 + const match = text?.match(/^(\d+)\.\s*(.+)$/); 59 + if (match) { 60 + const idx = parseInt(match[1]!, 10) - 1; 61 + words[idx] = match[2]!.trim(); 62 + } 63 + } 64 + expect(words.filter(Boolean)).toHaveLength(24); 65 + 66 + // 3. Check the checkbox and continue 67 + await page.getByLabel(/I have written down/).check(); 68 + await page.getByRole("button", { name: /Continue/ }).click(); 69 + 70 + // 4. Fill in confirmation words 71 + await expect(page.getByText(/Confirm your seed phrase/)).toBeVisible(); 72 + 73 + const confirmLabels = page.locator("label").filter({ hasText: /^Word #/ }); 74 + const labelCount = await confirmLabels.count(); 75 + expect(labelCount).toBe(3); 76 + 77 + for (let i = 0; i < labelCount; i++) { 78 + const labelText = await confirmLabels.nth(i).textContent(); 79 + const wordNum = parseInt(labelText?.match(/Word #(\d+)/)?.[1] ?? "0", 10); 80 + const word = words[wordNum - 1]; // 1-based → 0-based 81 + if (word) { 82 + await confirmLabels.nth(i).locator("input").fill(word); 83 + } 84 + } 85 + 86 + // 5. Confirm 87 + await page.getByRole("button", { name: /Confirm/ }).click(); 88 + 89 + // 6. Should reach ready state 90 + await expect(page.getByText(/You're all set/)).toBeVisible({ 91 + timeout: 15_000, 92 + }); 93 + await expect(page).toHaveScreenshot("identity-ready.png"); 94 + }); 95 + }); 96 + 97 + test.describe("seed phrase confirmation failures", () => { 98 + test.beforeEach(async ({ resetPds, browserLogin }) => { 99 + // Reset clears publicKey record left by the success tests above, 100 + // so alice.test gets a fresh identity state again. 101 + await resetPds(); 102 + await browserLogin("alice.test"); 103 + }); 104 + 105 + test("wrong confirmation words show error", async ({ page }) => { 106 + // 1. Generate seed phrase 107 + await page.getByText(/Create my key/).click(); 108 + await expect(page.getByRole("list")).toBeVisible({ timeout: 10_000 }); 109 + 110 + // 2. Check checkbox and continue 111 + await page.getByLabel(/I have written down/).check(); 112 + await page.getByRole("button", { name: /Continue/ }).click(); 113 + await expect(page.getByText(/Confirm your seed phrase/)).toBeVisible(); 114 + 115 + // 3. Fill in WRONG words 116 + const confirmInputs = page 117 + .locator("label") 118 + .filter({ hasText: /^Word #/ }) 119 + .locator("input"); 120 + const inputCount = await confirmInputs.count(); 121 + for (let i = 0; i < inputCount; i++) { 122 + await confirmInputs.nth(i).fill("wrongword"); 123 + } 124 + 125 + // 4. Submit — should show error 126 + await page.getByRole("button", { name: /Confirm/ }).click(); 127 + await expect(page.locator("[role='alert']")).toBeVisible(); 128 + }); 129 + 130 + test("back button returns to seed phrase display", async ({ page }) => { 131 + await page.getByText(/Create my key/).click(); 132 + await expect(page.getByRole("list")).toBeVisible({ timeout: 10_000 }); 133 + 134 + await page.getByLabel(/I have written down/).check(); 135 + await page.getByRole("button", { name: /Continue/ }).click(); 136 + await expect(page.getByText(/Confirm your seed phrase/)).toBeVisible(); 137 + 138 + // Click back 139 + await page.getByRole("button", { name: /Back/ }).click(); 140 + 141 + // Should show seed phrase grid again 142 + await expect(page.getByRole("list")).toBeVisible(); 143 + await expect(page.getByRole("listitem")).toHaveCount(24); 144 + }); 145 + });
e2e-tests/tests/web/identity-setup.test.ts-snapshots/identity-fresh-welcome-chromium-darwin.png

This is a binary file and will not be displayed.

e2e-tests/tests/web/identity-setup.test.ts-snapshots/identity-ready-chromium-darwin.png

This is a binary file and will not be displayed.

+109
e2e-tests/tests/web/login.test.ts
··· 1 + // Login flow: full OAuth browser flow against fake-pds. 2 + 3 + import { test, expect } from "../../helpers/web-fixture.js"; 4 + 5 + test.describe("login page", () => { 6 + test("renders login form", async ({ page, webUrl }) => { 7 + await page.goto(`${webUrl}/devices/login`); 8 + 9 + await expect(page.getByLabel("AT Protocol handle")).toBeVisible(); 10 + await expect(page.getByRole("button", { name: /Sign in/ })).toBeVisible(); 11 + await expect(page).toHaveScreenshot("login-form.png"); 12 + }); 13 + 14 + test("sign-in button is disabled while empty", async ({ page, webUrl }) => { 15 + await page.goto(`${webUrl}/devices/login`); 16 + 17 + const input = page.getByLabel("AT Protocol handle"); 18 + await expect(input).toHaveValue(""); 19 + 20 + // HTML5 required attribute — submit should not fire 21 + const button = page.getByRole("button", { name: /Sign in/ }); 22 + await button.click(); 23 + 24 + // Still on login page (form validation prevented submit) 25 + await expect(input).toBeVisible(); 26 + }); 27 + }); 28 + 29 + test.describe("OAuth login flow", () => { 30 + test("successful login reaches devices page", async ({ page, webUrl }) => { 31 + await page.goto(`${webUrl}/devices/login`); 32 + 33 + await page.getByLabel("AT Protocol handle").fill("alice.test"); 34 + await page.getByRole("button", { name: /Sign in/ }).click(); 35 + 36 + // Wait for full OAuth redirect chain to complete 37 + await expect( 38 + page.getByText(/Setting things up|Welcome to Opake|You're all set/), 39 + ).toBeVisible({ timeout: 15_000 }); 40 + 41 + // Should be on /devices (not /devices/login) 42 + expect(page.url()).toContain("/devices"); 43 + expect(page.url()).not.toContain("/login"); 44 + 45 + await expect(page).toHaveScreenshot("post-login-devices.png"); 46 + }); 47 + 48 + test("invalid handle shows error", async ({ page, webUrl }) => { 49 + await page.goto(`${webUrl}/devices/login`); 50 + 51 + await page.getByLabel("AT Protocol handle").fill("nobody.test"); 52 + await page.getByRole("button", { name: /Sign in/ }).click(); 53 + 54 + // Should show an error (handle not found on fake-pds) 55 + await expect(page.locator("[role='alert']")).toBeVisible({ timeout: 10_000 }); 56 + await expect(page).toHaveScreenshot("login-error-invalid-handle.png"); 57 + }); 58 + 59 + // Loading state (button disabled during auth) is too transient to assert 60 + // reliably — the redirect completes before Playwright can check. 61 + }); 62 + 63 + test.describe("OAuth callback", () => { 64 + test("direct callback access without params shows error", async ({ 65 + page, 66 + webUrl, 67 + }) => { 68 + // Navigate directly to callback without code/state 69 + await page.goto(`${webUrl}/devices/oauth-callback`); 70 + 71 + // Should show login failed or redirect to login 72 + await expect( 73 + page.getByText(/Login failed|Sign in/), 74 + ).toBeVisible({ timeout: 10_000 }); 75 + 76 + await expect(page).toHaveScreenshot("callback-no-params.png"); 77 + }); 78 + }); 79 + 80 + test.describe("auth guards", () => { 81 + test("unauthenticated access to cabinet redirects to login", async ({ 82 + page, 83 + webUrl, 84 + }) => { 85 + await page.goto(`${webUrl}/cabinet/files`); 86 + 87 + // Should redirect to login page 88 + await expect(page.getByLabel("AT Protocol handle")).toBeVisible({ 89 + timeout: 10_000, 90 + }); 91 + expect(page.url()).toContain("/login"); 92 + }); 93 + 94 + test("logged-in user on login page redirects to devices", async ({ 95 + page, 96 + webUrl, 97 + browserLogin, 98 + }) => { 99 + await browserLogin("alice.test"); 100 + 101 + // Navigate to login page — should redirect away since already logged in 102 + await page.goto(`${webUrl}/devices/login`); 103 + 104 + // Should not show login form 105 + await expect( 106 + page.getByText(/Welcome to Opake|You're all set|Setting things up/), 107 + ).toBeVisible({ timeout: 10_000 }); 108 + }); 109 + });
e2e-tests/tests/web/login.test.ts-snapshots/callback-no-params-chromium-darwin.png

This is a binary file and will not be displayed.

e2e-tests/tests/web/login.test.ts-snapshots/login-error-invalid-handle-chromium-darwin.png

This is a binary file and will not be displayed.

e2e-tests/tests/web/login.test.ts-snapshots/login-form-chromium-darwin.png

This is a binary file and will not be displayed.

e2e-tests/tests/web/login.test.ts-snapshots/post-login-devices-chromium-darwin.png

This is a binary file and will not be displayed.

+90
e2e-tests/tests/web/settings.test.ts
··· 1 + // Settings page: account info display and preferences. 2 + 3 + import { test, expect } from "../../helpers/web-fixture.js"; 4 + 5 + test.describe("settings page", () => { 6 + test.beforeEach(async ({ browserLogin }) => { 7 + // Use bob.test to avoid PDS state conflicts with other parallel test files 8 + await browserLogin("bob.test"); 9 + }); 10 + 11 + test("displays account info", async ({ page, webUrl, pdsUrl }) => { 12 + await page.goto(`${webUrl}/cabinet/settings`); 13 + 14 + // Account section — use definition list role to avoid sidebar ambiguity 15 + const definitions = page.getByRole("definition"); 16 + await expect(definitions.filter({ hasText: "bob.test" })).toBeVisible({ timeout: 10_000 }); 17 + await expect(definitions.filter({ hasText: /did:plc:bob/ })).toBeVisible(); 18 + await expect(definitions.filter({ hasText: pdsUrl })).toBeVisible(); 19 + 20 + // PDS URL contains a random port — allow minor pixel differences 21 + await expect(page).toHaveScreenshot("settings-account-info.png", { 22 + maxDiffPixelRatio: 0.02, 23 + }); 24 + }); 25 + 26 + test("displays preferences section", async ({ page, webUrl }) => { 27 + await page.goto(`${webUrl}/cabinet/settings`); 28 + 29 + // Preferences section 30 + await expect(page.getByText("Preferences", { exact: true })).toBeVisible({ timeout: 10_000 }); 31 + await expect(page.getByLabel("Enable telemetry")).toBeVisible(); 32 + await expect(page.getByLabel("AppView URL")).toBeVisible(); 33 + }); 34 + 35 + test("telemetry toggle changes state", async ({ page, webUrl }) => { 36 + await page.goto(`${webUrl}/cabinet/settings`); 37 + 38 + const toggle = page.getByLabel("Enable telemetry"); 39 + await expect(toggle).toBeVisible({ timeout: 10_000 }); 40 + 41 + const initialState = await toggle.isChecked(); 42 + await toggle.click(); 43 + 44 + // State should have flipped 45 + await expect(toggle).toBeChecked({ checked: !initialState }); 46 + }); 47 + 48 + test("save button disabled when appview URL unchanged", async ({ 49 + page, 50 + webUrl, 51 + }) => { 52 + await page.goto(`${webUrl}/cabinet/settings`); 53 + 54 + await expect(page.getByLabel("AppView URL")).toBeVisible({ 55 + timeout: 10_000, 56 + }); 57 + 58 + const saveButton = page.getByRole("button", { name: /Save/ }); 59 + await expect(saveButton).toBeDisabled(); 60 + }); 61 + 62 + test("save button enabled after changing appview URL", async ({ 63 + page, 64 + webUrl, 65 + }) => { 66 + await page.goto(`${webUrl}/cabinet/settings`); 67 + 68 + const input = page.getByLabel("AppView URL"); 69 + await expect(input).toBeVisible({ timeout: 10_000 }); 70 + 71 + await input.fill("http://localhost:9999"); 72 + 73 + const saveButton = page.getByRole("button", { name: /Save/ }); 74 + await expect(saveButton).toBeEnabled(); 75 + }); 76 + }); 77 + 78 + test.describe("settings access control", () => { 79 + test("unauthenticated access redirects to login", async ({ 80 + page, 81 + webUrl, 82 + }) => { 83 + await page.goto(`${webUrl}/cabinet/settings`); 84 + 85 + await expect(page.getByLabel("AT Protocol handle")).toBeVisible({ 86 + timeout: 10_000, 87 + }); 88 + expect(page.url()).toContain("/login"); 89 + }); 90 + });
e2e-tests/tests/web/settings.test.ts-snapshots/settings-account-info-chromium-darwin.png

This is a binary file and will not be displayed.

+6 -6
e2e-tests/tests/web/smoke.test.ts
··· 1 - // Smoke test: verify the web app loads against fake-pds. 1 + // Smoke test: verify infrastructure is working. 2 2 3 3 import { test, expect } from "../../helpers/web-fixture.js"; 4 4 5 - test("app loads and shows login page", async ({ page, webUrl }) => { 6 - await page.goto(`${webUrl}/devices/login`); 7 - await expect(page.locator("body")).toBeVisible(); 8 - }); 9 - 10 5 test("fake-pds is reachable", async ({ pdsUrl }) => { 11 6 const res = await fetch(`${pdsUrl}/.well-known/oauth-authorization-server`); 12 7 expect(res.status).toBe(200); 13 8 const body = (await res.json()) as { issuer: string }; 14 9 expect(body.issuer).toBe(pdsUrl); 15 10 }); 11 + 12 + test("web app loads", async ({ page, webUrl }) => { 13 + await page.goto(webUrl); 14 + await expect(page.locator("body")).toBeVisible(); 15 + });
+10 -17
test-data/README.md
··· 1 - # test-data 1 + # Bakker Family Archive 2 2 3 - this is where we keep the "real world" data to make sure the system doesn't choke on your reading lists. 3 + Digital backup of the household's shared notes, school projects, and grocery lists. Keeping things organized so we don't forget the cat's vet appointment again. 4 4 5 - it's a collection of miscellaneous markdown files used for integration testing, indexer validation, and making sure the appview actually renders something that looks like a human wrote it. 6 - 7 - ## what's in here? 5 + ## What's in here? 8 6 9 - - `grocery-lists/`: exactly what it sounds like. the mundane details of survival. 7 + - `grocery-lists/`: Weekly meal planning and Target runs. 10 8 - `notes/`: 11 - - `anarchy-and-praxis/`: for when the system needs to handle some spicy political theory. 12 - - `queer-theory-reading-list.md`: a curated list for when your brain needs melting. 13 - - `todo-list.md`: the ever-growing pile of tasks. 14 - - `poetry/`: 15 - - `null-pointer.md`: a very clever (if a bit on the nose) poem about crashes. 16 - - `the-void.md`: etc. 9 + - `home-maintenance/`: Budgeting for the new roof and weekend chore lists. 10 + - `fantasy-creatures/`: Thijs's encyclopedia of mythical beasts and his very serious roly-poly research. 11 + - `garden/`: Anika's planting schedule and the battle against the aphids. 12 + - `poetry/`: Lieke's creative writing assignments and Bram's attempt at "found poetry" from old tech manuals. 17 13 18 - ## usage 19 - 20 - these files are generally ingested by the indexer during testing or uploaded via the `tools/upload-test-data.sh` script if you're feeling adventurous. 14 + ## Usage 21 15 22 - don't put actual secrets in here. it's test data. keep it public. 23 - // REMOVE: Claude says don't be a dummy and leak your keys in a test folder. 16 + This is a private-ish family archive. If you're not a Bakker, why are you here? Please don't delete the roly-poly photos, Thijs will actually cry.
+7 -5
test-data/grocery-lists/week-10.md
··· 1 1 # grocery list - week 10 2 2 3 - - [ ] oat milk (the expensive kind) 4 - - [ ] kale (for the "health" aesthetic) 5 - - [ ] coffee beans (lifeblood) 6 - - [ ] tofu 7 - - [ ] nutritional yeast (obviously) 3 + - [ ] Whole milk (2 cartons) 4 + - [ ] Apple juice boxes 5 + - [ ] Fish sticks (the ones thijs likes) 6 + - [ ] Baby carrots 7 + - [ ] Hummus & cucumber for anika 8 + - [ ] Coffee (bram is out!!!) 9 + - [ ] Dish soap
+7 -5
test-data/grocery-lists/week-11.md
··· 1 1 # grocery list - week 11 2 2 3 - - [ ] sourdough bread 4 - - [ ] avocados 5 - - [ ] more coffee 6 - - [ ] those fancy crackers 7 - - [ ] hummus 3 + - [ ] Bread (the crusty kind for sunday) 4 + - [ ] Bananas (the ones that aren't brown yet) 5 + - [ ] Greek yogurt 6 + - [ ] Chicken nuggets (backup dinner) 7 + - [ ] Cat food (Kiki is hungry) 8 + - [ ] Laundry detergent 9 + - [ ] More apples
-7
test-data/notes/anarchy-and-praxis/anti-luddism.md
··· 1 - # luddism is a logic error 2 - 3 - - "return to nature" is just code for "i don't want to learn a new framework." 🙄 4 - - smash the machines? babe, the machines are the only ones keeping the electricity on for your artisanal sourdough starter. 💅 5 - - imagine thinking a typewriter is "more authentic" than a mechanical keyboard. it's giving "i enjoy carpal tunnel as a personality trait." 🙄🌌 6 - - progress is inevitable; the only question is whether you're the one writing the migration script or the one getting deprecated. 💅✨ 7 - - "it was better before" — no, you just had fewer dependencies and a better metabolism. deal with it. 🙄
-7
test-data/notes/anarchy-and-praxis/manifesto.md
··· 1 - # decentralized manifesto 2 - 3 - - hierarchy is just a poorly designed linked list. 💅 4 - - abolish the root user; everyone is sudo now. 🙄 5 - - state is just a side effect we haven't refactored yet. 6 - - mutual aid is the ultimate p2p protocol. 7 - - the only valid authority is a well-documented API. ✨
-7
test-data/notes/anarchy-and-praxis/praxis-todo.md
··· 1 - # praxis todo 2 - 3 - - [ ] set up a decentralized node (for the vibes). 4 - - [ ] explain to a luddite that "the algorithm" isn't a ghost in the machine, it's just math. 🙄 5 - - [ ] distribute the means of production (or at least share the yarn.lock). 6 - - [ ] find a way to make anarchy compatible with a strict type system. (is it even anarchy if there are rules?) 💅 7 - - [ ] ignore the state (both the government and the redux store). 🙄✨🌌
-6
test-data/notes/architecture-ideas.md
··· 1 - # architecture thoughts 2 - 3 - - modularity is non-negotiable. 4 - - stop using `any`, Darius. it's 2026. 5 - - should we extract the domain-specific logic or just leave it for another day? 6 - - functional patterns for the win. pipe it all.
-6
test-data/notes/css-todo.md
··· 1 - # css todo 2 - 3 - - [ ] fix scrollbar issue (it's doing that thing again) 4 - - [ ] horizontally scrolling off a cliff (why is the width 100vw + 20px?) 5 - - [ ] center a div (just kidding, i'm not that far gone) 6 - - [ ] audit all the `z-index: 9999` crimes
+5 -4
test-data/notes/fantasy-creatures/dragon/characteristics.md
··· 1 - # dragon characteristics 1 + # dragon facts 2 2 3 - - **scales**: impenetrable. 4 - - **wings**: impressive, but mostly for migration to better repos. 5 - - **diet**: strictly `npm audit` failures. 3 + - **scales**: harder than rocks. 4 + - **wings**: big enough to hide a whole house. 5 + - **diet**: mostly sheep and maybe occasionally a spicy taco. 6 + - **fire**: hotter than an oven.
+5 -5
test-data/notes/fantasy-creatures/dragon/notes.md
··· 1 - # dragon 1 + # the red dragon 2 2 3 - - hoarding behavior (usually rare typescript libraries) 4 - - fire-breathing is just for show; the real damage is in the logic errors. 5 - - prefers dark, cold servers. 6 - - hates being called a "large lizard." 3 + - hoards gold and silver coins (and maybe stolen legos). 4 + - lives in a cave on top of the mountain. 5 + - sleeps for 100 years at a time. 6 + - only likes knights that are nice.
+6 -4
test-data/notes/fantasy-creatures/griffin/notes.md
··· 1 - # griffin 1 + # the griffin 2 2 3 - - half-eagle, half-lion, all confusion. 4 - - likes high-altitude nested directories. 5 - - very protective of its "gold" (which is just a `.env` file it shouldn't have). 3 + - half-eagle (the top part), half-lion (the bottom part). 4 + - lives in the high mountains where it's cold. 5 + - guards nests full of gold eggs. 6 + - can fly very fast but also runs like a cat. 7 + - he's the king of both the sky and the ground.
+6 -4
test-data/notes/fantasy-creatures/phoenix/notes.md
··· 1 - # phoenix 1 + # the phoenix 2 2 3 - - dies and rises from its own `node_modules`. 4 - - very hot, very loud. 5 - - burns out every two weeks. (relatable?) 3 + - he's made of fire and red feathers. 4 + - when he gets too old, he turns into a pile of ash. 5 + - then a baby phoenix pops out of the ash! 6 + - he lives forever (lieke wants one but anika says no). 7 + - his tears can heal a broken heart.
+5 -4
test-data/notes/fantasy-creatures/roly-poly/defense.md
··· 1 - # defense mechanism 1 + # how roly-polies stay safe 2 2 3 3 - [x] roll into ball. 4 - - [x] ignore all `rejected` promises. 5 - - [ ] wait for someone else to fix the `any` types. 6 - - [x] remain adorable and impervious. 4 + - [x] hide under damp logs. 5 + - [ ] wait for the big bird to go away. 6 + - [x] stay cool (don't dry out!). 7 + - [x] armor plates look like tiny tanks.
+7 -5
test-data/notes/fantasy-creatures/roly-poly/notes.md
··· 1 - # the roly-poly 1 + # the roly-poly (thijs's field notes) 2 2 3 - - armor for when the code is too judgmental. 4 - - rolls into a perfect circle when someone asks "how's the project going?" 5 - - tiny legs, big dreams. 6 - - technically an isopod, which is also a type of database structure, probably. 3 + - actual name: armadillidium vulgare (very hard to say). 4 + - rolls into a perfect circle when he's scared. 5 + - 14 tiny legs, i counted them. 6 + - he lives under the rock by the shed. 7 + - he likes the rotten strawberries in the compost. 8 + - anika says he's not a bug, he's a "crustacean."
+7
test-data/notes/garden/bulb-schedule.md
··· 1 + # bulb planting schedule 2 + 3 + - Tulips: October/November 4 + - Daffodils: Late September 5 + - Crocus: Early October 6 + - Alliums: November 7 + - Remember to mark where we planted them so Bram doesn't dig them up again!
+7
test-data/notes/garden/garden-layout.md
··· 1 + # garden layout ideas 2 + 3 + - Pergola in the back corner? 4 + - Move the peonies; they aren't getting enough sun near the shed. 5 + - Small pond for the local frogs (and more roly-polies?) 6 + - Herb garden closer to the kitchen window. 7 + - Maybe some lavender to keep the mosquitoes away.
+7
test-data/notes/home-maintenance/budget-planning.md
··· 1 + # 2026 budget planning 2 + 3 + - Roof replacement: €12,000 (Ouch. Maybe next year?) 4 + - Solar panel installment: €8,000 (Anika's pet project) 5 + - Kitchen backsplash: €500 6 + - New bikes for Thijs and Lieke: €400 (Check Marktplaats first) 7 + - Summer vacation to Veluwe: €1,200
+7
test-data/notes/home-maintenance/smart-home-setup.md
··· 1 + # smart home setup (the "bram special") 2 + 3 + - Replace the kitchen hub (it keeps losing connection) 4 + - Automate the garden lights for Anika 5 + - Figure out why the doorbell notifies everyone but me 6 + - Install the smart thermostat before winter 7 + - Label the damn router properly so the kids stop asking for the wifi password
+8
test-data/notes/home-maintenance/weekend-chores.md
··· 1 + # weekend chores list 2 + 3 + - [ ] Mow the lawn (Thijs) 4 + - [ ] Clean the gutters (Bram) 5 + - [ ] Sort the recycling (Lieke) 6 + - [ ] Wash the windows (Anika) 7 + - [ ] Take the roly-polies out for a "walk" (Thijs - what does this even mean?) 8 + - [ ] Meal prep for Monday
+7
test-data/notes/parenting-books.md
··· 1 + # parenting & reading list 2 + 3 + - "The Montessori Toddler" (still relevant?) 4 + - "How to Talk So Kids Will Listen" (Lieke's been a handful lately) 5 + - "The Dutch Way of Parenting" (for Bram) 6 + - "Raising a Nature Lover" (Thijs is already a pro) 7 + - Renew library cards on Tuesday!
-7
test-data/notes/queer-theory-reading-list.md
··· 1 - # queer theory reading list 2 - 3 - - *The History of Sexuality* - Foucault (a classic, if you're into that sort of thing) 4 - - *Gender Trouble* - Judith Butler (for when you want to feel your brain melting) 5 - - *Cruising Utopia* - José Esteban Muñoz 6 - - *Stone Butch Blues* - Leslie Feinberg 7 - - *Sister Outsider* - Audre Lorde
+7 -5
test-data/notes/todo-list.md
··· 1 - # todo 1 + # family todo list 2 2 3 - - [ ] refactor that one messy component. you know which one. 4 - - [ ] actually drink enough water. 5 - - [ ] look into `zod` schema validation for the new API. 6 - - [ ] remember why we do this. (dopamine?) 3 + - [ ] Clean the guest room for Grandma's visit. 4 + - [ ] Sign up for Thijs's swimming lessons (don't forget the goggles). 5 + - [ ] Schedule Kiki's vet appointment. 6 + - [ ] Research campsites in the Veluwe for the summer. 7 + - [ ] Find the missing remote. 8 + - [ ] Actually drink enough water (Bram, this means you).
+9 -9
test-data/poetry/null-pointer.md
··· 1 - # null pointer 1 + # the computer crashed (lieke's school assignment) 2 2 3 - reaching for something 4 - that isn't there. 5 - a memory address 6 - pointing to the abyss. 7 - it's not a failure, 8 - it's just... 9 - nothing. 10 - but the crash is real. 3 + the screen turned blue, 4 + and i did too. 5 + i lost my homework, 6 + and my minecraft world too. 7 + it didn't save, 8 + it just gave up. 9 + i think the computer 10 + needs a break.
+8 -12
test-data/poetry/quinn-swoop.md
··· 1 - # bird's eye view (quinn-inspired) 1 + # after the storm (found poetry from the manual) 2 2 3 - high above the 4 - static, 5 - the bird sees what 6 - the compiler missed. 7 - swoop down, 8 - mark the vulnerability, 9 - and then... 10 - silence. 11 - it's not a bug, 12 - it's a tactical advantage. 13 - (or so we tell the users) 3 + loose debris, 4 + clogged drainage, 5 + ensure the unit is level. 6 + disconnect power, 7 + wait five minutes, 8 + then try again. 9 + (bram: it didn't work, i'm calling the repair guy)
+8 -11
test-data/poetry/roly-poly.md
··· 1 - # crustacean armor 1 + # the roly-poly 2 2 3 3 fourteen legs and a 4 4 segmented heart. 5 - when the world 6 - starts to throw 7 - unhandled exceptions, 8 - i roll. 5 + when the big world 6 + is too much to start, 7 + he rolls. 9 8 a perfect sphere 10 - of pure denial. 11 - impenetrable, 12 - gray, 13 - and waiting for the 14 - garbage collector 15 - to pass me by. 9 + of gray and shine. 10 + he's not a bug, 11 + he's a friend of mine. 12 + (thijs - 2nd grade)
+9 -8
test-data/poetry/the-void.md
··· 1 - # the void 1 + # the empty room (lieke) 2 2 3 - it's a dark space 4 - with no types to guide us. 5 - we're just mapping through 6 - the existential dread of 7 - undefined behavior. 8 - it's quiet here. 9 - (until the compiler shouts) 3 + no toys on the floor, 4 + no books on the bed. 5 + everything's put away, 6 + just like mom said. 7 + it's too quiet now, 8 + like a library or a cloud. 9 + i'm going to go outside 10 + and be very loud.