From 1a69787cf584e21bf4f5222f9c7b49b95542976f Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 17 Sep 2026 12:36:50 +0800 Subject: [PATCH 1/3] test(cache): add in-memory remote cache backend Co-authored-by: GPT-6 --- .github/workflows/ci.yml | 15 +- .../remote_cache_backend/package.json | 1 + .../remote_cache_backend/snapshots.toml | 512 ++++++++++++++++++ .../snapshots/blob_replacement.md | 104 ++++ .../snapshots/daemon_lifecycle.md | 60 ++ .../snapshots/entries_and_associations.md | 85 +++ .../snapshots/invalid_requests.md | 131 +++++ .../snapshots/opaque_binary_data.md | 49 ++ .../snapshots/request_limit.md | 42 ++ packages/tools/README.md | 44 +- packages/tools/package.json | 14 + packages/tools/src/cbor-http.test.ts | 60 ++ packages/tools/src/cbor-http.ts | 109 ++++ packages/tools/src/edn.test.ts | 28 + packages/tools/src/edn.ts | 24 + packages/tools/src/remote-cache/cli.test.ts | 79 +++ packages/tools/src/remote-cache/cli.ts | 203 +++++++ .../tools/src/remote-cache/server.test.ts | 142 +++++ packages/tools/src/remote-cache/server.ts | 170 ++++++ packages/tools/tsconfig.json | 13 + pnpm-lock.yaml | 42 ++ pnpm-workspace.yaml | 3 + 22 files changed, 1926 insertions(+), 4 deletions(-) create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/package.json create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md create mode 100644 crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md create mode 100644 packages/tools/src/cbor-http.test.ts create mode 100755 packages/tools/src/cbor-http.ts create mode 100644 packages/tools/src/edn.test.ts create mode 100644 packages/tools/src/edn.ts create mode 100644 packages/tools/src/remote-cache/cli.test.ts create mode 100755 packages/tools/src/remote-cache/cli.ts create mode 100644 packages/tools/src/remote-cache/server.test.ts create mode 100644 packages/tools/src/remote-cache/server.ts create mode 100644 packages/tools/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 962852370..5e4efdd50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,7 +148,9 @@ jobs: run: pnpm --filter vite-task-tools exec playwright install-deps chromium - name: Run ignored tests - run: ${{ matrix.cargo_cmd }} test --target ${{ matrix.build_target }} -- --ignored + run: | + pnpm --filter vite-task-tools test + ${{ matrix.cargo_cmd }} test --target ${{ matrix.build_target }} -- --ignored # Windows tests are cross-compiled on a fast Linux runner with cargo-xwin # (clang-cl + lld-link against the xwin-downloaded MSVC CRT/Windows SDK) @@ -281,7 +283,9 @@ jobs: - name: Run ignored tests if: matrix.mode == 'ignored' - run: cargo-nextest nextest run --archive-file windows-tests.tar.zst --workspace-remap . --run-ignored ignored-only + run: | + pnpm --filter vite-task-tools test + cargo-nextest nextest run --archive-file windows-tests.tar.zst --workspace-remap . --run-ignored ignored-only test-musl: needs: detect-changes @@ -335,7 +339,9 @@ jobs: - uses: oxc-project/setup-node@f46a72f95efdc55273fcd042d61c84e723b2892c # v1.4.1 - name: Run ignored tests - run: cargo test -- --ignored + run: | + pnpm --filter vite-task-tools test + cargo test -- --ignored fmt: name: Format and Check Deps @@ -374,6 +380,9 @@ jobs: pnpm build-vite-task-client-types git diff --exit-code packages/vite-task-client/src/index.d.ts + - name: Check test utility types + run: pnpm --filter vite-task-tools check + done: runs-on: namespace-profile-linux-x64-default if: always() diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/package.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/package.json @@ -0,0 +1 @@ +{} diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml new file mode 100644 index 000000000..f2eadc9f0 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml @@ -0,0 +1,512 @@ +[[e2e]] +name = "entries_and_associations" +ignore = true +steps = [ + [ + "remote-cache-server", + "start", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'A', \"secondary_key\": 'S'}", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'VA'}", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'U', \"value\": 'VA'}", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'B', \"secondary_key\": 'S', \"value\": 'VB'}", + ], + { argv = [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'A', \"secondary_key\": 'S'}", + ], comment = "The exact entry survives reassignment of S to B." }, + { argv = [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'C', \"secondary_key\": 'S'}", + ], comment = "The previous fetch did not change S. Fallback returns only B's key." }, + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'T', \"value\": 'VA2'}", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'A', \"secondary_key\": 'missing'}", + ], + { argv = [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'C', \"secondary_key\": 'U'}", + ], comment = "An older association to A still resolves after A is replaced." }, + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'C', \"secondary_key\": 'T'}", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'C', \"secondary_key\": 'S'}", + ], + [ + "remote-cache-server", + "stop", + ], +] + +[[e2e]] +name = "blob_replacement" +ignore = true +steps = [ + [ + "remote-cache-server", + "start", + ], + [ + "cbor-http", + "GET", + "/blob/missing", + ], + [ + "vtt", + "write-file", + "archive.txt", + "first archive", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'first'}", + "--form-file", + "blob=archive.txt", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'A', \"secondary_key\": 'S'}", + ], + [ + "cbor-http", + "GET", + "/blob/1", + ], + { argv = [ + "cbor-http", + "POST", + "/store", + "--form-data", + "blob='second archive'", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'second'}", + ], comment = "Accept blob before metadata, with neither part supplying a filename." }, + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'A', \"secondary_key\": 'S'}", + ], + [ + "cbor-http", + "GET", + "/blob/2", + ], + { argv = [ + "cbor-http", + "GET", + "/blob/1", + ], comment = "Previously returned IDs retain their original bytes." }, + { argv = [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'no archive'}", + ], comment = "Omitting blob clears the association." }, + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'A', \"secondary_key\": 'S'}", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'empty archive'}", + "--form-data", + "blob=''", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'A', \"secondary_key\": 'S'}", + ], + { argv = [ + "cbor-http", + "GET", + "/blob/3", + ], comment = "An empty blob has an ID and downloads as an empty byte string." }, + [ + "remote-cache-server", + "stop", + ], +] + +[[e2e]] +name = "opaque_binary_data" +ignore = true +steps = [ + [ + "remote-cache-server", + "start", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": b64'AP+A', \"secondary_key\": '', \"value\": b64'AP+A'}", + "--form-data", + "blob=b64'AP+A'", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": b64'AP+A', \"secondary_key\": ''}", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": '', \"secondary_key\": ''}", + ], + [ + "cbor-http", + "GET", + "/blob/1", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": '', \"secondary_key\": '', \"value\": ''}", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": '', \"secondary_key\": ''}", + ], + [ + "remote-cache-server", + "stop", + ], +] + +[[e2e]] +name = "invalid_requests" +ignore = true +steps = [ + [ + "remote-cache-server", + "start", + ], + [ + "cbor-http", + "GET", + "/missing", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--data", + "'bad'", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--data", + "'bad'", + "--content-type", + "application/cbor", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": \"text\", \"secondary_key\": ''}", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": ''}", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "[]", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": '', \"key\": 'duplicate', \"secondary_key\": ''}", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'original'}", + "--form-data", + "blob='original archive'", + ], + [ + "cbor-http", + "POST", + "/store", + "--cbor", + "{}", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-data", + "blob='missing metadata'", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'T', \"value\": \"wrong type\"}", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'T', \"value\": 'replacement'}", + "--form-cbor", + "metadata={}", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'T', \"value\": 'replacement'}", + "--form-data", + "blob='one'", + "--form-data", + "blob='two'", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={}", + "--form-data", + "unexpected='part'", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-data", + "metadata='wrong content type'", + ], + [ + "cbor-http", + "POST", + "/store", + "--data", + "'--boundary\\r\\nContent-Disposition: form-data; name=\"metadata\"\\r\\nContent-Type: application/cbor\\r\\n\\r\\n'", + "--content-type", + "multipart/form-data; boundary=boundary", + ], + { argv = [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'A', \"secondary_key\": 'S'}", + ], comment = "Failed stores did not replace the value or blob." }, + [ + "cbor-http", + "GET", + "/blob/1", + ], + { argv = [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'B', \"secondary_key\": 'T'}", + ], comment = "Failed stores did not publish a secondary association." }, + [ + "remote-cache-server", + "stop", + ], +] + +[[e2e]] +name = "request_limit" +ignore = true +steps = [ + [ + "remote-cache-server", + "start", + "--max-request-bytes", + "1024", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'original'}", + ], + [ + "node", + "-e", + "require('node:fs').writeFileSync('large.bin', Buffer.alloc(2048))", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'T', \"value\": 'replacement'}", + "--form-file", + "blob=large.bin", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'A', \"secondary_key\": 'S'}", + ], + [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'B', \"secondary_key\": 'T'}", + ], + [ + "remote-cache-server", + "stop", + ], +] + +[[e2e]] +name = "daemon_lifecycle" +ignore = true +steps = [ + [ + "remote-cache-server", + "start", + "--base-path", + "/projects/test", + ], + [ + "remote-cache-server", + "start", + ], + [ + "cbor-http", + "POST", + "/store", + "--form-cbor", + "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'first daemon'}", + ], + [ + "vtt", + "rm", + "cache.url", + ], + { argv = [ + "remote-cache-server", + "stop", + ], comment = "Wait for shutdown after file deletion, even when another command removed the file." }, + [ + "remote-cache-server", + "start", + ], + { argv = [ + "cbor-http", + "POST", + "/fetch", + "--cbor", + "{\"key\": 'A', \"secondary_key\": 'S'}", + ], comment = "A new daemon starts with empty state." }, + [ + "remote-cache-server", + "stop", + ], + [ + "remote-cache-server", + "stop", + ], +] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md new file mode 100644 index 000000000..23d025b5b --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md @@ -0,0 +1,104 @@ +# blob_replacement + +## `remote-cache-server start` + +``` +Remote cache server started +``` + +## `cbor-http GET /blob/missing` + +``` +{"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Blob not found"} +``` + +## `vtt write-file archive.txt 'first archive'` + +``` +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''first'\''}' --form-file blob=archive.txt` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'first', "blob_id": "1"}} +``` + +## `cbor-http GET /blob/1` + +``` +{"status": 200, "content_type": "application/octet-stream", "body": 'first archive'} +``` + +## `cbor-http POST /store --form-data 'blob='\''second archive'\''' --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''second'\''}'` + +Accept blob before metadata, with neither part supplying a filename. + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": "2"}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'second', "blob_id": "2"}} +``` + +## `cbor-http GET /blob/2` + +``` +{"status": 200, "content_type": "application/octet-stream", "body": 'second archive'} +``` + +## `cbor-http GET /blob/1` + +Previously returned IDs retain their original bytes. + +``` +{"status": 200, "content_type": "application/octet-stream", "body": 'first archive'} +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''no archive'\''}'` + +Omitting blob clears the association. + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'no archive', "blob_id": null}} +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''empty archive'\''}' --form-data blob=''` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": "3"}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'empty archive', "blob_id": "3"}} +``` + +## `cbor-http GET /blob/3` + +An empty blob has an ID and downloads as an empty byte string. + +``` +{"status": 200, "content_type": "application/octet-stream", "body": ''} +``` + +## `remote-cache-server stop` + +``` +Remote cache server stopped +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md new file mode 100644 index 000000000..6eb660581 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md @@ -0,0 +1,60 @@ +# daemon_lifecycle + +## `remote-cache-server start --base-path /projects/test` + +``` +Remote cache server started +``` + +## `remote-cache-server start` + +**Exit code:** 1 + +``` +Remote cache server already started (cache.lock exists) +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''first daemon'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +``` + +## `vtt rm cache.url` + +``` +``` + +## `remote-cache-server stop` + +Wait for shutdown after file deletion, even when another command removed the file. + +``` +Remote cache server stopped +``` + +## `remote-cache-server start` + +``` +Remote cache server started +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` + +A new daemon starts with empty state. + +``` +{"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} +``` + +## `remote-cache-server stop` + +``` +Remote cache server stopped +``` + +## `remote-cache-server stop` + +``` +Remote cache server stopped +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md new file mode 100644 index 000000000..d52e2d149 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md @@ -0,0 +1,85 @@ +# entries_and_associations + +## `remote-cache-server start` + +``` +Remote cache server started +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` + +``` +{"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''VA'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''U'\'', "value": '\''VA'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''B'\'', "secondary_key": '\''S'\'', "value": '\''VB'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` + +The exact entry survives reassignment of S to B. + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'VA', "blob_id": null}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` + +The previous fetch did not change S. Fallback returns only B's key. + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'B'}} +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''VA2'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''missing'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'VA2', "blob_id": null}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''U'\''}'` + +An older association to A still resolves after A is replaced. + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'A'}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''T'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'A'}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'B'}} +``` + +## `remote-cache-server stop` + +``` +Remote cache server stopped +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md new file mode 100644 index 000000000..de39985ce --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md @@ -0,0 +1,131 @@ +# invalid_requests + +## `remote-cache-server start` + +``` +Remote cache server started +``` + +## `cbor-http GET /missing` + +``` +{"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Route not found"} +``` + +## `cbor-http POST /fetch --data 'bad'` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected application/cbor"} +``` + +## `cbor-http POST /fetch --data 'bad' --content-type application/cbor` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} +``` + +## `cbor-http POST /fetch --cbor '{"key": "text", "secondary_key": '\'''\''}'` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\'''\''}'` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} +``` + +## `cbor-http POST /fetch --cbor []` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\'''\'', "key": '\''duplicate'\'', "secondary_key": '\'''\''}'` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid CBOR"} +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''original'\''}' --form-data 'blob='\''original archive'\'''` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} +``` + +## `cbor-http POST /store --cbor {}` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected multipart/form-data"} +``` + +## `cbor-http POST /store --form-data 'blob='\''missing metadata'\'''` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Missing metadata"} +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": "wrong type"}'` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key, value"} +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-cbor metadata={}` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-data blob='one' --form-data blob='two'` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} +``` + +## `cbor-http POST /store --form-cbor metadata={} --form-data unexpected='part'` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} +``` + +## `cbor-http POST /store --form-data 'metadata='\''wrong content type'\'''` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} +``` + +## `cbor-http POST /store --data ''\''--boundary\r\nContent-Disposition: form-data; name="metadata"\r\nContent-Type: application/cbor\r\n\r\n'\''' --content-type 'multipart/form-data; boundary=boundary'` + +``` +{"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` + +Failed stores did not replace the value or blob. + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'original', "blob_id": "1"}} +``` + +## `cbor-http GET /blob/1` + +``` +{"status": 200, "content_type": "application/octet-stream", "body": 'original archive'} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''B'\'', "secondary_key": '\''T'\''}'` + +Failed stores did not publish a secondary association. + +``` +{"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} +``` + +## `remote-cache-server stop` + +``` +Remote cache server stopped +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md new file mode 100644 index 000000000..d13fcf0db --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md @@ -0,0 +1,49 @@ +# opaque_binary_data + +## `remote-cache-server start` + +``` +Remote cache server started +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": b64'\''AP+A'\'', "secondary_key": '\'''\'', "value": b64'\''AP+A'\''}' --form-data blob=b64'AP+A'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} +``` + +## `cbor-http POST /fetch --cbor '{"key": b64'\''AP+A'\'', "secondary_key": '\'''\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": b64'AP+A', "blob_id": "1"}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": b64'AP+A'}} +``` + +## `cbor-http GET /blob/1` + +``` +{"status": 200, "content_type": "application/octet-stream", "body": b64'AP+A'} +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\'''\'', "secondary_key": '\'''\'', "value": '\'''\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": '', "blob_id": null}} +``` + +## `remote-cache-server stop` + +``` +Remote cache server stopped +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md new file mode 100644 index 000000000..38aceb511 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md @@ -0,0 +1,42 @@ +# request_limit + +## `remote-cache-server start --max-request-bytes 1024` + +``` +Remote cache server started +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''original'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} +``` + +## `node -e 'require('\''node:fs'\'').writeFileSync('\''large.bin'\'', Buffer.alloc(2048))'` + +``` +``` + +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-file blob=large.bin` + +``` +{"status": 413, "content_type": "text/plain; charset=utf-8", "body": "Request too large"} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` + +``` +{"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'original', "blob_id": null}} +``` + +## `cbor-http POST /fetch --cbor '{"key": '\''B'\'', "secondary_key": '\''T'\''}'` + +``` +{"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} +``` + +## `remote-cache-server stop` + +``` +Remote cache server stopped +``` diff --git a/packages/tools/README.md b/packages/tools/README.md index 629f20d92..46ed71a13 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -1 +1,43 @@ -This package provides Node.js dependencies (oxlint, oxfmt, cross-env) used by fspy tests. +This package provides Node.js dependencies and commands for the test suites. + +## Remote cache backend + +Run `pnpm install` at the repository root to install `remote-cache-server` and `cbor-http` into this package's `node_modules/.bin`. The workspace self-dependency makes pnpm link the package's own commands there. The E2E harness already includes that directory in PATH. Both commands run TypeScript directly using the Node version in `.node-version`. + +```sh +remote-cache-server start +cbor-http POST /store --form-cbor "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'record'}" +cbor-http POST /fetch --cbor "{\"key\": 'A', \"secondary_key\": 'S'}" +remote-cache-server stop +``` + +`start` launches an in-memory daemon on a free loopback port and writes its endpoint to `cache.url` in the current directory. It returns when the daemon is ready. Each E2E case has its own directory and server state. Deleting `cache.url` stops the daemon; `stop` deletes it and waits for shutdown. The daemon also exits after five minutes so a failed test cannot leave it running indefinitely. Use `--max-lifetime-ms` on `start` for longer tests. + +`cache.lock` prevents overlapping starts in the same directory. It is removed after the server closes. Daemon output goes to `cache.log`, and startup failures include that log. These files stay in the test's directory. + +The backend implements `POST /fetch`, `POST /store`, and `GET /blob/{blob_id}` from the [remote cache RFC](https://github.com/voidzero-dev/vite-task/blob/rfc-cloudflare-remote-cache/docs/rfcs/0001-remote-cache.md#4-http-api-mapping). Keys and values are opaque bytes. Blobs remain unchanged until the daemon exits. Blob IDs are sequential strings to keep snapshots deterministic. There is no authentication or persistent storage. + +`start --base-path /projects/test` places the API under that path and includes it in `cache.url`. Requests are limited to 64 MiB by default; use `--max-request-bytes` to change the limit. Keys, values, and blobs have no separate length limits. + +## CBOR HTTP client + +`cbor-http METHOD PATH` resolves the path against `cache.url`. An absolute HTTP URL works without that file. Request bodies use [CBOR extended diagnostic notation (EDN)](https://www.rfc-editor.org/rfc/rfc8610.html#appendix-G): `"text"` is text, `'bytes'` is a UTF-8 byte string, and `b64'AP+A'` contains arbitrary binary bytes. + +| Option | Body | +| ----------------------- | --------------------------------------------------------- | +| `--cbor EDN` | Encode EDN as CBOR with `Content-Type: application/cbor`. | +| `--data EDN` | Send an EDN byte string as raw bytes. | +| `--form-cbor NAME=EDN` | Add a CBOR multipart part. | +| `--form-data NAME=EDN` | Add an EDN byte string as a raw multipart part. | +| `--form-file NAME=FILE` | Add file contents as a raw multipart part. | +| `--content-type TYPE` | Override the request content type. | + +Multipart options can repeat and retain their order. Raw bodies and parts default to `application/octet-stream`. Parts have no filename, including binary metadata. + +Every response prints one EDN map containing `status`, `content_type`, and `body`. CBOR is decoded, text becomes a text string, and other bodies become byte strings. Byte strings use readable UTF-8 when possible and base64 EDN otherwise. HTTP error responses exit successfully so snapshots can assert their status and body. Argument errors, transport failures, and invalid CBOR responses exit unsuccessfully. Requests time out after ten seconds. + +```text +{"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'record', "blob_id": null}} +``` + +Run `pnpm --filter vite-task-tools check` for type checking and `pnpm --filter vite-task-tools test` for utility tests. Run `cargo test -p vt_bin --test e2e_snapshots -- remote_cache_backend --ignored` for the backend snapshots. diff --git a/packages/tools/package.json b/packages/tools/package.json index 22355f5c7..baa480229 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,14 +1,25 @@ { "name": "vite-task-tools", "private": true, + "bin": { + "cbor-http": "./src/cbor-http.ts", + "remote-cache-server": "./src/remote-cache/cli.ts" + }, "type": "module", + "scripts": { + "check": "tsc --noEmit -p tsconfig.json", + "test": "node --test src/*.test.ts src/remote-cache/*.test.ts" + }, "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", + "@fastify/busboy": "catalog:", "@openai/codex": "catalog:", "@playwright/browser-chromium": "catalog:", "@vitest/browser-playwright": "catalog:", "@voidzero-dev/vite-task-client": "workspace:*", "bun": "catalog:", + "cbor-edn": "catalog:", + "cbor2": "catalog:", "cross-env": "catalog:", "deno": "catalog:", "oxfmt": "catalog:", @@ -18,5 +29,8 @@ "typescript": "7.1.0-dev.20260910.1", "vite": "catalog:", "vitest": "catalog:" + }, + "devDependencies": { + "vite-task-tools": "workspace:*" } } diff --git a/packages/tools/src/cbor-http.test.ts b/packages/tools/src/cbor-http.test.ts new file mode 100644 index 000000000..8332463ac --- /dev/null +++ b/packages/tools/src/cbor-http.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { once } from 'node:events'; +import { createServer } from 'node:http'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); +const cli = fileURLToPath(new URL('./cbor-http.ts', import.meta.url)); + +await test('the HTTP client translates CBOR, text errors, and raw responses to EDN', async (t) => { + const server = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + if (request.url === '/cbor') { + // RFC 8949: a map with one text key and one byte-string value. + assert.deepEqual(Buffer.concat(chunks), Buffer.from([0xa1, 0x61, 0x61, 0x41, 0x62])); + response.writeHead(200, { 'content-type': 'application/cbor' }); + response.end(Buffer.concat(chunks)); + } else if (request.url === '/text') { + response.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' }); + response.end('try again'); + } else if (request.url === '/raw') { + response.writeHead(200, { 'content-type': 'application/octet-stream' }); + response.end(Buffer.from([0, 255, 128])); + } else { + response.writeHead(200, { 'content-type': 'application/cbor' }); + response.end(Buffer.from([0x41])); // Byte string missing its byte. + } + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(() => { + server.close(); + server.closeAllConnections(); + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const run = (path: string, ...args: string[]) => + exec(process.execPath, [cli, 'POST', `http://127.0.0.1:${address.port}${path}`, ...args], { + timeout: 15_000, + }); + assert.equal( + (await run('/cbor', '--cbor', '{"a": \'b\'}')).stdout.trim(), + '{"status": 200, "content_type": "application/cbor", "body": {"a": \'b\'}}', + ); + assert.equal( + (await run('/text')).stdout.trim(), + '{"status": 503, "content_type": "text/plain; charset=utf-8", "body": "try again"}', + ); + assert.equal( + (await run('/raw')).stdout.trim(), + '{"status": 200, "content_type": "application/octet-stream", "body": b64\'AP+A\'}', + ); + await assert.rejects(run('/invalid'), (error: unknown) => { + assert.equal((error as { code: number }).code, 1); + return true; + }); +}); diff --git a/packages/tools/src/cbor-http.ts b/packages/tools/src/cbor-http.ts new file mode 100755 index 000000000..7e540c890 --- /dev/null +++ b/packages/tools/src/cbor-http.ts @@ -0,0 +1,109 @@ +#!/usr/bin/env node +import { parseEDN } from 'cbor-edn'; +import { decode } from 'cbor2/decoder'; +import { encode } from 'cbor2/encoder'; +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { parseArgs } from 'node:util'; +import { toEDN } from './edn.ts'; + +function bytes(edn: string): Uint8Array { + const value: unknown = decode(parseEDN(edn, {})); + if (!(value instanceof Uint8Array)) throw new Error('Expected an EDN byte string'); + return value; +} + +async function main(): Promise { + const { values, positionals, tokens } = parseArgs({ + allowPositionals: true, + tokens: true, + options: { + cbor: { type: 'string' }, + data: { type: 'string' }, + 'content-type': { type: 'string' }, + 'form-cbor': { type: 'string', multiple: true }, + 'form-data': { type: 'string', multiple: true }, + 'form-file': { type: 'string', multiple: true }, + }, + }); + const [method, path] = positionals; + if (positionals.length !== 2 || !method || !path) { + throw new Error( + 'Usage: cbor-http METHOD PATH [--cbor EDN | --data EDN | --form-cbor NAME=EDN | --form-data NAME=EDN | --form-file NAME=FILE]', + ); + } + let body: Buffer | undefined; + let contentType: string | undefined; + const formTokens = tokens.filter( + (token) => token.kind === 'option' && token.name.startsWith('form-'), + ); + if ( + [values.cbor !== undefined, values.data !== undefined, formTokens.length > 0].filter(Boolean) + .length > 1 + ) { + throw new Error('Choose one request body format'); + } + if (values.cbor !== undefined) { + body = Buffer.from(parseEDN(values.cbor, {})); + contentType = 'application/cbor'; + } else if (values.data !== undefined) { + body = Buffer.from(bytes(values.data)); + contentType = 'application/octet-stream'; + } else if (formTokens.length) { + const boundary = randomUUID(); + const chunks: Buffer[] = []; + for (const token of formTokens) { + if (token.kind !== 'option' || token.value === undefined) continue; + const equal = token.value.indexOf('='); + const name = token.value.slice(0, equal); + const value = token.value.slice(equal + 1); + if (equal < 1 || /["\r\n\\]/.test(name)) throw new Error('Expected multipart NAME=VALUE'); + const isCbor = token.name === 'form-cbor'; + const data = isCbor + ? parseEDN(value, {}) + : token.name === 'form-file' + ? await readFile(value) + : bytes(value); + // Do not add filenames: binary metadata is valid without one. + chunks.push( + Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\nContent-Type: ${isCbor ? 'application/cbor' : 'application/octet-stream'}\r\n\r\n`, + ), + ); + chunks.push(Buffer.from(data), Buffer.from('\r\n')); + } + chunks.push(Buffer.from(`--${boundary}--\r\n`)); + body = Buffer.concat(chunks); + contentType = `multipart/form-data; boundary=${boundary}`; + } + contentType = values['content-type'] ?? contentType; + const url = /^https?:\/\//.test(path) + ? path + : `${(await readFile('cache.url', 'utf8')).trim().replace(/\/$/, '')}/${path.replace(/^\//, '')}`; + const response = await fetch(url, { + method, + ...(body === undefined ? {} : { body }), + ...(contentType === undefined ? {} : { headers: { 'content-type': contentType } }), + signal: AbortSignal.timeout(10_000), + }); + const responseType = response.headers.get('content-type') ?? ''; + const raw = new Uint8Array(await response.arrayBuffer()); + const type = responseType.split(';')[0]?.trim(); + let printedBody: string; + if (type === 'application/cbor') { + decode(raw); // diagnose alone does not reject every incomplete CBOR item. + printedBody = toEDN(raw); + } else { + printedBody = toEDN(encode(type?.startsWith('text/') ? new TextDecoder().decode(raw) : raw)); + } + console.log( + `{"status": ${response.status}, "content_type": ${JSON.stringify(responseType)}, "body": ${printedBody}}`, + ); +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/packages/tools/src/edn.test.ts b/packages/tools/src/edn.test.ts new file mode 100644 index 000000000..68faa485b --- /dev/null +++ b/packages/tools/src/edn.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseEDN } from 'cbor-edn'; +import { decode } from 'cbor2/decoder'; +import { encode } from 'cbor2/encoder'; +import { toEDN } from './edn.ts'; + +await test('EDN distinguishes binary and text, including escapes and invalid UTF-8', () => { + for (const value of [ + new Uint8Array(), + new Uint8Array([0, 255, 128]), + new TextEncoder().encode("quotes: '\"\\\n\t\0 and h'ff'"), + new Uint8Array([239, 187, 191, 65]), + { text: "h'ff'", bytes: new TextEncoder().encode('hello') }, + { text: '"quoted"', values: [true, 1, 3.5, null] }, + ]) { + const edn = toEDN(encode(value)); + assert.deepEqual(decode(parseEDN(edn, {})), value); + } + assert.equal(toEDN(encode(new Uint8Array([0, 255, 128]))), "b64'AP+A'"); + assert.equal(toEDN(encode(new TextEncoder().encode('hello'))), "'hello'"); +}); + +await test('known RFC 8949 encodings decode without depending on our request encoder', () => { + assert.equal(toEDN(Uint8Array.of(0x43, 0x61, 0x62, 0x63)), "'abc'"); + assert.equal(toEDN(Uint8Array.of(0x63, 0x61, 0x62, 0x63)), '"abc"'); + assert.equal(toEDN(Uint8Array.of(0xa1, 0x61, 0x61, 0x01)), '{"a": 1}'); +}); diff --git a/packages/tools/src/edn.ts b/packages/tools/src/edn.ts new file mode 100644 index 000000000..44919d221 --- /dev/null +++ b/packages/tools/src/edn.ts @@ -0,0 +1,24 @@ +import { diagnose } from 'cbor2/diagnostic'; + +/** Keep byte strings readable without mistaking text containing h'...' for bytes. */ +export function toEDN(bytes: Uint8Array): string { + return diagnose(bytes).replace( + /"(?:[^"\\]|\\.)*"|h'([0-9a-f]*)'/gi, + (token, hex: string | undefined) => { + if (hex === undefined) return token; + const binary = Buffer.from(hex, 'hex'); + try { + const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(binary); + const escaped = JSON.stringify(text) + .slice(1, -1) + .replace(/\\.|'/g, (part) => { + if (part === '\\"') return '"'; + return part === "'" ? "\\'" : part; + }); + return `'${escaped}'`; + } catch { + return `b64'${binary.toString('base64')}'`; + } + }, + ); +} diff --git a/packages/tools/src/remote-cache/cli.test.ts b/packages/tools/src/remote-cache/cli.test.ts new file mode 100644 index 000000000..bc61a32b0 --- /dev/null +++ b/packages/tools/src/remote-cache/cli.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import test, { type TestContext } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); +const cli = fileURLToPath(new URL('./cli.ts', import.meta.url)); + +async function fixture(t: TestContext) { + const cwd = await mkdtemp(join(tmpdir(), 'remote-cache-test-')); + const run = (...args: string[]) => + exec(process.execPath, [cli, ...args], { cwd, timeout: 15_000 }); + t.after(async () => { + try { + await run('stop'); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + return { cwd, run }; +} + +async function waitForRemoval(path: string): Promise { + const deadline = Date.now() + 10_000; + while (true) { + try { + await access(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + assert.ok(Date.now() < deadline, `Daemon did not remove ${path}`); + await delay(25); + } +} + +await test('deleting cache.url stops the daemon and closes the listener', async (t) => { + const { cwd, run } = await fixture(t); + await run('start'); + const url = (await readFile(join(cwd, 'cache.url'), 'utf8')).trim(); + assert.equal((await fetch(`${url}/fetch`)).status, 404); + await rm(join(cwd, 'cache.url')); + await waitForRemoval(join(cwd, 'cache.lock')); + await assert.rejects(fetch(`${url}/fetch`)); +}); + +await test('maximum lifetime cleans up a daemon even when stop never runs', async (t) => { + const { cwd, run } = await fixture(t); + await run('start', '--max-lifetime-ms', '800'); + const url = (await readFile(join(cwd, 'cache.url'), 'utf8')).trim(); + await waitForRemoval(join(cwd, 'cache.lock')); + await assert.rejects(access(join(cwd, 'cache.url'))); + await assert.rejects(fetch(`${url}/fetch`)); +}); + +await test('concurrent starts have one owner and separate directories stay isolated', async (t) => { + const a = await fixture(t); + const b = await fixture(t); + const starts = await Promise.allSettled([a.run('start'), a.run('start'), b.run('start')]); + assert.equal(starts.filter((result) => result.status === 'fulfilled').length, 2); + const urlA = (await readFile(join(a.cwd, 'cache.url'), 'utf8')).trim(); + const urlB = (await readFile(join(b.cwd, 'cache.url'), 'utf8')).trim(); + assert.notEqual(urlA, urlB); + await a.run('stop'); + assert.equal((await fetch(`${urlB}/fetch`)).status, 404); +}); + +await test('startup refuses an existing URL file and releases its lock', async (t) => { + const { cwd, run } = await fixture(t); + await writeFile(join(cwd, 'cache.url'), 'do not overwrite'); + await assert.rejects(run('start'), /cache.url already exists/); + assert.equal(await readFile(join(cwd, 'cache.url'), 'utf8'), 'do not overwrite'); + await assert.rejects(access(join(cwd, 'cache.lock'))); +}); diff --git a/packages/tools/src/remote-cache/cli.ts b/packages/tools/src/remote-cache/cli.ts new file mode 100755 index 000000000..2762d8a49 --- /dev/null +++ b/packages/tools/src/remote-cache/cli.ts @@ -0,0 +1,203 @@ +#!/usr/bin/env node +import { fork } from 'node:child_process'; +import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { setTimeout as delay } from 'node:timers/promises'; +import { parseArgs } from 'node:util'; +import { createCacheServer } from './server.ts'; + +const urlFile = 'cache.url'; +const lockDir = 'cache.lock'; +const logFile = 'cache.log'; +const startupTimeout = 10_000; + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + +async function waitUntil(check: () => Promise): Promise { + const deadline = Date.now() + startupTimeout; + while (!(await check())) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for remote cache server'); + await delay(50); + } +} + +async function start(args: string[]): Promise { + try { + await mkdir(lockDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new Error('Remote cache server already started (cache.lock exists)'); + } + throw error; + } + try { + if (await exists(urlFile)) throw new Error('cache.url already exists'); + const log = await open(logFile, 'w'); + const child = fork(import.meta.filename, ['serve', ...args], { + detached: true, + stdio: ['ignore', log.fd, log.fd, 'ipc'], + }); + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Remote cache server startup timed out')), + startupTimeout, + ); + child.once('message', (message) => { + clearTimeout(timeout); + if (message === 'ready') resolve(); + else reject(new Error('Unexpected daemon readiness message')); + }); + child.once('error', (error) => { + clearTimeout(timeout); + reject(error); + }); + child.once('exit', () => { + clearTimeout(timeout); + reject(new Error('Remote cache server exited before readiness')); + }); + }); + } catch (error) { + // A failed startup must not leave a detached child or a stale lock. + if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) { + const exited = new Promise((resolve) => child.once('exit', () => resolve())); + child.kill('SIGKILL'); + await exited; + } + await rm(urlFile, { force: true }); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n${await readFile(logFile, 'utf8')}`.trim(), + ); + } finally { + await log.close(); + if (child.connected) child.disconnect(); + child.unref(); + } + } catch (error) { + await rm(lockDir, { recursive: true, force: true }); + throw error; + } + console.log('Remote cache server started'); +} + +async function serve( + maxLifetimeMs: number, + maxRequestBytes: number, + basePath: string, +): Promise { + const server = createCacheServer({ maxRequestBytes, basePath }); + let stopping = false; + let ready = false; + let url: string | undefined; + process.on('SIGTERM', () => { + stopping = true; + }); + process.on('SIGINT', () => { + stopping = true; + }); + // If the starter disappears before readiness, nobody can use this instance. + process.on('disconnect', () => { + if (!ready) stopping = true; + }); + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Missing server address'); + url = `http://127.0.0.1:${address.port}${basePath}`; + await writeFile(`${lockDir}/url.tmp`, `${url}\n`); + await rename(`${lockDir}/url.tmp`, urlFile); + if (process.connected) process.send?.('ready'); + ready = true; + const deadline = Date.now() + maxLifetimeMs; + while (!stopping && Date.now() < deadline) { + try { + if ((await readFile(urlFile, 'utf8')).trim() !== url) break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') break; + throw error; + } + await delay(50); + } + } finally { + await new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + // Removing the lock acknowledges that the listening socket has closed. + try { + if ((await readFile(urlFile, 'utf8')).trim() === url) await rm(urlFile, { force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error(error); + process.exitCode = 1; + } + } finally { + await rm(lockDir, { recursive: true, force: true }); + } + } +} + +function positiveInteger(value: string): number { + const number = Number(value); + if (!Number.isSafeInteger(number) || number <= 0 || number > 2_147_483_647) { + throw new Error('Limits must be positive integers no greater than 2147483647'); + } + return number; +} + +async function main(): Promise { + const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + 'max-lifetime-ms': { type: 'string', default: '300000' }, + 'max-request-bytes': { type: 'string', default: String(64 * 1024 * 1024) }, + 'base-path': { type: 'string', default: '' }, + }, + }); + const lifetime = positiveInteger(values['max-lifetime-ms']); + const limit = positiveInteger(values['max-request-bytes']); + const basePath = values['base-path']; + if (basePath && !/^\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+$/.test(basePath)) { + throw new Error('Base path must contain slash-separated names without a trailing slash'); + } + if (positionals.length !== 1) throw new Error('Usage: remote-cache-server start|stop'); + switch (positionals[0]) { + case 'start': + await start([ + '--max-lifetime-ms', + String(lifetime), + '--max-request-bytes', + String(limit), + '--base-path', + basePath, + ]); + break; + case 'serve': + await serve(lifetime, limit, basePath); + break; + case 'stop': + await rm(urlFile, { force: true }); + await waitUntil(async () => !(await exists(lockDir))); + console.log('Remote cache server stopped'); + break; + default: + throw new Error('Usage: remote-cache-server start|stop'); + } +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/packages/tools/src/remote-cache/server.test.ts b/packages/tools/src/remote-cache/server.test.ts new file mode 100644 index 000000000..0a16367be --- /dev/null +++ b/packages/tools/src/remote-cache/server.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { request } from 'node:http'; +import test, { type TestContext } from 'node:test'; +import { decode } from 'cbor2/decoder'; +import { encode } from 'cbor2/encoder'; +import { createCacheServer } from './server.ts'; + +async function backend(t: TestContext, maxRequestBytes?: number): Promise { + const server = createCacheServer(maxRequestBytes === undefined ? {} : { maxRequestBytes }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(async () => { + const closed = once(server, 'close'); + server.close(); + server.closeAllConnections(); + await closed; + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + return `http://127.0.0.1:${address.port}`; +} + +function data(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +async function lookup(url: string, key: Uint8Array, secondary = data('S')) { + const response = await fetch(`${url}/fetch`, { + method: 'POST', + headers: { 'content-type': 'application/cbor' }, + body: Buffer.from(encode({ key, secondary_key: secondary })), + }); + assert.equal(response.status, 200); + return decode<{ kind: string; value: Uint8Array; blob_id: string }>( + new Uint8Array(await response.arrayBuffer()), + ); +} + +await test('concurrent stores keep values paired with immutable blobs', async (t) => { + const url = await backend(t); + const responses = await Promise.all( + Array.from({ length: 12 }, async (_, index) => { + const bytes = data(`value-${index}`); + const form = new FormData(); + form.set( + 'metadata', + new Blob( + [Buffer.from(encode({ key: data('A'), secondary_key: data('S'), value: bytes }))], + { type: 'application/cbor' }, + ), + ); + form.set('blob', new Blob([bytes], { type: 'application/octet-stream' })); + const response = await fetch(`${url}/store`, { method: 'POST', body: form }); + assert.equal(response.status, 200); + const { blob_id } = decode<{ blob_id: string }>(new Uint8Array(await response.arrayBuffer())); + return { bytes, blob_id }; + }), + ); + const exact = await lookup(url, data('A')); + assert.equal(exact.kind, 'exact'); + assert.ok(exact.value instanceof Uint8Array); + const downloaded = new Uint8Array( + await (await fetch(`${url}/blob/${exact.blob_id}`)).arrayBuffer(), + ); + assert.deepEqual(downloaded, exact.value); + for (const { bytes, blob_id } of responses) { + const response = await fetch(`${url}/blob/${blob_id}`); + assert.equal(response.status, 200); + assert.deepEqual(new Uint8Array(await response.arrayBuffer()), bytes); + } +}); + +await test('large keys and values are accepted without implicit multipart field limits', async (t) => { + const url = await backend(t); + const key = data('k'.repeat(128 * 1024)); + const value = data('v'.repeat(2 * 1024 * 1024)); + const form = new FormData(); + form.set( + 'metadata', + new Blob([Buffer.from(encode({ key, secondary_key: key, value }))], { + type: 'application/cbor', + }), + ); + assert.equal((await fetch(`${url}/store`, { method: 'POST', body: form })).status, 200); + assert.deepEqual((await lookup(url, key, key)).value, value); +}); + +await test('an aborted upload never publishes metadata received before the blob', async (t) => { + const url = await backend(t); + const first = new FormData(); + first.set( + 'metadata', + new Blob( + [Buffer.from(encode({ key: data('A'), secondary_key: data('S'), value: data('original') }))], + { type: 'application/cbor' }, + ), + ); + assert.equal((await fetch(`${url}/store`, { method: 'POST', body: first })).status, 200); + + const upload = request(`${url}/store`, { + method: 'POST', + headers: { 'content-type': 'multipart/form-data; boundary=test' }, + }); + upload.on('error', () => {}); // Aborting our own socket is intentional. + upload.write( + '--test\r\nContent-Disposition: form-data; name="metadata"\r\nContent-Type: application/cbor\r\n\r\n', + ); + upload.write(encode({ key: data('A'), secondary_key: data('T'), value: data('replacement') })); + upload.write( + '\r\n--test\r\nContent-Disposition: form-data; name="blob"\r\nContent-Type: application/octet-stream\r\n\r\n', + ); + await new Promise((resolve) => upload.write('partial blob', () => resolve())); + const closed = new Promise((resolve) => upload.once('close', resolve)); + upload.destroy(); + await closed; + assert.deepEqual((await lookup(url, data('A'))).value, data('original')); + const response = await fetch(`${url}/fetch`, { + method: 'POST', + headers: { 'content-type': 'application/cbor' }, + body: Buffer.from(encode({ key: data('B'), secondary_key: data('T') })), + }); + assert.equal(response.status, 404); +}); + +await test('chunked requests exceeding the limit return 413 without Content-Length', async (t) => { + const url = await backend(t, 32); + const upload = request(`${url}/fetch`, { + method: 'POST', + headers: { 'content-type': 'application/cbor' }, + }); + const result = new Promise((resolve, reject) => { + upload.on('error', reject); + upload.on('response', (response) => { + response.resume(); + resolve(response.statusCode); + }); + }); + upload.write(Buffer.alloc(20)); + upload.end(Buffer.alloc(20)); + assert.equal(await result, 413); +}); diff --git a/packages/tools/src/remote-cache/server.ts b/packages/tools/src/remote-cache/server.ts new file mode 100644 index 000000000..429382406 --- /dev/null +++ b/packages/tools/src/remote-cache/server.ts @@ -0,0 +1,170 @@ +import { Busboy } from '@fastify/busboy'; +import { decode } from 'cbor2/decoder'; +import { encode } from 'cbor2/encoder'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; + +interface Entry { + value: Uint8Array; + blob_id: string | null; +} + +class RequestError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +function mediaType(value: string | undefined): string { + return value?.split(';')[0]?.trim().toLowerCase() ?? ''; +} + +function byteFields(body: Uint8Array, names: string[]): Map { + let value: unknown; + try { + // Decode from Uint8Array so byte strings stay Uint8Array, not Node Buffers + // (whose toJSON method would otherwise turn them into maps when encoded). + value = decode(new Uint8Array(body), { preferMap: true, rejectDuplicateKeys: true }); + } catch { + throw new RequestError(400, 'Invalid CBOR'); + } + if (!(value instanceof Map) || names.some((name) => !(value.get(name) instanceof Uint8Array))) { + throw new RequestError(400, `Expected byte strings: ${names.join(', ')}`); + } + return value; +} + +function readBody(request: IncomingMessage, limit: number): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + request.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > limit) { + chunks.length = 0; + reject(new RequestError(413, 'Request too large')); + } else { + chunks.push(chunk); + } + }); + request.on('end', () => resolve(Buffer.concat(chunks))); + request.on('error', reject); + request.on('aborted', () => reject(new RequestError(400, 'Incomplete request'))); + }); +} + +async function readParts(body: Buffer, contentType: string): Promise> { + return new Promise((resolve, reject) => { + const parts = new Map(); + const names = new Set(); + // Both parts are binary, including metadata without a filename. + const parser = new Busboy({ + headers: { 'content-type': contentType }, + isPartAFile: () => true, + }); + parser.on('file', (name, stream, _filename, _encoding, type) => { + const expected = name === 'metadata' ? 'application/cbor' : 'application/octet-stream'; + if (!['metadata', 'blob'].includes(name) || names.has(name) || type !== expected) { + reject(new Error('Invalid multipart part')); + } + names.add(name); + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer) => chunks.push(chunk)); + stream.on('end', () => parts.set(name, Buffer.concat(chunks))); + stream.on('error', reject); + }); + parser.on('error', reject); + parser.on('finish', () => resolve(parts)); + parser.end(body); + }); +} + +function cbor(response: ServerResponse, value: unknown): void { + response.writeHead(200, { 'content-type': 'application/cbor' }); + response.end(encode(value)); +} + +/** A disposable backend. Keys, values, and blobs remain opaque bytes. */ +export function createCacheServer({ maxRequestBytes = 64 * 1024 * 1024, basePath = '' } = {}) { + const entries = new Map(); + const associations = new Map(); + const blobs = new Map(); + let nextBlobId = 1; + + async function handle(request: IncomingMessage, response: ServerResponse): Promise { + const path = new URL(request.url ?? '/', 'http://localhost').pathname; + if (request.method === 'GET' && path.startsWith(`${basePath}/blob/`)) { + const blob = blobs.get(path.slice(`${basePath}/blob/`.length)); + if (blob === undefined) throw new RequestError(404, 'Blob not found'); + response.writeHead(200, { 'content-type': 'application/octet-stream' }); + response.end(blob); + return; + } + if (request.method !== 'POST' || ![`${basePath}/fetch`, `${basePath}/store`].includes(path)) { + throw new RequestError(404, 'Route not found'); + } + + const contentType = request.headers['content-type'] ?? ''; + const body = await readBody(request, maxRequestBytes); + if (path === `${basePath}/fetch`) { + if (mediaType(contentType) !== 'application/cbor') { + throw new RequestError(400, 'Expected application/cbor'); + } + const fields = byteFields(body, ['key', 'secondary_key']); + const key = Buffer.from(fields.get('key')!).toString('hex'); + const secondary = Buffer.from(fields.get('secondary_key')!).toString('hex'); + const entry = entries.get(key); + if (entry) { + cbor(response, { kind: 'exact', ...entry }); + } else { + const associatedKey = associations.get(secondary); + if (associatedKey === undefined || !entries.has(associatedKey)) { + throw new RequestError(404, 'Entry not found'); + } + cbor(response, { + kind: 'fallback', + key: new Uint8Array(Buffer.from(associatedKey, 'hex')), + }); + } + return; + } + + if (mediaType(contentType) !== 'multipart/form-data') { + throw new RequestError(400, 'Expected multipart/form-data'); + } + let parts: Map; + try { + parts = await readParts(body, contentType); + } catch { + throw new RequestError(400, 'Invalid multipart body'); + } + const metadata = parts.get('metadata'); + if (metadata === undefined) throw new RequestError(400, 'Missing metadata'); + const fields = byteFields(metadata, ['key', 'secondary_key', 'value']); + const key = Buffer.from(fields.get('key')!).toString('hex'); + const secondary = Buffer.from(fields.get('secondary_key')!).toString('hex'); + const blob = parts.get('blob'); + const blobId = blob === undefined ? null : String(nextBlobId++); + + // Publish only after the complete request is validated. There is no await + // between these writes, so concurrent stores cannot mix values and blobs. + if (blobId !== null) blobs.set(blobId, blob!); + entries.set(key, { value: fields.get('value')!, blob_id: blobId }); + associations.set(secondary, key); + cbor(response, { blob_id: blobId }); + } + + return createServer({ requestTimeout: 10_000 }, (request, response) => { + void handle(request, response).catch((error: unknown) => { + const known = error instanceof RequestError; + if (!known) console.error(error); + response.writeHead(known ? error.status : 500, { + 'content-type': 'text/plain; charset=utf-8', + }); + response.end(known ? error.message : 'Internal server error'); + request.resume(); + }); + }); +} diff --git a/packages/tools/tsconfig.json b/packages/tools/tsconfig.json new file mode 100644 index 000000000..9f403cc30 --- /dev/null +++ b/packages/tools/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@tsconfig/strictest/tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "target": "ES2022", + "lib": ["ES2022"], + "types": ["node"], + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3747b9403..c4b92e8c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@anthropic-ai/sandbox-runtime': specifier: 0.0.75 version: 0.0.75 + '@fastify/busboy': + specifier: 3.2.2 + version: 3.2.2 '@openai/codex': specifier: 0.154.0 version: 0.154.0 @@ -27,6 +30,12 @@ catalogs: bun: specifier: 1.4.2 version: 1.4.2 + cbor-edn: + specifier: 0.2.2 + version: 0.2.2 + cbor2: + specifier: 1.11.0 + version: 1.11.0 cross-env: specifier: ^10.1.0 version: 10.1.0 @@ -82,6 +91,9 @@ importers: '@anthropic-ai/sandbox-runtime': specifier: 'catalog:' version: 0.0.75 + '@fastify/busboy': + specifier: 'catalog:' + version: 3.2.2 '@openai/codex': specifier: 'catalog:' version: 0.154.0 @@ -97,6 +109,12 @@ importers: bun: specifier: 'catalog:' version: 1.4.2 + cbor-edn: + specifier: 'catalog:' + version: 0.2.2 + cbor2: + specifier: 'catalog:' + version: 1.11.0 cross-env: specifier: 'catalog:' version: 10.1.0 @@ -124,6 +142,10 @@ importers: vitest: specifier: 'catalog:' version: 4.1.11(@types/node@25.0.3)(@vitest/browser-playwright@4.1.11)(@vitest/browser-preview@4.1.11)(vite@8.3.0(@types/node@25.0.3)) + devDependencies: + vite-task-tools: + specifier: workspace:* + version: 'link:' packages/vite-task-client: {} @@ -184,6 +206,9 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@fastify/busboy@3.2.2': + resolution: {integrity: sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -1316,6 +1341,15 @@ packages: os: [darwin, linux, android, freebsd, win32] hasBin: true + cbor-edn@0.2.2: + resolution: {integrity: sha512-/zu3hPQmrkHtHoW1Bp73pYRlIg1R34rNiP4L87FMI2EbBf7l0cO3B2W/rBx3W8mvqDh3Qc4cJy3DcAnVaamQPQ==} + engines: {node: '>=18.7'} + hasBin: true + + cbor2@1.11.0: + resolution: {integrity: sha512-teHnpDGcDzwouFJgCJLpDY2zMaEOYvYDryd6sln67wPrCuObmwriG1NAA1w7rUIf/l++bNdts4U7WeVdYTGwLg==} + engines: {node: '>=18.7'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1811,6 +1845,8 @@ snapshots: '@epic-web/invariant@1.0.0': {} + '@fastify/busboy@3.2.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} '@openai/codex@0.154.0': @@ -2581,6 +2617,12 @@ snapshots: '@oven/bun-windows-aarch64': 1.4.2 '@oven/bun-windows-x64': 1.4.2 + cbor-edn@0.2.2: + dependencies: + cbor2: 1.11.0 + + cbor2@1.11.0: {} + chai@6.2.2: {} commander@12.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 99501bf21..e99a1bd85 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,12 +10,15 @@ allowBuilds: catalog: '@anthropic-ai/sandbox-runtime': 0.0.75 + '@fastify/busboy': 3.2.2 '@openai/codex': 0.154.0 '@tsconfig/strictest': ^2.0.8 '@types/node': 25.0.3 '@playwright/browser-chromium': 1.63.0 '@vitest/browser-playwright': 4.1.11 bun: 1.4.2 + cbor-edn: 0.2.2 + cbor2: 1.11.0 cross-env: ^10.1.0 deno: 2.9.6 husky: ^9.1.7 From 8def103e784fa97020ade35a8ecb4df36f96a57b Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 17 Sep 2026 13:19:10 +0800 Subject: [PATCH 2/3] test(cache): simplify backend lifecycle and support Windows fixtures Co-authored-by: GPT-6 --- .typos.toml | 1 + .../remote_cache_backend/snapshots.toml | 220 ++++++++++++------ .../snapshots/blob_replacement.md | 30 +-- .../snapshots/daemon_lifecycle.md | 27 +-- .../snapshots/entries_and_associations.md | 26 +-- .../snapshots/invalid_requests.md | 42 ++-- .../snapshots/opaque_binary_data.md | 16 +- .../snapshots/request_limit.md | 12 +- packages/tools/README.md | 12 +- packages/tools/package.json | 2 +- packages/tools/src/cbor-http.test.ts | 60 ----- packages/tools/src/cbor-http.ts | 1 - packages/tools/src/edn.test.ts | 13 +- packages/tools/src/remote-cache/cli.test.ts | 79 ------- packages/tools/src/remote-cache/cli.ts | 203 ++++++++-------- .../tools/src/remote-cache/server.test.ts | 142 ----------- packages/tools/src/remote-cache/server.ts | 2 +- 17 files changed, 332 insertions(+), 556 deletions(-) delete mode 100644 packages/tools/src/cbor-http.test.ts delete mode 100644 packages/tools/src/remote-cache/cli.test.ts delete mode 100644 packages/tools/src/remote-cache/server.test.ts diff --git a/.typos.toml b/.typos.toml index e7642038d..c227ba5df 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,6 +1,7 @@ [default.extend-words] ratatui = "ratatui" PUNICODE = "PUNICODE" +EDN = "EDN" # Win32 spells `ERROR_FILENAME_EXCED_RANGE` this way. EXCED = "EXCED" diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml index f2eadc9f0..d3e701e80 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml @@ -3,88 +3,101 @@ name = "entries_and_associations" ignore = true steps = [ [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "start", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'VA'}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'U', \"value\": 'VA'}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", "metadata={\"key\": 'B', \"secondary_key\": 'S', \"value\": 'VB'}", ], { argv = [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], comment = "The exact entry survives reassignment of S to B." }, { argv = [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'C', \"secondary_key\": 'S'}", ], comment = "The previous fetch did not change S. Fallback returns only B's key." }, [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'T', \"value\": 'VA2'}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'missing'}", ], { argv = [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'C', \"secondary_key\": 'U'}", ], comment = "An older association to A still resolves after A is replaced." }, [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'C', \"secondary_key\": 'T'}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'C', \"secondary_key\": 'S'}", ], [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "stop", ], ] @@ -94,11 +107,13 @@ name = "blob_replacement" ignore = true steps = [ [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "start", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "GET", "/blob/missing", ], @@ -109,7 +124,8 @@ steps = [ "first archive", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", @@ -118,19 +134,22 @@ steps = [ "blob=archive.txt", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "GET", "/blob/1", ], { argv = [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-data", @@ -139,38 +158,44 @@ steps = [ "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'second'}", ], comment = "Accept blob before metadata, with neither part supplying a filename." }, [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "GET", "/blob/2", ], { argv = [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "GET", "/blob/1", ], comment = "Previously returned IDs retain their original bytes." }, { argv = [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'no archive'}", ], comment = "Omitting blob clears the association." }, [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", @@ -179,19 +204,22 @@ steps = [ "blob=''", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], { argv = [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "GET", "/blob/3", ], comment = "An empty blob has an ID and downloads as an empty byte string." }, [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "stop", ], ] @@ -201,11 +229,13 @@ name = "opaque_binary_data" ignore = true steps = [ [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "start", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", @@ -214,40 +244,46 @@ steps = [ "blob=b64'AP+A'", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": b64'AP+A', \"secondary_key\": ''}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": '', \"secondary_key\": ''}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "GET", "/blob/1", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", "metadata={\"key\": '', \"secondary_key\": '', \"value\": ''}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": '', \"secondary_key\": ''}", ], [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "stop", ], ] @@ -257,23 +293,27 @@ name = "invalid_requests" ignore = true steps = [ [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "start", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "GET", "/missing", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--data", "'bad'", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--data", @@ -282,35 +322,40 @@ steps = [ "application/cbor", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": \"text\", \"secondary_key\": ''}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": ''}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "[]", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": '', \"key\": 'duplicate', \"secondary_key\": ''}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", @@ -319,28 +364,32 @@ steps = [ "blob='original archive'", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--cbor", "{}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-data", "blob='missing metadata'", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'T', \"value\": \"wrong type\"}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", @@ -349,7 +398,8 @@ steps = [ "metadata={}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", @@ -360,7 +410,8 @@ steps = [ "blob='two'", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", @@ -369,14 +420,16 @@ steps = [ "unexpected='part'", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-data", "metadata='wrong content type'", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--data", @@ -385,26 +438,30 @@ steps = [ "multipart/form-data; boundary=boundary", ], { argv = [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], comment = "Failed stores did not replace the value or blob." }, [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "GET", "/blob/1", ], { argv = [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'B', \"secondary_key\": 'T'}", ], comment = "Failed stores did not publish a secondary association." }, [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "stop", ], ] @@ -414,13 +471,15 @@ name = "request_limit" ignore = true steps = [ [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "start", "--max-request-bytes", "1024", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", @@ -432,7 +491,8 @@ steps = [ "require('node:fs').writeFileSync('large.bin', Buffer.alloc(2048))", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", @@ -441,21 +501,24 @@ steps = [ "blob=large.bin", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'B', \"secondary_key\": 'T'}", ], [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "stop", ], ] @@ -465,48 +528,51 @@ name = "daemon_lifecycle" ignore = true steps = [ [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "start", "--base-path", "/projects/test", ], [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "start", ], [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'first daemon'}", ], - [ - "vtt", - "rm", - "cache.url", - ], { argv = [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "stop", - ], comment = "Wait for shutdown after file deletion, even when another command removed the file." }, + ], comment = "Connect before deleting cache.url, then wait for the connection to close before restarting." }, [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "start", ], { argv = [ - "cbor-http", + "node", + "../node_modules/vite-task-tools/src/cbor-http.ts", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], comment = "A new daemon starts with empty state." }, [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "stop", ], [ - "remote-cache-server", + "node", + "../node_modules/vite-task-tools/src/remote-cache/cli.ts", "stop", ], ] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md index 23d025b5b..232b05b33 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md @@ -1,12 +1,12 @@ # blob_replacement -## `remote-cache-server start` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` ``` Remote cache server started ``` -## `cbor-http GET /blob/missing` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/missing` ``` {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Blob not found"} @@ -17,25 +17,25 @@ Remote cache server started ``` ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''first'\''}' --form-file blob=archive.txt` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''first'\''}' --form-file blob=archive.txt` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'first', "blob_id": "1"}} ``` -## `cbor-http GET /blob/1` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/1` ``` {"status": 200, "content_type": "application/octet-stream", "body": 'first archive'} ``` -## `cbor-http POST /store --form-data 'blob='\''second archive'\''' --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''second'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-data 'blob='\''second archive'\''' --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''second'\''}'` Accept blob before metadata, with neither part supplying a filename. @@ -43,19 +43,19 @@ Accept blob before metadata, with neither part supplying a filename. {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "2"}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'second', "blob_id": "2"}} ``` -## `cbor-http GET /blob/2` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/2` ``` {"status": 200, "content_type": "application/octet-stream", "body": 'second archive'} ``` -## `cbor-http GET /blob/1` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/1` Previously returned IDs retain their original bytes. @@ -63,7 +63,7 @@ Previously returned IDs retain their original bytes. {"status": 200, "content_type": "application/octet-stream", "body": 'first archive'} ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''no archive'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''no archive'\''}'` Omitting blob clears the association. @@ -71,25 +71,25 @@ Omitting blob clears the association. {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'no archive', "blob_id": null}} ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''empty archive'\''}' --form-data blob=''` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''empty archive'\''}' --form-data blob=''` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "3"}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'empty archive', "blob_id": "3"}} ``` -## `cbor-http GET /blob/3` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/3` An empty blob has an ID and downloads as an empty byte string. @@ -97,7 +97,7 @@ An empty blob has an ID and downloads as an empty byte string. {"status": 200, "content_type": "application/octet-stream", "body": ''} ``` -## `remote-cache-server stop` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` ``` Remote cache server stopped diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md index 6eb660581..14b1ae0bf 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md @@ -1,45 +1,40 @@ # daemon_lifecycle -## `remote-cache-server start --base-path /projects/test` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start --base-path /projects/test` ``` Remote cache server started ``` -## `remote-cache-server start` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` **Exit code:** 1 ``` -Remote cache server already started (cache.lock exists) +Remote cache server already started (cache.url exists) ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''first daemon'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''first daemon'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `vtt rm cache.url` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` -``` -``` - -## `remote-cache-server stop` - -Wait for shutdown after file deletion, even when another command removed the file. +Connect before deleting cache.url, then wait for the connection to close before restarting. ``` Remote cache server stopped ``` -## `remote-cache-server start` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` ``` Remote cache server started ``` -## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` A new daemon starts with empty state. @@ -47,14 +42,14 @@ A new daemon starts with empty state. {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} ``` -## `remote-cache-server stop` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` ``` Remote cache server stopped ``` -## `remote-cache-server stop` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` ``` -Remote cache server stopped +No remote cache endpoint found ``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md index d52e2d149..7fa1cc11a 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md @@ -1,36 +1,36 @@ # entries_and_associations -## `remote-cache-server start` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` ``` Remote cache server started ``` -## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''VA'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''VA'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''U'\'', "value": '\''VA'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''U'\'', "value": '\''VA'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''B'\'', "secondary_key": '\''S'\'', "value": '\''VB'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''B'\'', "secondary_key": '\''S'\'', "value": '\''VB'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` The exact entry survives reassignment of S to B. @@ -38,7 +38,7 @@ The exact entry survives reassignment of S to B. {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'VA', "blob_id": null}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` The previous fetch did not change S. Fallback returns only B's key. @@ -46,19 +46,19 @@ The previous fetch did not change S. Fallback returns only B's key. {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'B'}} ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''VA2'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''VA2'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''missing'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''missing'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'VA2', "blob_id": null}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''U'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''U'\''}'` An older association to A still resolves after A is replaced. @@ -66,19 +66,19 @@ An older association to A still resolves after A is replaced. {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'A'}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''T'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''T'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'A'}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'B'}} ``` -## `remote-cache-server stop` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` ``` Remote cache server stopped diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md index de39985ce..576222bca 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md @@ -1,108 +1,108 @@ # invalid_requests -## `remote-cache-server start` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` ``` Remote cache server started ``` -## `cbor-http GET /missing` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /missing` ``` {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Route not found"} ``` -## `cbor-http POST /fetch --data 'bad'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --data 'bad'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected application/cbor"} ``` -## `cbor-http POST /fetch --data 'bad' --content-type application/cbor` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --data 'bad' --content-type application/cbor` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} ``` -## `cbor-http POST /fetch --cbor '{"key": "text", "secondary_key": '\'''\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": "text", "secondary_key": '\'''\''}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} ``` -## `cbor-http POST /fetch --cbor '{"key": '\'''\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\'''\''}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} ``` -## `cbor-http POST /fetch --cbor []` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor []` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} ``` -## `cbor-http POST /fetch --cbor '{"key": '\'''\'', "key": '\''duplicate'\'', "secondary_key": '\'''\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\'''\'', "key": '\''duplicate'\'', "secondary_key": '\'''\''}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid CBOR"} ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''original'\''}' --form-data 'blob='\''original archive'\'''` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''original'\''}' --form-data 'blob='\''original archive'\'''` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} ``` -## `cbor-http POST /store --cbor {}` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --cbor {}` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected multipart/form-data"} ``` -## `cbor-http POST /store --form-data 'blob='\''missing metadata'\'''` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-data 'blob='\''missing metadata'\'''` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Missing metadata"} ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": "wrong type"}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": "wrong type"}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key, value"} ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-cbor metadata={}` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-cbor metadata={}` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-data blob='one' --form-data blob='two'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-data blob='one' --form-data blob='two'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} ``` -## `cbor-http POST /store --form-cbor metadata={} --form-data unexpected='part'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor metadata={} --form-data unexpected='part'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} ``` -## `cbor-http POST /store --form-data 'metadata='\''wrong content type'\'''` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-data 'metadata='\''wrong content type'\'''` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} ``` -## `cbor-http POST /store --data ''\''--boundary\r\nContent-Disposition: form-data; name="metadata"\r\nContent-Type: application/cbor\r\n\r\n'\''' --content-type 'multipart/form-data; boundary=boundary'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --data ''\''--boundary\r\nContent-Disposition: form-data; name="metadata"\r\nContent-Type: application/cbor\r\n\r\n'\''' --content-type 'multipart/form-data; boundary=boundary'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` Failed stores did not replace the value or blob. @@ -110,13 +110,13 @@ Failed stores did not replace the value or blob. {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'original', "blob_id": "1"}} ``` -## `cbor-http GET /blob/1` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/1` ``` {"status": 200, "content_type": "application/octet-stream", "body": 'original archive'} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''B'\'', "secondary_key": '\''T'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''B'\'', "secondary_key": '\''T'\''}'` Failed stores did not publish a secondary association. @@ -124,7 +124,7 @@ Failed stores did not publish a secondary association. {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} ``` -## `remote-cache-server stop` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` ``` Remote cache server stopped diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md index d13fcf0db..82935f284 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md @@ -1,48 +1,48 @@ # opaque_binary_data -## `remote-cache-server start` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` ``` Remote cache server started ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": b64'\''AP+A'\'', "secondary_key": '\'''\'', "value": b64'\''AP+A'\''}' --form-data blob=b64'AP+A'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": b64'\''AP+A'\'', "secondary_key": '\'''\'', "value": b64'\''AP+A'\''}' --form-data blob=b64'AP+A'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} ``` -## `cbor-http POST /fetch --cbor '{"key": b64'\''AP+A'\'', "secondary_key": '\'''\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": b64'\''AP+A'\'', "secondary_key": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": b64'AP+A', "blob_id": "1"}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": b64'AP+A'}} ``` -## `cbor-http GET /blob/1` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/1` ``` {"status": 200, "content_type": "application/octet-stream", "body": b64'AP+A'} ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\'''\'', "secondary_key": '\'''\'', "value": '\'''\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\'''\'', "secondary_key": '\'''\'', "value": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": '', "blob_id": null}} ``` -## `remote-cache-server stop` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` ``` Remote cache server stopped diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md index 38aceb511..60d4c26ec 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md @@ -1,12 +1,12 @@ # request_limit -## `remote-cache-server start --max-request-bytes 1024` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start --max-request-bytes 1024` ``` Remote cache server started ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''original'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''original'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} @@ -17,25 +17,25 @@ Remote cache server started ``` ``` -## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-file blob=large.bin` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-file blob=large.bin` ``` {"status": 413, "content_type": "text/plain; charset=utf-8", "body": "Request too large"} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'original', "blob_id": null}} ``` -## `cbor-http POST /fetch --cbor '{"key": '\''B'\'', "secondary_key": '\''T'\''}'` +## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''B'\'', "secondary_key": '\''T'\''}'` ``` {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} ``` -## `remote-cache-server stop` +## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` ``` Remote cache server stopped diff --git a/packages/tools/README.md b/packages/tools/README.md index 46ed71a13..b15314da9 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -11,9 +11,11 @@ cbor-http POST /fetch --cbor "{\"key\": 'A', \"secondary_key\": 'S'}" remote-cache-server stop ``` -`start` launches an in-memory daemon on a free loopback port and writes its endpoint to `cache.url` in the current directory. It returns when the daemon is ready. Each E2E case has its own directory and server state. Deleting `cache.url` stops the daemon; `stop` deletes it and waits for shutdown. The daemon also exits after five minutes so a failed test cannot leave it running indefinitely. Use `--max-lifetime-ms` on `start` for longer tests. +`start` creates `cache.url` exclusively in the current directory, refusing to start if it already exists. It launches an in-memory daemon on a free loopback port and fills the file with its endpoint before returning. Each E2E case has its own directory and server state. -`cache.lock` prevents overlapping starts in the same directory. It is removed after the server closes. Daemon output goes to `cache.log`, and startup failures include that log. These files stay in the test's directory. +`stop` connects to the endpoint, deletes `cache.url`, and reads until EOF or a connection reset confirms shutdown. The daemon watches the directory for file changes and closes its listener before closing active connections. Wait for `stop` to finish before restarting in the same directory. Deleting the file manually also requests shutdown, but a later `stop` cannot wait for the server because the endpoint is gone. The harness enforces step timeouts and skips remaining steps when one times out. The detached daemon exits after five minutes so it is cleaned up even if `stop` never runs. Use `--max-lifetime-ms` on `start` for longer tests. + +Daemon output goes to `cache.log`, and startup failures include that log. The log stays in the test's directory. The backend implements `POST /fetch`, `POST /store`, and `GET /blob/{blob_id}` from the [remote cache RFC](https://github.com/voidzero-dev/vite-task/blob/rfc-cloudflare-remote-cache/docs/rfcs/0001-remote-cache.md#4-http-api-mapping). Keys and values are opaque bytes. Blobs remain unchanged until the daemon exits. Blob IDs are sequential strings to keep snapshots deterministic. There is no authentication or persistent storage. @@ -34,10 +36,12 @@ The backend implements `POST /fetch`, `POST /store`, and `GET /blob/{blob_id}` f Multipart options can repeat and retain their order. Raw bodies and parts default to `application/octet-stream`. Parts have no filename, including binary metadata. -Every response prints one EDN map containing `status`, `content_type`, and `body`. CBOR is decoded, text becomes a text string, and other bodies become byte strings. Byte strings use readable UTF-8 when possible and base64 EDN otherwise. HTTP error responses exit successfully so snapshots can assert their status and body. Argument errors, transport failures, and invalid CBOR responses exit unsuccessfully. Requests time out after ten seconds. +Every response prints one EDN map containing `status`, `content_type`, and `body`. CBOR is decoded, text becomes a text string, and other bodies become byte strings. Byte strings use readable UTF-8 when possible and base64 EDN otherwise. HTTP error responses exit successfully so snapshots can assert their status and body. Argument errors, transport failures, and invalid CBOR responses exit unsuccessfully. ```text {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'record', "blob_id": null}} ``` -Run `pnpm --filter vite-task-tools check` for type checking and `pnpm --filter vite-task-tools test` for utility tests. Run `cargo test -p vt_bin --test e2e_snapshots -- remote_cache_backend --ignored` for the backend snapshots. +Run `pnpm --filter vite-task-tools check` for type checking and `pnpm --filter vite-task-tools test` for the EDN-formatting unit test. Run `cargo test -p vt_bin --test e2e_snapshots -- remote_cache_backend --ignored` for the backend snapshots. + +E2E steps invoke the TypeScript files with `node` through the existing `../node_modules/vite-task-tools` link. This avoids launching shell command shims as executables on Windows and preserves EDN arguments without shell quoting. diff --git a/packages/tools/package.json b/packages/tools/package.json index baa480229..8dc90a5a6 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -8,7 +8,7 @@ "type": "module", "scripts": { "check": "tsc --noEmit -p tsconfig.json", - "test": "node --test src/*.test.ts src/remote-cache/*.test.ts" + "test": "node --test src/*.test.ts" }, "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", diff --git a/packages/tools/src/cbor-http.test.ts b/packages/tools/src/cbor-http.test.ts deleted file mode 100644 index 8332463ac..000000000 --- a/packages/tools/src/cbor-http.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; -import { once } from 'node:events'; -import { createServer } from 'node:http'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; - -const exec = promisify(execFile); -const cli = fileURLToPath(new URL('./cbor-http.ts', import.meta.url)); - -await test('the HTTP client translates CBOR, text errors, and raw responses to EDN', async (t) => { - const server = createServer(async (request, response) => { - const chunks = []; - for await (const chunk of request) chunks.push(Buffer.from(chunk)); - if (request.url === '/cbor') { - // RFC 8949: a map with one text key and one byte-string value. - assert.deepEqual(Buffer.concat(chunks), Buffer.from([0xa1, 0x61, 0x61, 0x41, 0x62])); - response.writeHead(200, { 'content-type': 'application/cbor' }); - response.end(Buffer.concat(chunks)); - } else if (request.url === '/text') { - response.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' }); - response.end('try again'); - } else if (request.url === '/raw') { - response.writeHead(200, { 'content-type': 'application/octet-stream' }); - response.end(Buffer.from([0, 255, 128])); - } else { - response.writeHead(200, { 'content-type': 'application/cbor' }); - response.end(Buffer.from([0x41])); // Byte string missing its byte. - } - }); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - t.after(() => { - server.close(); - server.closeAllConnections(); - }); - const address = server.address(); - assert.ok(address && typeof address !== 'string'); - const run = (path: string, ...args: string[]) => - exec(process.execPath, [cli, 'POST', `http://127.0.0.1:${address.port}${path}`, ...args], { - timeout: 15_000, - }); - assert.equal( - (await run('/cbor', '--cbor', '{"a": \'b\'}')).stdout.trim(), - '{"status": 200, "content_type": "application/cbor", "body": {"a": \'b\'}}', - ); - assert.equal( - (await run('/text')).stdout.trim(), - '{"status": 503, "content_type": "text/plain; charset=utf-8", "body": "try again"}', - ); - assert.equal( - (await run('/raw')).stdout.trim(), - '{"status": 200, "content_type": "application/octet-stream", "body": b64\'AP+A\'}', - ); - await assert.rejects(run('/invalid'), (error: unknown) => { - assert.equal((error as { code: number }).code, 1); - return true; - }); -}); diff --git a/packages/tools/src/cbor-http.ts b/packages/tools/src/cbor-http.ts index 7e540c890..b889d7fc9 100755 --- a/packages/tools/src/cbor-http.ts +++ b/packages/tools/src/cbor-http.ts @@ -84,7 +84,6 @@ async function main(): Promise { method, ...(body === undefined ? {} : { body }), ...(contentType === undefined ? {} : { headers: { 'content-type': contentType } }), - signal: AbortSignal.timeout(10_000), }); const responseType = response.headers.get('content-type') ?? ''; const raw = new Uint8Array(await response.arrayBuffer()); diff --git a/packages/tools/src/edn.test.ts b/packages/tools/src/edn.test.ts index 68faa485b..50f5d5721 100644 --- a/packages/tools/src/edn.test.ts +++ b/packages/tools/src/edn.test.ts @@ -5,24 +5,13 @@ import { decode } from 'cbor2/decoder'; import { encode } from 'cbor2/encoder'; import { toEDN } from './edn.ts'; -await test('EDN distinguishes binary and text, including escapes and invalid UTF-8', () => { +await test('EDN formatting preserves escapes, a UTF-8 BOM, and text resembling byte strings', () => { for (const value of [ - new Uint8Array(), - new Uint8Array([0, 255, 128]), new TextEncoder().encode("quotes: '\"\\\n\t\0 and h'ff'"), new Uint8Array([239, 187, 191, 65]), { text: "h'ff'", bytes: new TextEncoder().encode('hello') }, - { text: '"quoted"', values: [true, 1, 3.5, null] }, ]) { const edn = toEDN(encode(value)); assert.deepEqual(decode(parseEDN(edn, {})), value); } - assert.equal(toEDN(encode(new Uint8Array([0, 255, 128]))), "b64'AP+A'"); - assert.equal(toEDN(encode(new TextEncoder().encode('hello'))), "'hello'"); -}); - -await test('known RFC 8949 encodings decode without depending on our request encoder', () => { - assert.equal(toEDN(Uint8Array.of(0x43, 0x61, 0x62, 0x63)), "'abc'"); - assert.equal(toEDN(Uint8Array.of(0x63, 0x61, 0x62, 0x63)), '"abc"'); - assert.equal(toEDN(Uint8Array.of(0xa1, 0x61, 0x61, 0x01)), '{"a": 1}'); }); diff --git a/packages/tools/src/remote-cache/cli.test.ts b/packages/tools/src/remote-cache/cli.test.ts deleted file mode 100644 index bc61a32b0..000000000 --- a/packages/tools/src/remote-cache/cli.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFile } from 'node:child_process'; -import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { setTimeout as delay } from 'node:timers/promises'; -import test, { type TestContext } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { promisify } from 'node:util'; - -const exec = promisify(execFile); -const cli = fileURLToPath(new URL('./cli.ts', import.meta.url)); - -async function fixture(t: TestContext) { - const cwd = await mkdtemp(join(tmpdir(), 'remote-cache-test-')); - const run = (...args: string[]) => - exec(process.execPath, [cli, ...args], { cwd, timeout: 15_000 }); - t.after(async () => { - try { - await run('stop'); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - return { cwd, run }; -} - -async function waitForRemoval(path: string): Promise { - const deadline = Date.now() + 10_000; - while (true) { - try { - await access(path); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; - throw error; - } - assert.ok(Date.now() < deadline, `Daemon did not remove ${path}`); - await delay(25); - } -} - -await test('deleting cache.url stops the daemon and closes the listener', async (t) => { - const { cwd, run } = await fixture(t); - await run('start'); - const url = (await readFile(join(cwd, 'cache.url'), 'utf8')).trim(); - assert.equal((await fetch(`${url}/fetch`)).status, 404); - await rm(join(cwd, 'cache.url')); - await waitForRemoval(join(cwd, 'cache.lock')); - await assert.rejects(fetch(`${url}/fetch`)); -}); - -await test('maximum lifetime cleans up a daemon even when stop never runs', async (t) => { - const { cwd, run } = await fixture(t); - await run('start', '--max-lifetime-ms', '800'); - const url = (await readFile(join(cwd, 'cache.url'), 'utf8')).trim(); - await waitForRemoval(join(cwd, 'cache.lock')); - await assert.rejects(access(join(cwd, 'cache.url'))); - await assert.rejects(fetch(`${url}/fetch`)); -}); - -await test('concurrent starts have one owner and separate directories stay isolated', async (t) => { - const a = await fixture(t); - const b = await fixture(t); - const starts = await Promise.allSettled([a.run('start'), a.run('start'), b.run('start')]); - assert.equal(starts.filter((result) => result.status === 'fulfilled').length, 2); - const urlA = (await readFile(join(a.cwd, 'cache.url'), 'utf8')).trim(); - const urlB = (await readFile(join(b.cwd, 'cache.url'), 'utf8')).trim(); - assert.notEqual(urlA, urlB); - await a.run('stop'); - assert.equal((await fetch(`${urlB}/fetch`)).status, 404); -}); - -await test('startup refuses an existing URL file and releases its lock', async (t) => { - const { cwd, run } = await fixture(t); - await writeFile(join(cwd, 'cache.url'), 'do not overwrite'); - await assert.rejects(run('start'), /cache.url already exists/); - assert.equal(await readFile(join(cwd, 'cache.url'), 'utf8'), 'do not overwrite'); - await assert.rejects(access(join(cwd, 'cache.lock'))); -}); diff --git a/packages/tools/src/remote-cache/cli.ts b/packages/tools/src/remote-cache/cli.ts index 2762d8a49..b1dec8658 100755 --- a/packages/tools/src/remote-cache/cli.ts +++ b/packages/tools/src/remote-cache/cli.ts @@ -1,152 +1,157 @@ #!/usr/bin/env node import { fork } from 'node:child_process'; -import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; -import { setTimeout as delay } from 'node:timers/promises'; +import { once } from 'node:events'; +import { open, readFile, rm, watch, writeFile } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import { Readable } from 'node:stream'; +import { setTimeout } from 'node:timers/promises'; import { parseArgs } from 'node:util'; import { createCacheServer } from './server.ts'; const urlFile = 'cache.url'; -const lockDir = 'cache.lock'; const logFile = 'cache.log'; -const startupTimeout = 10_000; - -async function exists(path: string): Promise { - try { - await stat(path); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; - throw error; - } -} - -async function waitUntil(check: () => Promise): Promise { - const deadline = Date.now() + startupTimeout; - while (!(await check())) { - if (Date.now() >= deadline) throw new Error('Timed out waiting for remote cache server'); - await delay(50); - } -} async function start(args: string[]): Promise { try { - await mkdir(lockDir); + const file = await open(urlFile, 'wx'); + await file.close(); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') { - throw new Error('Remote cache server already started (cache.lock exists)'); + throw new Error('Remote cache server already started (cache.url exists)'); } throw error; } try { - if (await exists(urlFile)) throw new Error('cache.url already exists'); const log = await open(logFile, 'w'); const child = fork(import.meta.filename, ['serve', ...args], { detached: true, stdio: ['ignore', log.fd, log.fd, 'ipc'], }); try { - await new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error('Remote cache server startup timed out')), - startupTimeout, - ); - child.once('message', (message) => { - clearTimeout(timeout); - if (message === 'ready') resolve(); - else reject(new Error('Unexpected daemon readiness message')); - }); - child.once('error', (error) => { - clearTimeout(timeout); - reject(error); - }); - child.once('exit', () => { - clearTimeout(timeout); - reject(new Error('Remote cache server exited before readiness')); - }); - }); + const [message] = await once(child, 'message'); + if (message !== 'ready') throw new Error('Unexpected daemon readiness message'); } catch (error) { - // A failed startup must not leave a detached child or a stale lock. - if (child.pid !== undefined && child.exitCode === null && child.signalCode === null) { - const exited = new Promise((resolve) => child.once('exit', () => resolve())); - child.kill('SIGKILL'); - await exited; - } - await rm(urlFile, { force: true }); - throw new Error( - `${error instanceof Error ? error.message : String(error)}\n${await readFile(logFile, 'utf8')}`.trim(), - ); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${message}\n${await readFile(logFile, 'utf8')}`.trim()); } finally { await log.close(); if (child.connected) child.disconnect(); child.unref(); } } catch (error) { - await rm(lockDir, { recursive: true, force: true }); + await rm(urlFile, { force: true }); throw error; } console.log('Remote cache server started'); } +async function ownsUrlFile(url: string | undefined): Promise { + try { + return (await readFile(urlFile, 'utf8')).trim() === url; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + async function serve( maxLifetimeMs: number, maxRequestBytes: number, basePath: string, ): Promise { const server = createCacheServer({ maxRequestBytes, basePath }); - let stopping = false; - let ready = false; + const shutdown = new AbortController(); + const { signal } = shutdown; let url: string | undefined; - process.on('SIGTERM', () => { - stopping = true; - }); - process.on('SIGINT', () => { - stopping = true; - }); - // If the starter disappears before readiness, nobody can use this instance. - process.on('disconnect', () => { - if (!ready) stopping = true; - }); - try { - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolve); - }); + + async function watchUrlFile(): Promise { + // Watch the directory so deleting the file is observable on every platform. + for await (const { filename } of watch('.', { signal })) { + if ((filename === null || filename === urlFile) && !(await ownsUrlFile(url))) return; + } + } + + async function announceReadiness(): Promise { + // Check after subscribing to file changes, so startup cannot miss a deletion. + // If the starter disappeared, nobody can use this instance. + if (!(await ownsUrlFile(url)) || !process.connected || signal.aborted) return; + process.send?.('ready'); + await setTimeout(maxLifetimeMs, undefined, { signal }); + } + + async function run(): Promise { + const listening = once(server, 'listening', { signal }); + server.listen(0, '127.0.0.1'); + await listening; const address = server.address(); if (!address || typeof address === 'string') throw new Error('Missing server address'); url = `http://127.0.0.1:${address.port}${basePath}`; - await writeFile(`${lockDir}/url.tmp`, `${url}\n`); - await rename(`${lockDir}/url.tmp`, urlFile); - if (process.connected) process.send?.('ready'); - ready = true; - const deadline = Date.now() + maxLifetimeMs; - while (!stopping && Date.now() < deadline) { - try { - if ((await readFile(urlFile, 'utf8')).trim() !== url) break; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') break; - throw error; - } - await delay(50); - } + // Fill the reserved file before reporting readiness. Do not recreate it if + // it was deleted during startup. + await writeFile(urlFile, `${url}\n`, { flag: 'r+' }); + await Promise.race([watchUrlFile(), announceReadiness()]); + } + + const pending = [ + once(process, 'SIGTERM', { signal }), + once(process, 'SIGINT', { signal }), + run(), + ]; + try { + await Promise.race(pending); } finally { - await new Promise((resolve) => { - server.close(() => resolve()); - server.closeAllConnections(); - }); - // Removing the lock acknowledges that the listening socket has closed. + shutdown.abort(); + await Promise.allSettled(pending); + // Finish file cleanup before closing the port, so a subsequent start after + // `stop` returns cannot have its URL file removed by this instance. try { - if ((await readFile(urlFile, 'utf8')).trim() === url) await rm(urlFile, { force: true }); + if (await ownsUrlFile(url)) await rm(urlFile, { force: true }); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error(error); - process.exitCode = 1; - } + console.error(error); + process.exitCode = 1; } finally { - await rm(lockDir, { recursive: true, force: true }); + if (server.listening) { + const closed = server[Symbol.asyncDispose](); + server.closeAllConnections(); + await closed; + } } } } +async function stop(): Promise { + let endpoint: string; + try { + endpoint = await readFile(urlFile, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + console.log('No remote cache endpoint found'); + return; + } + const url = new URL(endpoint.trim()); + const socket = createConnection({ + host: url.hostname, + port: Number(url.port), + }); + const reader = Readable.toWeb(socket).getReader(); + try { + await once(socket, 'connect'); + await rm(urlFile, { force: true }); + // Shutdown closes the listener before this connection. EOF or a reset + // confirms the server has stopped accepting connections. + const { done } = await reader.read(); + if (!done) throw new Error('Unexpected data while waiting for remote cache server shutdown'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ECONNREFUSED' && code !== 'ECONNRESET') throw error; + await rm(urlFile, { force: true }); + } finally { + reader.releaseLock(); + socket.destroy(); + } + console.log('Remote cache server stopped'); +} + function positiveInteger(value: string): number { const number = Number(value); if (!Number.isSafeInteger(number) || number <= 0 || number > 2_147_483_647) { @@ -186,9 +191,7 @@ async function main(): Promise { await serve(lifetime, limit, basePath); break; case 'stop': - await rm(urlFile, { force: true }); - await waitUntil(async () => !(await exists(lockDir))); - console.log('Remote cache server stopped'); + await stop(); break; default: throw new Error('Usage: remote-cache-server start|stop'); diff --git a/packages/tools/src/remote-cache/server.test.ts b/packages/tools/src/remote-cache/server.test.ts deleted file mode 100644 index 0a16367be..000000000 --- a/packages/tools/src/remote-cache/server.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import assert from 'node:assert/strict'; -import { once } from 'node:events'; -import { request } from 'node:http'; -import test, { type TestContext } from 'node:test'; -import { decode } from 'cbor2/decoder'; -import { encode } from 'cbor2/encoder'; -import { createCacheServer } from './server.ts'; - -async function backend(t: TestContext, maxRequestBytes?: number): Promise { - const server = createCacheServer(maxRequestBytes === undefined ? {} : { maxRequestBytes }); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - t.after(async () => { - const closed = once(server, 'close'); - server.close(); - server.closeAllConnections(); - await closed; - }); - const address = server.address(); - assert.ok(address && typeof address !== 'string'); - return `http://127.0.0.1:${address.port}`; -} - -function data(value: string): Uint8Array { - return new TextEncoder().encode(value); -} - -async function lookup(url: string, key: Uint8Array, secondary = data('S')) { - const response = await fetch(`${url}/fetch`, { - method: 'POST', - headers: { 'content-type': 'application/cbor' }, - body: Buffer.from(encode({ key, secondary_key: secondary })), - }); - assert.equal(response.status, 200); - return decode<{ kind: string; value: Uint8Array; blob_id: string }>( - new Uint8Array(await response.arrayBuffer()), - ); -} - -await test('concurrent stores keep values paired with immutable blobs', async (t) => { - const url = await backend(t); - const responses = await Promise.all( - Array.from({ length: 12 }, async (_, index) => { - const bytes = data(`value-${index}`); - const form = new FormData(); - form.set( - 'metadata', - new Blob( - [Buffer.from(encode({ key: data('A'), secondary_key: data('S'), value: bytes }))], - { type: 'application/cbor' }, - ), - ); - form.set('blob', new Blob([bytes], { type: 'application/octet-stream' })); - const response = await fetch(`${url}/store`, { method: 'POST', body: form }); - assert.equal(response.status, 200); - const { blob_id } = decode<{ blob_id: string }>(new Uint8Array(await response.arrayBuffer())); - return { bytes, blob_id }; - }), - ); - const exact = await lookup(url, data('A')); - assert.equal(exact.kind, 'exact'); - assert.ok(exact.value instanceof Uint8Array); - const downloaded = new Uint8Array( - await (await fetch(`${url}/blob/${exact.blob_id}`)).arrayBuffer(), - ); - assert.deepEqual(downloaded, exact.value); - for (const { bytes, blob_id } of responses) { - const response = await fetch(`${url}/blob/${blob_id}`); - assert.equal(response.status, 200); - assert.deepEqual(new Uint8Array(await response.arrayBuffer()), bytes); - } -}); - -await test('large keys and values are accepted without implicit multipart field limits', async (t) => { - const url = await backend(t); - const key = data('k'.repeat(128 * 1024)); - const value = data('v'.repeat(2 * 1024 * 1024)); - const form = new FormData(); - form.set( - 'metadata', - new Blob([Buffer.from(encode({ key, secondary_key: key, value }))], { - type: 'application/cbor', - }), - ); - assert.equal((await fetch(`${url}/store`, { method: 'POST', body: form })).status, 200); - assert.deepEqual((await lookup(url, key, key)).value, value); -}); - -await test('an aborted upload never publishes metadata received before the blob', async (t) => { - const url = await backend(t); - const first = new FormData(); - first.set( - 'metadata', - new Blob( - [Buffer.from(encode({ key: data('A'), secondary_key: data('S'), value: data('original') }))], - { type: 'application/cbor' }, - ), - ); - assert.equal((await fetch(`${url}/store`, { method: 'POST', body: first })).status, 200); - - const upload = request(`${url}/store`, { - method: 'POST', - headers: { 'content-type': 'multipart/form-data; boundary=test' }, - }); - upload.on('error', () => {}); // Aborting our own socket is intentional. - upload.write( - '--test\r\nContent-Disposition: form-data; name="metadata"\r\nContent-Type: application/cbor\r\n\r\n', - ); - upload.write(encode({ key: data('A'), secondary_key: data('T'), value: data('replacement') })); - upload.write( - '\r\n--test\r\nContent-Disposition: form-data; name="blob"\r\nContent-Type: application/octet-stream\r\n\r\n', - ); - await new Promise((resolve) => upload.write('partial blob', () => resolve())); - const closed = new Promise((resolve) => upload.once('close', resolve)); - upload.destroy(); - await closed; - assert.deepEqual((await lookup(url, data('A'))).value, data('original')); - const response = await fetch(`${url}/fetch`, { - method: 'POST', - headers: { 'content-type': 'application/cbor' }, - body: Buffer.from(encode({ key: data('B'), secondary_key: data('T') })), - }); - assert.equal(response.status, 404); -}); - -await test('chunked requests exceeding the limit return 413 without Content-Length', async (t) => { - const url = await backend(t, 32); - const upload = request(`${url}/fetch`, { - method: 'POST', - headers: { 'content-type': 'application/cbor' }, - }); - const result = new Promise((resolve, reject) => { - upload.on('error', reject); - upload.on('response', (response) => { - response.resume(); - resolve(response.statusCode); - }); - }); - upload.write(Buffer.alloc(20)); - upload.end(Buffer.alloc(20)); - assert.equal(await result, 413); -}); diff --git a/packages/tools/src/remote-cache/server.ts b/packages/tools/src/remote-cache/server.ts index 429382406..8ca50870e 100644 --- a/packages/tools/src/remote-cache/server.ts +++ b/packages/tools/src/remote-cache/server.ts @@ -156,7 +156,7 @@ export function createCacheServer({ maxRequestBytes = 64 * 1024 * 1024, basePath cbor(response, { blob_id: blobId }); } - return createServer({ requestTimeout: 10_000 }, (request, response) => { + return createServer((request, response) => { void handle(request, response).catch((error: unknown) => { const known = error instanceof RequestError; if (!known) console.error(error); From 2e8d5b5275500c72d1c9c44cd3c96f1ae99442f9 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Thu, 17 Sep 2026 13:39:54 +0800 Subject: [PATCH 3/3] test(cache): skip backend snapshots on Windows Co-authored-by: GPT-6 --- .../remote_cache_backend/snapshots.toml | 220 +++++++----------- .../snapshots/blob_replacement.md | 30 +-- .../snapshots/daemon_lifecycle.md | 16 +- .../snapshots/entries_and_associations.md | 26 +-- .../snapshots/invalid_requests.md | 42 ++-- .../snapshots/opaque_binary_data.md | 16 +- .../snapshots/request_limit.md | 12 +- packages/tools/README.md | 2 +- 8 files changed, 150 insertions(+), 214 deletions(-) diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml index d3e701e80..20bdee4a9 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots.toml @@ -1,119 +1,107 @@ +# Windows is skipped because the PTY launcher cannot execute pnpm command shims. [[e2e]] name = "entries_and_associations" +cfg = "not(windows)" ignore = true steps = [ [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "start", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'VA'}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'U', \"value\": 'VA'}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", "metadata={\"key\": 'B', \"secondary_key\": 'S', \"value\": 'VB'}", ], { argv = [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], comment = "The exact entry survives reassignment of S to B." }, { argv = [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'C', \"secondary_key\": 'S'}", ], comment = "The previous fetch did not change S. Fallback returns only B's key." }, [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'T', \"value\": 'VA2'}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'missing'}", ], { argv = [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'C', \"secondary_key\": 'U'}", ], comment = "An older association to A still resolves after A is replaced." }, [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'C', \"secondary_key\": 'T'}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'C', \"secondary_key\": 'S'}", ], [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "stop", ], ] [[e2e]] name = "blob_replacement" +cfg = "not(windows)" ignore = true steps = [ [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "start", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "GET", "/blob/missing", ], @@ -124,8 +112,7 @@ steps = [ "first archive", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", @@ -134,22 +121,19 @@ steps = [ "blob=archive.txt", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "GET", "/blob/1", ], { argv = [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-data", @@ -158,44 +142,38 @@ steps = [ "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'second'}", ], comment = "Accept blob before metadata, with neither part supplying a filename." }, [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "GET", "/blob/2", ], { argv = [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "GET", "/blob/1", ], comment = "Previously returned IDs retain their original bytes." }, { argv = [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'no archive'}", ], comment = "Omitting blob clears the association." }, [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", @@ -204,38 +182,34 @@ steps = [ "blob=''", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], { argv = [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "GET", "/blob/3", ], comment = "An empty blob has an ID and downloads as an empty byte string." }, [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "stop", ], ] [[e2e]] name = "opaque_binary_data" +cfg = "not(windows)" ignore = true steps = [ [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "start", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", @@ -244,76 +218,67 @@ steps = [ "blob=b64'AP+A'", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": b64'AP+A', \"secondary_key\": ''}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": '', \"secondary_key\": ''}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "GET", "/blob/1", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", "metadata={\"key\": '', \"secondary_key\": '', \"value\": ''}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": '', \"secondary_key\": ''}", ], [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "stop", ], ] [[e2e]] name = "invalid_requests" +cfg = "not(windows)" ignore = true steps = [ [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "start", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "GET", "/missing", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--data", "'bad'", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--data", @@ -322,40 +287,35 @@ steps = [ "application/cbor", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": \"text\", \"secondary_key\": ''}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": ''}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "[]", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": '', \"key\": 'duplicate', \"secondary_key\": ''}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", @@ -364,32 +324,28 @@ steps = [ "blob='original archive'", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--cbor", "{}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-data", "blob='missing metadata'", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'T', \"value\": \"wrong type\"}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", @@ -398,8 +354,7 @@ steps = [ "metadata={}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", @@ -410,8 +365,7 @@ steps = [ "blob='two'", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", @@ -420,16 +374,14 @@ steps = [ "unexpected='part'", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-data", "metadata='wrong content type'", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--data", @@ -438,48 +390,43 @@ steps = [ "multipart/form-data; boundary=boundary", ], { argv = [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], comment = "Failed stores did not replace the value or blob." }, [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "GET", "/blob/1", ], { argv = [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'B', \"secondary_key\": 'T'}", ], comment = "Failed stores did not publish a secondary association." }, [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "stop", ], ] [[e2e]] name = "request_limit" +cfg = "not(windows)" ignore = true steps = [ [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "start", "--max-request-bytes", "1024", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", @@ -491,8 +438,7 @@ steps = [ "require('node:fs').writeFileSync('large.bin', Buffer.alloc(2048))", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", @@ -501,78 +447,68 @@ steps = [ "blob=large.bin", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'B', \"secondary_key\": 'T'}", ], [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "stop", ], ] [[e2e]] name = "daemon_lifecycle" +cfg = "not(windows)" ignore = true steps = [ [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "start", "--base-path", "/projects/test", ], [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "start", ], [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/store", "--form-cbor", "metadata={\"key\": 'A', \"secondary_key\": 'S', \"value\": 'first daemon'}", ], { argv = [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "stop", ], comment = "Connect before deleting cache.url, then wait for the connection to close before restarting." }, [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "start", ], { argv = [ - "node", - "../node_modules/vite-task-tools/src/cbor-http.ts", + "cbor-http", "POST", "/fetch", "--cbor", "{\"key\": 'A', \"secondary_key\": 'S'}", ], comment = "A new daemon starts with empty state." }, [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "stop", ], [ - "node", - "../node_modules/vite-task-tools/src/remote-cache/cli.ts", + "remote-cache-server", "stop", ], ] diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md index 232b05b33..23d025b5b 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/blob_replacement.md @@ -1,12 +1,12 @@ # blob_replacement -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` +## `remote-cache-server start` ``` Remote cache server started ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/missing` +## `cbor-http GET /blob/missing` ``` {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Blob not found"} @@ -17,25 +17,25 @@ Remote cache server started ``` ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''first'\''}' --form-file blob=archive.txt` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''first'\''}' --form-file blob=archive.txt` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'first', "blob_id": "1"}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/1` +## `cbor-http GET /blob/1` ``` {"status": 200, "content_type": "application/octet-stream", "body": 'first archive'} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-data 'blob='\''second archive'\''' --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''second'\''}'` +## `cbor-http POST /store --form-data 'blob='\''second archive'\''' --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''second'\''}'` Accept blob before metadata, with neither part supplying a filename. @@ -43,19 +43,19 @@ Accept blob before metadata, with neither part supplying a filename. {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "2"}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'second', "blob_id": "2"}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/2` +## `cbor-http GET /blob/2` ``` {"status": 200, "content_type": "application/octet-stream", "body": 'second archive'} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/1` +## `cbor-http GET /blob/1` Previously returned IDs retain their original bytes. @@ -63,7 +63,7 @@ Previously returned IDs retain their original bytes. {"status": 200, "content_type": "application/octet-stream", "body": 'first archive'} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''no archive'\''}'` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''no archive'\''}'` Omitting blob clears the association. @@ -71,25 +71,25 @@ Omitting blob clears the association. {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'no archive', "blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''empty archive'\''}' --form-data blob=''` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''empty archive'\''}' --form-data blob=''` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "3"}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'empty archive', "blob_id": "3"}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/3` +## `cbor-http GET /blob/3` An empty blob has an ID and downloads as an empty byte string. @@ -97,7 +97,7 @@ An empty blob has an ID and downloads as an empty byte string. {"status": 200, "content_type": "application/octet-stream", "body": ''} ``` -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` +## `remote-cache-server stop` ``` Remote cache server stopped diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md index 14b1ae0bf..46d141e70 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/daemon_lifecycle.md @@ -1,12 +1,12 @@ # daemon_lifecycle -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start --base-path /projects/test` +## `remote-cache-server start --base-path /projects/test` ``` Remote cache server started ``` -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` +## `remote-cache-server start` **Exit code:** 1 @@ -14,13 +14,13 @@ Remote cache server started Remote cache server already started (cache.url exists) ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''first daemon'\''}'` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''first daemon'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` +## `remote-cache-server stop` Connect before deleting cache.url, then wait for the connection to close before restarting. @@ -28,13 +28,13 @@ Connect before deleting cache.url, then wait for the connection to close before Remote cache server stopped ``` -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` +## `remote-cache-server start` ``` Remote cache server started ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` A new daemon starts with empty state. @@ -42,13 +42,13 @@ A new daemon starts with empty state. {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} ``` -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` +## `remote-cache-server stop` ``` Remote cache server stopped ``` -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` +## `remote-cache-server stop` ``` No remote cache endpoint found diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md index 7fa1cc11a..d52e2d149 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/entries_and_associations.md @@ -1,36 +1,36 @@ # entries_and_associations -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` +## `remote-cache-server start` ``` Remote cache server started ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''VA'\''}'` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''VA'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''U'\'', "value": '\''VA'\''}'` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''U'\'', "value": '\''VA'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''B'\'', "secondary_key": '\''S'\'', "value": '\''VB'\''}'` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''B'\'', "secondary_key": '\''S'\'', "value": '\''VB'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` The exact entry survives reassignment of S to B. @@ -38,7 +38,7 @@ The exact entry survives reassignment of S to B. {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'VA', "blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` The previous fetch did not change S. Fallback returns only B's key. @@ -46,19 +46,19 @@ The previous fetch did not change S. Fallback returns only B's key. {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'B'}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''VA2'\''}'` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''VA2'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''missing'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''missing'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'VA2', "blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''U'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''U'\''}'` An older association to A still resolves after A is replaced. @@ -66,19 +66,19 @@ An older association to A still resolves after A is replaced. {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'A'}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''T'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''T'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'A'}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''C'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": 'B'}} ``` -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` +## `remote-cache-server stop` ``` Remote cache server stopped diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md index 576222bca..de39985ce 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/invalid_requests.md @@ -1,108 +1,108 @@ # invalid_requests -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` +## `remote-cache-server start` ``` Remote cache server started ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /missing` +## `cbor-http GET /missing` ``` {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Route not found"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --data 'bad'` +## `cbor-http POST /fetch --data 'bad'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected application/cbor"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --data 'bad' --content-type application/cbor` +## `cbor-http POST /fetch --data 'bad' --content-type application/cbor` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": "text", "secondary_key": '\'''\''}'` +## `cbor-http POST /fetch --cbor '{"key": "text", "secondary_key": '\'''\''}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\'''\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\'''\''}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor []` +## `cbor-http POST /fetch --cbor []` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\'''\'', "key": '\''duplicate'\'', "secondary_key": '\'''\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\'''\'', "key": '\''duplicate'\'', "secondary_key": '\'''\''}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid CBOR"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''original'\''}' --form-data 'blob='\''original archive'\'''` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''original'\''}' --form-data 'blob='\''original archive'\'''` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --cbor {}` +## `cbor-http POST /store --cbor {}` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected multipart/form-data"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-data 'blob='\''missing metadata'\'''` +## `cbor-http POST /store --form-data 'blob='\''missing metadata'\'''` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Missing metadata"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": "wrong type"}'` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": "wrong type"}'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Expected byte strings: key, secondary_key, value"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-cbor metadata={}` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-cbor metadata={}` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-data blob='one' --form-data blob='two'` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-data blob='one' --form-data blob='two'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor metadata={} --form-data unexpected='part'` +## `cbor-http POST /store --form-cbor metadata={} --form-data unexpected='part'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-data 'metadata='\''wrong content type'\'''` +## `cbor-http POST /store --form-data 'metadata='\''wrong content type'\'''` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --data ''\''--boundary\r\nContent-Disposition: form-data; name="metadata"\r\nContent-Type: application/cbor\r\n\r\n'\''' --content-type 'multipart/form-data; boundary=boundary'` +## `cbor-http POST /store --data ''\''--boundary\r\nContent-Disposition: form-data; name="metadata"\r\nContent-Type: application/cbor\r\n\r\n'\''' --content-type 'multipart/form-data; boundary=boundary'` ``` {"status": 400, "content_type": "text/plain; charset=utf-8", "body": "Invalid multipart body"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` Failed stores did not replace the value or blob. @@ -110,13 +110,13 @@ Failed stores did not replace the value or blob. {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'original', "blob_id": "1"}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/1` +## `cbor-http GET /blob/1` ``` {"status": 200, "content_type": "application/octet-stream", "body": 'original archive'} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''B'\'', "secondary_key": '\''T'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''B'\'', "secondary_key": '\''T'\''}'` Failed stores did not publish a secondary association. @@ -124,7 +124,7 @@ Failed stores did not publish a secondary association. {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} ``` -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` +## `remote-cache-server stop` ``` Remote cache server stopped diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md index 82935f284..d13fcf0db 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/opaque_binary_data.md @@ -1,48 +1,48 @@ # opaque_binary_data -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start` +## `remote-cache-server start` ``` Remote cache server started ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": b64'\''AP+A'\'', "secondary_key": '\'''\'', "value": b64'\''AP+A'\''}' --form-data blob=b64'AP+A'` +## `cbor-http POST /store --form-cbor 'metadata={"key": b64'\''AP+A'\'', "secondary_key": '\'''\'', "value": b64'\''AP+A'\''}' --form-data blob=b64'AP+A'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": "1"}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": b64'\''AP+A'\'', "secondary_key": '\'''\''}'` +## `cbor-http POST /fetch --cbor '{"key": b64'\''AP+A'\'', "secondary_key": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": b64'AP+A', "blob_id": "1"}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "fallback", "key": b64'AP+A'}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts GET /blob/1` +## `cbor-http GET /blob/1` ``` {"status": 200, "content_type": "application/octet-stream", "body": b64'AP+A'} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\'''\'', "secondary_key": '\'''\'', "value": '\'''\''}'` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\'''\'', "secondary_key": '\'''\'', "value": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\'''\'', "secondary_key": '\'''\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": '', "blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` +## `remote-cache-server stop` ``` Remote cache server stopped diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md index 60d4c26ec..38aceb511 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/remote_cache_backend/snapshots/request_limit.md @@ -1,12 +1,12 @@ # request_limit -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts start --max-request-bytes 1024` +## `remote-cache-server start --max-request-bytes 1024` ``` Remote cache server started ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''original'\''}'` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''S'\'', "value": '\''original'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"blob_id": null}} @@ -17,25 +17,25 @@ Remote cache server started ``` ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-file blob=large.bin` +## `cbor-http POST /store --form-cbor 'metadata={"key": '\''A'\'', "secondary_key": '\''T'\'', "value": '\''replacement'\''}' --form-file blob=large.bin` ``` {"status": 413, "content_type": "text/plain; charset=utf-8", "body": "Request too large"} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''A'\'', "secondary_key": '\''S'\''}'` ``` {"status": 200, "content_type": "application/cbor", "body": {"kind": "exact", "value": 'original', "blob_id": null}} ``` -## `node ../node_modules/vite-task-tools/src/cbor-http.ts POST /fetch --cbor '{"key": '\''B'\'', "secondary_key": '\''T'\''}'` +## `cbor-http POST /fetch --cbor '{"key": '\''B'\'', "secondary_key": '\''T'\''}'` ``` {"status": 404, "content_type": "text/plain; charset=utf-8", "body": "Entry not found"} ``` -## `node ../node_modules/vite-task-tools/src/remote-cache/cli.ts stop` +## `remote-cache-server stop` ``` Remote cache server stopped diff --git a/packages/tools/README.md b/packages/tools/README.md index b15314da9..438964ca5 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -44,4 +44,4 @@ Every response prints one EDN map containing `status`, `content_type`, and `body Run `pnpm --filter vite-task-tools check` for type checking and `pnpm --filter vite-task-tools test` for the EDN-formatting unit test. Run `cargo test -p vt_bin --test e2e_snapshots -- remote_cache_backend --ignored` for the backend snapshots. -E2E steps invoke the TypeScript files with `node` through the existing `../node_modules/vite-task-tools` link. This avoids launching shell command shims as executables on Windows and preserves EDN arguments without shell quoting. +The backend snapshots are skipped on Windows because the PTY launcher cannot execute pnpm command shims.