AtlatestRepositorylantern
lantern / tree / lantern-system / testtest-lantern-system.sgl
1
;;; Headless test suite for the lantern-system fs + pty plugin.2
;;;3
;;; Proves the whole Lantern-side capability path with NO GTK/WebKit window:4
;;;5
;;; - fs commands over (sigil system fs): read-dir / stat / read-file /6
;;; write-file / mkdir / rename / delete, plus grant-scope enforcement.7
;;; - pty commands over (sigil system session): open a REAL shell, drive it8
;;; (write / resize / signal), stream its output as pty.data events, observe9
;;; pty.exit, and prove CREDIT-BASED BACKPRESSURE (a flooding `yes` caps at10
;;; the window until credited).11
;;; - The full bridge round-trip: JSON in -> registry (allowlist) -> handler ->12
;;; _resolve / _emit JS out, through a fake recording webview backend — the13
;;; exact path a real Lantern window drives, minus the window.14
;;;15
;;; Two harness tiers: DIRECT (call a command's handler with an args dict + a16
;;; capture ctx, so output bytes are inspectable) and BRIDGE (drive JSON through17
;;; the real (lantern bridge), asserting the JS the page would receive).19
(import (sigil core)20
(sigil test)21
(sigil string)22
(sigil io)23
(sigil fs)24
(sigil path)25
(sigil process) ; getenv (fs.home tests)26
(sigil async)27
(lantern-system base64)28
(sigil system grant)29
(lantern registry)30
(lantern webview)31
(lantern bridge)32
(lantern serve) ; make-servable-registry / make-fs-resolver33
(lantern-system plugin)34
(lantern-system fs) ; fs-serve-commands35
(lantern-system pty))37
;; ============================================================38
;; Harness helpers39
;; ============================================================41
;; Find a command's handler by name in a lantern-system-commands list.42
(define (handler-for cmds name)43
(let loop ((cs cmds))44
(cond ((null? cs) (error "no such command" name))45
((string=? (dict-ref (car cs) name: #f) name) (dict-ref (car cs) handler: #f))46
(else (loop (cdr cs))))))48
;; A ctx that CAPTURES emitted (event . payload) pairs (newest first), so a49
;; direct handler call can be inspected without a webview. emit-bulk (the50
;; bridge's bulk lane, used by the pty data pump) is normalized to the same51
;; payload shape (bytes: carries the base64) so the collectors below see both.52
(define (capture-ctx)53
(let ((events (make-vector 1 (list))))54
(dict emit: (lambda (ev payload)55
(vector-set! events 0 (cons (cons ev payload) (vector-ref events 0))))56
emit-bulk: (lambda (ev meta b64)57
(vector-set! events 058
(cons (cons ev (dict-set meta bytes: b64))59
(vector-ref events 0))))60
captured: events)))62
(define (ctx-events ctx) (reverse (vector-ref (dict-ref ctx captured:) 0)))63
(define (events-of ctx name)64
(filter (lambda (e) (string=? (car e) name)) (ctx-events ctx)))65
(define (saw-event? ctx name) (pair? (events-of ctx name)))67
;; Concatenate the decoded bytes of every pty.data event into a string.68
;; (base64-decode returns a bytevector — the wire is binary; utf8->string it69
;; for text assertions.)70
(define (collected-text ctx)71
(fold-left72
(lambda (acc e) (string-append acc (utf8->string (base64-decode (dict-ref (cdr e) bytes:)))))73
""74
(events-of ctx "pty.data")))76
(define (collected-byte-count ctx) (string-length (collected-text ctx)))78
(define (b64 s) (base64-encode s))80
;; ============================================================81
;; fs commands (direct, grant scoped to a temp dir)82
;; ============================================================84
;; A fresh temp tree + a grants table scoped to it, plus the command handlers.85
(define (fs-fixture)86
(let* ((dir (make-temp-directory))87
(g (make-grants)))88
(grant-add! g (string-append "fs:rw:" dir))89
(write-file-string (path-join dir "a.txt") "hello A")90
(ensure-directory (path-join dir "sub"))91
(write-file-string (path-join (path-join dir "sub") "b.txt") "bee")92
(dict dir: dir g: g cmds: (lantern-system-commands g))))94
(test "fs.stat returns a JSON-safe stat with a string type"95
(let* ((fx (fs-fixture))96
(stat (handler-for (dict-ref fx cmds:) "fs.stat")))97
(let ((r (stat (dict path: (path-join (dict-ref fx dir:) "a.txt")) #f)))98
(assert-equal (dict-ref r type: #f) "regular")99
(assert-equal (dict-ref r size: #f) 7)100
(assert-equal (dict-ref r name: #f) "a.txt")101
(assert-true (string? (dict-ref r type: #f))))))103
;; ---- fs.serve-file / the lantern://local/<handle> round-trip ----104
;; The load-bearing new path: a grant-checked command registers a real file, the105
;; handle-scoped fs route then serves its bytes, and unserve revokes it. The106
;; command and the route share ONE registry, exactly as the plugin wires them.108
(test "fs.serve-file registers a file the fs route serves; unserve revokes it"109
(let* ((dir (make-temp-directory))110
(g (make-grants))111
(_ (grant-add! g (string-append "fs:rw:" dir)))112
(img (path-join dir "pic.png"))113
(__ (write-file-string img "PNGDATA"))114
(reg (make-servable-registry))115
(serve-cmds (fs-serve-commands g reg))116
(serve (handler-for serve-cmds "fs.serve-file"))117
(unserve (handler-for serve-cmds "fs.unserve-file"))118
(resolve (make-fs-resolver reg))119
(r (serve (dict path: img) #f))120
(handle (dict-ref r handle: #f)))121
(assert-true (string? handle))122
(assert-equal (dict-ref r url: #f) (string-append "lantern://local/" handle))123
;; the fs route now serves the registered file (200 + real-extension mime)124
(let ((resp (resolve (string-append "/" handle))))125
(assert-equal (dict-ref resp status: #f) 200)126
(assert-equal (dict-ref resp mime: #f) "image/png"))127
;; unserve revokes the handle -> 404 (authority does not outlive the view)128
(unserve (dict handle: handle) #f)129
(assert-equal (dict-ref (resolve (string-append "/" handle)) status: #f) 404)))131
;; Registration HONORS the grant scope (fs-stat gates it): a path outside the132
;; grant raises, so a scoped embedding cannot register a file it may not read.133
(test "fs.serve-file refuses a path outside the grant scope"134
(let* ((dir (make-temp-directory))135
(g (make-grants))136
(_ (grant-add! g (string-append "fs:rw:" dir)))137
(reg (make-servable-registry))138
(serve (handler-for (fs-serve-commands g reg) "fs.serve-file"))139
(raised (guard (e (#t #t)) (serve (dict path: "/etc/hostname") #f) #f)))140
(assert-true raised)141
;; and nothing got registered for a would-be handle142
(assert-equal (servable-lookup reg "f1") #f)))144
;; fs.read-dir now ships the listing's TSV over the BULK lane (bulk-b64) to skip145
;; the O(n²) json-encode. Decode it back to rows — the test-side mirror of146
;; (slate providers lantern) parse-dir-tsv — so these tests assert on the same147
;; entries the folder view sees.148
(define (read-dir-rows r)149
(let ((tsv (utf8->string (base64-decode (dict-ref r bulk-b64: "")))))150
(if (string=? tsv "")151
'()152
(map (lambda (line)153
(let ((f (string-split line "\t")))154
(dict name: (car f) type: (cadr f) size: (or (string->number (caddr f)) 0))))155
(string-split tsv "\n")))))156
(define (read-dir-total r) (dict-ref (dict-ref r meta: #{}) total: 0))157
;; The "⋯ +N more (listing capped)" marker row (name starts with U+22EF).158
(define (cap-marker-row? e)159
(let ((nm (dict-ref e name: "")))160
(and (> (string-length nm) 0) (= (char->integer (string-ref nm 0)) #x22EF))))162
(test "fs.read-dir lists entries with kinds"163
(let* ((fx (fs-fixture))164
(rd (handler-for (dict-ref fx cmds:) "fs.read-dir")))165
(let* ((r (rd (dict path: (dict-ref fx dir:)) #f))166
(entries (read-dir-rows r))167
(names (map (lambda (e) (dict-ref e name: #f)) entries)))168
(assert-true (member "a.txt" names))169
(assert-true (member "sub" names))170
(let ((sub (let loop ((es entries))171
(cond ((null? es) #f)172
((string=? (dict-ref (car es) name: #f) "sub") (car es))173
(else (loop (cdr es)))))))174
(assert-equal (dict-ref sub type: #f) "directory")))))176
(test "fs.read-dir returns entries in sigil-system's canonical order"177
;; This is the ONLY path Slate's folder view and find-file actually see, and it178
;; does NOT go through sigil-system's fs-read-dir (which stats every name and179
;; overflows at /gnu/store scale) — so sorting there alone would never reach a180
;; user. It calls fs-sort-entries on the entries it kept instead, which is what181
;; keeps this order identical to fs-read-dir's without re-deriving the rule.182
(let* ((fx (fs-fixture))183
(dir (dict-ref fx dir:))184
(g (dict-ref fx g:))185
(rd (handler-for (dict-ref fx cmds:) "fs.read-dir")))186
;; the fixture holds a.txt + sub/; add the other two tiers, out of order187
(write-file-string (path-join dir "README.md") "r")188
(write-file-string (path-join dir ".gitignore") "i")189
(ensure-directory (path-join dir ".git"))190
(let* ((r (rd (dict path: dir) #f))191
(names (map (lambda (e) (dict-ref e name: #f)) (read-dir-rows r))))192
;; hidden dirs, dirs, hidden files, files — alphabetical within each tier193
(assert-equal names (list ".git" "sub" ".gitignore" "a.txt" "README.md")))))195
(test "fs.read-dir honors limit: and always reports the full total"196
(let* ((fx (fs-fixture))197
(rd (handler-for (dict-ref fx cmds:) "fs.read-dir")))198
;; the fixture dir has 2 top-level entries (a.txt + sub/)199
(let* ((r (rd (dict path: (dict-ref fx dir:) limit: 1) #f))200
(rows (read-dir-rows r)))201
;; total is the FULL count (in meta:), regardless of the cap202
(assert-equal (read-dir-total r) 2)203
;; 1 real kept entry, plus a visible "capped" marker row204
(assert-equal (length (filter (lambda (e) (not (cap-marker-row? e))) rows)) 1)205
(assert-equal (length (filter cap-marker-row? rows)) 1))206
;; no limit: everything, total matches, no marker207
(let* ((r (rd (dict path: (dict-ref fx dir:)) #f))208
(rows (read-dir-rows r)))209
(assert-equal (length rows) 2)210
(assert-equal (read-dir-total r) 2)211
(assert-equal (length (filter cap-marker-row? rows)) 0))))213
(test "fs.read-file returns the file bytes on the bulk lane (utf8 meta)"214
(let* ((fx (fs-fixture))215
(rf (handler-for (dict-ref fx cmds:) "fs.read-file")))216
(let ((r (rf (dict path: (path-join (dict-ref fx dir:) "a.txt")) #f)))217
(assert-equal (utf8->string (base64-decode (dict-ref r bulk-b64: #f))) "hello A")218
(assert-equal (dict-ref (dict-ref r meta: #{}) encoding: #f) "utf8"))))220
(test "fs.read-file base64 round-trips the bytes on the bulk lane"221
(let* ((fx (fs-fixture))222
(rf (handler-for (dict-ref fx cmds:) "fs.read-file")))223
(let ((r (rf (dict path: (path-join (dict-ref fx dir:) "a.txt") encoding: "base64") #f)))224
(assert-equal (dict-ref (dict-ref r meta: #{}) encoding: #f) "base64")225
(assert-equal (utf8->string (base64-decode (dict-ref r bulk-b64: #f))) "hello A"))))227
(test "fs.home reports the home directory"228
;; Lets a client expand "~" without hardcoding a host convention — for a remote229
;; node the home that matters is the NODE's, not the one typing. The fixture's230
;; grants are scoped to a temp dir, so this asserts the scoped DENIAL; the231
;; allowed path is covered in sigil-system, which owns the grant check.232
(let* ((fx (fs-fixture))233
(home-cmd (handler-for (dict-ref fx cmds:) "fs.home")))234
(assert-true (guard (e (#t #t)) (home-cmd (dict) #f) #f))))236
(test "fs.home reports the home directory when granted"237
(let* ((dir (make-temp-directory))238
(g (make-grants))239
(home (getenv "HOME")))240
(grant-add! g (string-append "fs:ro:" home))241
(let* ((cmds (lantern-system-commands g))242
(home-cmd (handler-for cmds "fs.home")))243
(assert-equal (dict-ref (home-cmd (dict) #f) path: #f)244
(or (realpath home) home)))))246
(test "fs.write-file writes atomically and returns a stat"247
(let* ((fx (fs-fixture))248
(wf (handler-for (dict-ref fx cmds:) "fs.write-file"))249
(path (path-join (dict-ref fx dir:) "new.txt")))250
(let ((r (wf (dict path: path content: "written here") #f)))251
(assert-equal (dict-ref r type: #f) "regular")252
(assert-equal (read-file-string path) "written here"))))254
(test "fs.write-file handles an EMPTY content string (missing-arg sentinel)"255
(let* ((fx (fs-fixture))256
(wf (handler-for (dict-ref fx cmds:) "fs.write-file"))257
(path (path-join (dict-ref fx dir:) "empty.txt")))258
(wf (dict path: path content: "") #f)259
(assert-equal (read-file-string path) "")))261
(test "fs.write-file takes bulk-lane content over content:"262
(let* ((fx (fs-fixture))263
(wf (handler-for (dict-ref fx cmds:) "fs.write-file"))264
(path (path-join (dict-ref fx dir:) "bulk.txt")))265
(wf (dict path: path bulk: "bulk body wins") #f)266
(assert-equal (read-file-string path) "bulk body wins")))268
(test "fs.write-file create-dirs: creates a missing parent directory"269
;; The ONLY test that can prove this contract: it asserts the file exists ON270
;; DISK, i.e. that the host ACTED. A caller-side test can only prove what was271
;; sent — and one did exactly that while the feature was broken, because the272
;; doc comment above fs.write-file advertised `create-dirs?:` and the code read273
;; `create-dirs:`. The flag is optional, so a misspelled name is274
;; indistinguishable from an absent one: no mkdir, no error.275
;;276
;; Slate's find-file needs this — you CREATE a file by typing a path that does277
;; not exist, and that path can name a directory that does not exist either.278
(let* ((fx (fs-fixture))279
(wf (handler-for (dict-ref fx cmds:) "fs.write-file"))280
(path (path-join (dict-ref fx dir:) "fresh/new.txt")))281
(wf (dict path: path content: "made the parent" create-dirs: #t) #f)282
(assert-equal (read-file-string path) "made the parent")))284
(test "fs.write-file create-dirs: reaches exactly ONE missing level, by design"285
;; Pins a real limit of the GRANT model rather than of this command. To decide286
;; whether a path is inside a grant, sigil-system's canonical-target needs a real287
;; anchor: it realpaths the path, or (for a write) realpaths the PARENT and288
;; re-appends the basename. Two missing levels leave nothing to realpath, so the289
;; write is denied before mkdir is ever reached.290
;;291
;; That is the SAFE default, not an oversight: resolving further up would mean292
;; re-appending an unresolved tail, and a ".." in that tail could escape the293
;; grant root — precisely what realpath'ing the parent prevents today. Lifting it294
;; needs normalization of the missing tail, i.e. a deliberate change to security295
;; code, not a drive-by.296
(let* ((fx (fs-fixture))297
(wf (handler-for (dict-ref fx cmds:) "fs.write-file"))298
(path (path-join (dict-ref fx dir:) "deep/deeper/new.txt")))299
(assert-true (guard (e (#t #t))300
(wf (dict path: path content: "x" create-dirs: #t) #f)301
#f))))303
(test "fs.write-file WITHOUT create-dirs: does not invent directories"304
;; The other half of the contract: mkdir is opt-in, so a typo'd path fails loudly305
;; instead of silently scattering directories across the filesystem.306
(let* ((fx (fs-fixture))307
(wf (handler-for (dict-ref fx cmds:) "fs.write-file"))308
(path (path-join (dict-ref fx dir:) "absent/new.txt")))309
(assert-true (guard (e (#t #t)) (wf (dict path: path content: "x") #f) #f))))311
(test "fs.mkdir / fs.rename / fs.delete take effect on disk"312
(let* ((fx (fs-fixture))313
(dir (dict-ref fx dir:))314
(mkdir (handler-for (dict-ref fx cmds:) "fs.mkdir"))315
(rename (handler-for (dict-ref fx cmds:) "fs.rename"))316
(delete (handler-for (dict-ref fx cmds:) "fs.delete")))317
(mkdir (dict path: (path-join dir "made")) #f)318
(assert-true (directory? (path-join dir "made")))319
(rename (dict from: (path-join dir "a.txt") to: (path-join dir "renamed.txt")) #f)320
(assert-true (file-exists? (path-join dir "renamed.txt")))321
(assert-false (file-exists? (path-join dir "a.txt")))322
(delete (dict path: (path-join dir "renamed.txt")) #f)323
(assert-false (file-exists? (path-join dir "renamed.txt")))))325
(test "fs command denies a path outside the grant"326
(let* ((fx (fs-fixture))327
(stat (handler-for (dict-ref fx cmds:) "fs.stat")))328
(assert-error (stat (dict path: "/etc/hostname") #f))))330
;; ============================================================331
;; pty commands (direct, allow-all, under with-async)332
;; ============================================================334
(test "pty round-trips input to output through a real pty"335
(with-async336
(pty-reset!)337
(let* ((cmds (lantern-system-commands (grant-allow-all)))338
(open (handler-for cmds "pty.open"))339
(write (handler-for cmds "pty.write"))340
(close (handler-for cmds "pty.close"))341
(ctx (capture-ctx))342
(res (open (dict argv: (list "cat")) ctx))343
(sid (dict-ref res session: #f)))344
(assert-true (integer? sid))345
(assert-equal (dict-ref res cols: #f) 80)346
(sleep 0.3)347
(write (dict session: sid bytes: (b64 "ping-line\n")) ctx)348
(sleep 0.4)349
(assert-true (string-contains? (collected-text ctx) "ping-line"))350
(close (dict session: sid) ctx)351
(sleep 0.4)352
(assert-true (saw-event? ctx "pty.exit")))))354
(test "pty runs shell commands and streams their output"355
(with-async356
(pty-reset!)357
(let* ((cmds (lantern-system-commands (grant-allow-all)))358
(open (handler-for cmds "pty.open"))359
(write (handler-for cmds "pty.write"))360
(close (handler-for cmds "pty.close"))361
(ctx (capture-ctx))362
(res (open (dict argv: (list "sh")) ctx))363
(sid (dict-ref res session: #f)))364
(sleep 0.3)365
(write (dict session: sid bytes: (b64 "echo hello-from-shell\n")) ctx)366
(sleep 0.5)367
(assert-true (string-contains? (collected-text ctx) "hello-from-shell"))368
(close (dict session: sid) ctx)369
(sleep 0.3))))371
(test "pty.resize delivers a new size to the child (SIGWINCH via TIOCSWINSZ)"372
(with-async373
(pty-reset!)374
(let* ((cmds (lantern-system-commands (grant-allow-all)))375
(open (handler-for cmds "pty.open"))376
(write (handler-for cmds "pty.write"))377
(resize (handler-for cmds "pty.resize"))378
(close (handler-for cmds "pty.close"))379
(ctx (capture-ctx))380
(res (open (dict argv: (list "sh") cols: 80 rows: 24) ctx))381
(sid (dict-ref res session: #f)))382
(sleep 0.3)383
(resize (dict session: sid cols: 100 rows: 40) ctx)384
(sleep 0.2)385
(write (dict session: sid bytes: (b64 "stty size\n")) ctx)386
(sleep 0.5)387
;; `stty size` prints "rows cols".388
(assert-true (string-contains? (collected-text ctx) "40 100"))389
(close (dict session: sid) ctx)390
(sleep 0.3))))392
(test "pty.credit backpressure: a flooding producer caps at the window"393
(with-async394
(pty-reset!)395
(let* ((cmds (lantern-system-commands (grant-allow-all)))396
(open (handler-for cmds "pty.open"))397
(credit (handler-for cmds "pty.credit"))398
(signal (handler-for cmds "pty.signal"))399
(close (handler-for cmds "pty.close"))400
(ctx (capture-ctx))401
;; `yes` floods forever; open with a small window and DON'T credit.402
(res (open (dict argv: (list "yes" "flood") credit: 8192) ctx))403
(sid (dict-ref res session: #f)))404
(sleep 0.6)405
(let ((n1 (collected-byte-count ctx)))406
(assert-true (> n1 0))407
;; Capped, NOT unbounded — the pump parked at zero window, the kernel408
;; buffer filled, and `yes` is blocked on write.409
(assert-true (< n1 200000))410
;; Replenish credit -> more flows.411
(credit (dict session: sid bytes: 16384) ctx)412
(sleep 0.4)413
(assert-true (> (collected-byte-count ctx) n1)))414
(close (dict session: sid) ctx)415
(sleep 0.4))))417
(test "pty.signal terminates the child and delivers exit"418
(with-async419
(pty-reset!)420
(let* ((cmds (lantern-system-commands (grant-allow-all)))421
(open (handler-for cmds "pty.open"))422
(signal (handler-for cmds "pty.signal"))423
(ctx (capture-ctx))424
(res (open (dict argv: (list "sh" "-c" "sleep 5; echo done")) ctx))425
(sid (dict-ref res session: #f)))426
(sleep 0.3)427
(signal (dict session: sid signal: "term") ctx)428
(sleep 0.6)429
(assert-true (saw-event? ctx "pty.exit"))430
(assert-false (string-contains? (collected-text ctx) "done")))))432
;; ============================================================433
;; The bridge round-trip (JSON -> registry -> handler -> JS)434
;; ============================================================436
;; A fake webview backend that records every eval-js string.437
(define *evals* (make-vector 1 (list)))438
(define (reset-evals!) (vector-set! *evals* 0 (list)))439
(define (last-eval) (let ((es (vector-ref *evals* 0))) (if (null? es) #f (car es))))440
(define (all-evals) (reverse (vector-ref *evals* 0)))441
(define (fake-backend)442
(make-backend 'fake443
(dict eval-js: (lambda (win js)444
(vector-set! *evals* 0 (cons js (vector-ref *evals* 0)))))))445
(define (sys-bridge g)446
(make-bridge (fake-backend) (dict fake-window: #t)447
(make-registry (lantern-system-commands g)) (dict fake-app: #t)))449
;; Substring index search (no index-returning contains in core string ops).450
(define (find-sub s sub)451
(let ((n (string-length s)) (m (string-length sub)))452
(let loop ((i 0))453
(cond ((> (+ i m) n) #f)454
((string=? (substring s i (+ i m)) sub) i)455
(else (loop (+ i 1)))))))457
;; Pull the integer after "session": out of a resolve JS string.458
(define (extract-session-id js)459
(let ((i (find-sub js "\"session\":")))460
(and i461
(let loop ((j (+ i (string-length "\"session\":"))) (acc ""))462
(if (and (< j (string-length js)) (char-numeric? (string-ref js j)))463
(loop (+ j 1) (string-append acc (string (string-ref js j))))464
(string->number acc))))))466
(define (fs-bridge-fixture)467
(let* ((dir (make-temp-directory))468
(g (make-grants)))469
(grant-add! g (string-append "fs:rw:" dir))470
(write-file-string (path-join dir "a.txt") "hello A")471
(dict dir: dir g: g bridge: (sys-bridge g))))473
(test "bridge: fs.read-dir resolves on the bulk lane (TSV base64, no entries JSON)"474
(reset-evals!)475
(let* ((fx (fs-bridge-fixture))476
(bridge (dict-ref fx bridge:)))477
(bridge-handle bridge478
(string-append "{\"id\":1,\"cmd\":\"fs.read-dir\",\"args\":{\"path\":\""479
(dict-ref fx dir:) "\"}}"))480
(let ((js (last-eval)))481
;; the listing crosses on the BULK lane now (base64 TSV via _resolveBulk),482
;; NOT as an entries JSON body — that per-entry json-encode was the O(n²) cost483
(assert-true (string-contains? js "window.lantern._resolveBulk(1,"))484
(assert-true (string-contains? js "\"total\":")) ; the full count rides in meta485
(assert-false (string-contains? js "entries")))))487
(test "bridge: fs.read-file resolves on the bulk lane (raw base64, no JSON body)"488
(reset-evals!)489
(let* ((fx (fs-bridge-fixture))490
(bridge (dict-ref fx bridge:)))491
(bridge-handle bridge492
(string-append "{\"id\":2,\"cmd\":\"fs.read-file\",\"args\":{\"path\":\""493
(path-join (dict-ref fx dir:) "a.txt") "\"}}"))494
(let ((js (last-eval)))495
(assert-true (string-contains? js "window.lantern._resolveBulk(2,"))496
;; the content crosses as raw base64 ("hello A" = aGVsbG8gQQ==), never as497
;; a json-encoded body498
(assert-true (string-contains? js "aGVsbG8gQQ=="))499
(assert-true (string-contains? js "\"encoding\":\"utf8\""))500
(assert-false (string-contains? js "hello A")))))502
(test "bridge: fs.write-file consumes a preceding bulk frame"503
(reset-evals!)504
(let* ((fx (fs-bridge-fixture))505
(bridge (dict-ref fx bridge:))506
(path (path-join (dict-ref fx dir:) "saved.txt")))507
;; the page posts the bulk frame first; the dispatcher stores it before508
;; handling the JSON request (simulated in the same order here)509
(assert-true (bulk-store! bridge (string-append "bulk\n7\nsaved via bulk")))510
(bridge-handle bridge511
(string-append "{\"id\":7,\"cmd\":\"fs.write-file\",\"args\":{\"path\":\""512
path "\"},\"bulk\":true}"))513
(assert-true (string-contains? (last-eval) "window.lantern._resolve(7,"))514
(assert-equal (read-file-string path) "saved via bulk")515
;; the frame is consumed — a second take finds nothing516
(assert-false (bulk-take! bridge 7))))518
(test "bridge: an unregistered command rejects (allowlist)"519
(reset-evals!)520
(let ((bridge (sys-bridge (grant-allow-all))))521
(bridge-handle bridge "{\"id\":3,\"cmd\":\"sys.nope\",\"args\":null}")522
(assert-true (string-contains? (last-eval) "window.lantern._reject(3,"))523
(assert-true (string-contains? (last-eval) "unknown-command"))))525
(test "bridge: pty.open resolves and streams pty.data, then closes"526
(with-async527
(pty-reset!)528
(reset-evals!)529
(let ((bridge (sys-bridge (grant-allow-all))))530
;; `yes` produces output with no input, so pty.data must appear.531
(bridge-handle bridge "{\"id\":4,\"cmd\":\"pty.open\",\"args\":{\"argv\":[\"yes\",\"bridge\"],\"credit\":4096}}")532
(let ((open-js (last-eval)))533
(assert-true (string-contains? open-js "window.lantern._resolve(4,"))534
(assert-true (string-contains? open-js "\"session\":"))535
(let ((sid (extract-session-id open-js)))536
(assert-true (integer? sid))537
(sleep 0.5)538
;; Somewhere in the recorded evals is a pty.data BULK emit (the pump539
;; rides the bulk lane when the bridge ctx provides it).540
(assert-true (any (lambda (js) (string-contains? js "window.lantern._emitBulk(\"pty.data\","))541
(all-evals)))542
;; Close through the bridge (guard the parse so a leak can't wedge).543
(when (integer? sid)544
(bridge-handle bridge545
(string-append "{\"id\":5,\"cmd\":\"pty.close\",\"args\":{\"session\":"546
(number->string sid) "}}"))547
(sleep 0.4)))))))