diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10729ebe..c45da5fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,6 +272,86 @@ jobs: exit 1 fi + # Prototype TypeScript e2e suite (test/e2e). Deliberately NOT in the release + # job's needs: and not required by the branch ruleset while it is being + # evaluated against the Go integration suite. + test-e2e: + name: E2E Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + # The action reads its version from a package.json's "packageManager" field, + # and this repo has none at the root — the suite's lives in test/e2e. + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + package_json_file: test/e2e/package.json + + # Node 26: the suite is type-erasable TypeScript, matching what Node itself + # can run by stripping types. + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version-file: test/e2e/.node-version + cache: pnpm + cache-dependency-path: test/e2e/pnpm-lock.yaml + + - name: Install e2e dependencies + shell: bash + working-directory: test/e2e + run: pnpm install --frozen-lockfile + + - name: Typecheck e2e suite + shell: bash + working-directory: test/e2e + run: pnpm exec tsc --noEmit + + # bash so the shared script works on the Windows runner too; container + # tests skip themselves there (no Linux containers available). + - name: Run e2e tests + shell: bash + run: make test-e2e + env: + CREATE_JUNIT_REPORT: "true" + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + # Linux has every prerequisite, so a skipped one there is a defect, not + # an environment fact. macOS runners have no container runtime and + # Windows cannot run Linux containers or shim the browser opener, so + # those legs stay lenient. + LSTK_E2E_REQUIRE_ALL: ${{ matrix.os == 'ubuntu-latest' && '1' || '' }} + + - name: Upload test results + uses: actions/upload-artifact@v7 + if: always() + with: + name: e2e-test-results-${{ matrix.os }} + path: test-e2e-results.xml + + # fail-on-error is off so a setup failure — which produces no report at all — + # surfaces as the one real failure instead of also failing here, where the + # message would be "no files found" rather than the actual cause. + - name: Test report + uses: dorny/test-reporter@v3 + if: always() + with: + name: E2E Test Results (${{ matrix.os }}) + path: test-e2e-results.xml + reporter: java-junit + fail-on-error: false + test-launcher: name: Launcher Tests runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 3b0c8cee..7014c95b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ test/integration/test-samples/**/.terraform/ test/integration/test-samples/**/.terraform.lock.hcl test/integration/test-samples/**/*.tfstate test/integration/test-samples/**/*.tfstate.* +test/e2e/node_modules/ +test-e2e-results.xml diff --git a/CLAUDE.md b/CLAUDE.md index b78dbbf8..416dd412 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ This installs a [gitleaks](https://github.com/gitleaks/gitleaks) hook that scans make build # Compiles to bin/lstk make test # Run unit tests (cmd/ and internal/) via gotestsum make test-integration # Run integration tests (rebuilds bin/lstk via `build`, requires Docker) +make test-e2e # Run the TypeScript e2e suite (rebuilds bin/lstk; requires Node >= 26, pnpm, Docker) make lint # Run golangci-lint (version pinned via .tool-versions) make govulncheck # Run govulncheck (reachability-based vuln scan) make mock-generate # Regenerate mocks (mockgen via go:generate) @@ -34,9 +35,15 @@ Run a single integration test: make test-integration RUN=TestStartCommandSucceedsWithValidToken ``` +Run a subset of the e2e suite (`RUN` is a substring match on the test name): +```bash +make test-e2e RUN="injects the endpoint" +``` + Notes: - Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for valid token tests. - `test/integration` is a **separate Go module** (own `go.mod`); `make lint` runs golangci-lint twice — repo root and `test/integration` — and fails if the installed golangci-lint version doesn't match `.tool-versions`. `golangci-lint run --fix` auto-fixes many findings. +- `test/e2e` is a **third** self-contained tree after the repo root and `test/integration`, and the only one that isn't Go: a Node/pnpm workspace running TypeScript under vitest. It drives the built `bin/lstk` and imports no lstk source, so nothing there is covered by `make lint`/`make govulncheck`. - `make govulncheck` also runs twice for the same reason (root + `test/integration`). It complements the dependency-version scan in `trivy.yml` with call-graph reachability analysis — it only flags known vulnerabilities in code actually called from the repo. It has no severity filter (most Go vulnerability reports carry no CVSS data), so it gates on reachability alone: any reachable known vulnerability fails the job. CI (`ci.yml`'s `govulncheck` job) runs it on every push/PR and uploads a SARIF report to the Security tab, same pattern as Trivy, but it is **not yet** in `release`'s `needs:` — it's a new check on a staged rollout and should be promoted to a hard release gate once it's proven false-positive-free. - Mocks are generated with mockgen (go.uber.org/mock) via per-file `//go:generate mockgen ...` directives (e.g. `internal/snapshot/remote.go`); adding a mock means adding a directive, then `make mock-generate`. - Set `CREATE_JUNIT_REPORT=1` to get a JUnit XML report from `make test` / `make test-integration`. @@ -220,8 +227,17 @@ When drafting Slack messages, PR descriptions, review replies, release notes, or # Testing -- Prefer integration tests to cover most cases. Use unit tests when integration tests are not practical. -- **When fixing a bug, always add an integration test** that fails before the fix and passes after. This prevents regressions and documents the exact scenario that was broken. +Three suites, and the first question for any new test is which one owns the area: + +- **`test/e2e/` (TypeScript/vitest)** owns CLI-level behaviour for the areas ported to it: the proxy commands `aws` and `terraform`, `--json` envelopes, exit codes, `--non-interactive`, lifecycle (`stop`/`restart`/`status`/`reset`), `logs`, `volume`, `--endpoint-url`/`LSTK_ENDPOINT_URL`, config resolution, completion, `docs`, the login journey, and the TUI paths. It drives the built binary and asserts whole output (`toPrintExactly`), never a substring and never a recorded snapshot. Conventions: [test/e2e/README.md](test/e2e/README.md); the coverage map against the Go suite: [test/e2e/PORTING.md](test/e2e/PORTING.md). +- **`test/integration/` (Go)** still owns snapshots, IaC end-to-end (`terraform_e2e`, `cdk`, `sam`), most of `start`, extensions and signal forwarding, update/install, license, telemetry, `az` / `setup azure` / `awsconfig`, and anything needing Docker-SDK-level setup or the real OS keyring. +- **Go unit tests** under `cmd/` and `internal/` are unaffected by the split and remain the right tool for logic with no CLI surface. + +- Prefer an end-to-end/integration test to a unit test where one is practical, in whichever of the two suites owns the area. +- **When fixing a bug, always add a test** that fails before the fix and passes after, in the suite that owns the area. This prevents regressions and documents the exact scenario that was broken. + +The rest of this section is specific to the Go integration suite. + - Integration tests that run the CLI binary with Bubble Tea must use a PTY (`github.com/creack/pty`) since Bubble Tea requires a terminal. Use `pty.Start(cmd)` instead of `cmd.CombinedOutput()`, read output with `io.Copy()`, and send keystrokes by writing to the PTY (e.g., `ptmx.Write([]byte("\r"))` for Enter). - Mark every integration test with `t.Parallel()` unless it shares external state with other tests. Today the main blocker is the Docker daemon: tests that start LocalStack containers cannot run concurrently because lstk's container discovery matches by `(image, internal port)`, so two parallel runs would cross-contaminate. Tests that only touch the filesystem, mock servers, or the CLI binary itself should be parallel. - Never let an integration test inherit the developer's real `$HOME`. Pass an isolated env via `testEnvWithHome(t.TempDir(), "")` (or build on top of it with `env.With(...)`) instead of `nil` or `os.Environ()`. Inheriting HOME pollutes the user's `~/.config/lstk/`, `~/.aws/`, and `~/.cache/lstk/`, and makes parallel runs interfere through shared `lstk.log`, license cache, and file-keyring fallback. diff --git a/Makefile b/Makefile index 7c2262d8..11cdea9f 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ endif BUILD_DIR=bin export CGO_ENABLED=0 -.PHONY: build clean test test-integration lint govulncheck mock-generate otel +.PHONY: build clean test test-integration test-e2e lint govulncheck mock-generate otel # Always invoke `go build` and let Go's build cache handle incrementality; a # file target on bin/lstk would be skipped when the binary exists, even with @@ -23,6 +23,9 @@ test: test-integration: build @RUN="$(RUN)" ./scripts/test-integration.sh +test-e2e: build + @RUN="$(RUN)" ./scripts/test-e2e.sh + otel: docker compose -f docker-compose.tracing.yaml up -d diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh new file mode 100755 index 00000000..bd555908 --- /dev/null +++ b/scripts/test-e2e.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Run the TypeScript end-to-end suite against the built binary (bin/lstk). +# +# Requires Node >= 26 (the suite is type-erasable TypeScript, run through vitest) +# and pnpm. See test/e2e/README.md. +# +# Honors: +# CREATE_JUNIT_REPORT Emit JUnit XML to test-e2e-results.xml when set. +# SHARD_INDEX 1-based shard index (used with SHARD_TOTAL). +# SHARD_TOTAL Total number of shards; passed to vitest --shard. +# RUN Substring filter passed to vitest -t. + +set -euo pipefail + +cd "$(dirname "$0")/../test/e2e" + +if ! command -v pnpm >/dev/null 2>&1; then + echo "pnpm is required to run the e2e suite: https://pnpm.io/installation" >&2 + exit 1 +fi + +NODE_MAJOR=$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0) +if [ "$NODE_MAJOR" -lt 26 ]; then + echo "Node >= 26 is required to run the e2e suite (found $(node -v 2>/dev/null || echo none))." >&2 + echo "The suite is type-erasable TypeScript targeting Node's native type stripping." >&2 + exit 1 +fi + +if [ -n "${CI:-}" ]; then + pnpm install --frozen-lockfile +else + # A full install on every local run is slow; install only when deps are missing. + [ -d node_modules ] || pnpm install +fi + +ARGS=() +if [ -n "${SHARD_TOTAL:-}" ]; then + ARGS+=(--shard "${SHARD_INDEX:-1}/${SHARD_TOTAL}") +fi +if [ -n "${RUN:-}" ]; then + ARGS+=(-t "$RUN") +fi + +exec pnpm exec vitest run ${ARGS[@]+"${ARGS[@]}"} diff --git a/test/e2e/.node-version b/test/e2e/.node-version new file mode 100644 index 00000000..6f4247a6 --- /dev/null +++ b/test/e2e/.node-version @@ -0,0 +1 @@ +26 diff --git a/test/e2e/PORTING.md b/test/e2e/PORTING.md new file mode 100644 index 00000000..132ed47c --- /dev/null +++ b/test/e2e/PORTING.md @@ -0,0 +1,130 @@ +# Porting status: Go integration suite → TypeScript e2e suite + +**Intent:** this suite owns the CLI boundary for the areas listed under "Covered". Go +**unit** tests (`cmd/`, `internal/`) stay as they are — they are the right tool for logic +with no CLI surface. `test/integration` keeps everything under "Still owned by Go" and +does not go away wholesale; it shrinks as areas move across. + +**Status: 225 tests across 26 files** (210 pass, 15 skip on this machine — an auth token, +a native Linux daemon and SSL_CERT_FILE certificate trust are the prerequisites not met +here). Full-suite wall clock ≈ 75s. + +The Go integration suite has been trimmed accordingly: against `main` it goes from **427 +→ 269** test functions across **55 → 40** files, 14,758 → 10,623 lines. + +## What "ported" means here + +Not a line-by-line translation. Many Go integration tests assert mechanism rather +than behaviour, and those were deliberately **not** carried across: + +- **Telemetry assertions** (`assertCommandTelemetry`, `mockAnalyticsServer`) — dropped + throughout. What lstk sends to analytics is not something a CLI user observes. Per-command + telemetry stays in Go, consolidated into one table in + `test/integration/command_telemetry_test.go` rather than scattered across the tests whose + behavioural halves moved here. +- **Container introspection** (`docker inspect` of `Config.Env`, `HostConfig.Binds`) + — replaced by the CLI-observable equivalent where one exists, e.g. `restart + --persist` is asserted through the `• Persistence: Enabled` line rather than the + container's environment. +- **Token storage** — the keyring assertions across `login_test.go` and `logout_test.go` + became behavioural ones in `login-journey.pty.test.ts` (see the README). "A failed + login stores nothing" is asserted as *`start` still demands credentials and a retry + reopens the browser* rather than by reading the store. Nothing in `test/e2e` touches + credential storage; `keyring: "file" | "system"` only selects which backend the binary + uses. That is not just tidiness — the two Go tests that did reach in + (`TestLogoutCommandNotesWhenEmulatorStillRunning` and its multi-emulator sibling) + wrote through the *test process's* `$HOME` while running lstk under a temp one, and + passed on Linux CI while failing locally. + +Two Go tests turned out to be **vacuous** and were re-targeted rather than copied: +`TestConfigWithUnknownFieldsIsAccepted` and `TestConfigWithMissingOptionalTagSucceeds` +assert config acceptance through `lstk config path`, which never parses the file (see +`cmd/config.go`) — they pass against a nonexistent path. The ports use `lstk logout`, which +really calls `config.Get()`. + +## Covered + +| Area | Vitest files | Tests | +| --- | --- | --- | +| Proxy commands (`aws`, `terraform`) | `aws-proxy`, `terraform-proxy` | 41 | +| `--json` envelope, exit codes, `--non-interactive` | `json-envelope`(+`.pty`), `json-flag`, `exit-codes`, `non-interactive.pty` | 42 | +| Lifecycle (`stop`, `restart`, `status`, `reset`) | `stop-restart`, `status`, `reset.pty` | 28 | +| `logs`, `volume` | `logs.pty`, `volume.pty` | 21 | +| Config, completion, docs | `config`, `completion`, `docs` | 27 | +| Start paths, emulator selection, login journey, TUI | `start`, `start-local-image`, `emulator-select.pty`, `emulator-type`, `login-journey.pty`, `tui-runtime-error.pty` | 18 | +| `--endpoint-url` / `LSTK_ENDPOINT_URL` | `endpoint-url`(+`.pty`), `endpoint-url-https` | 36 | +| Harness self-tests (not product behaviour) | `harness/strip-ansi`, `harness/print-exactly` | 12 | + +### What that removed from the Go suite + +Deleted outright: `json_envelope`, `exit_code`, `non_interactive`, `completion`, +`aws_completion`, `docs`, `terraform_cmd`, `logs`, `reset`, `volume`, `stop`, `restart`, +`logout`, `status`, `endpoint_url`, `endpoint_url_https`. + +`aws_completion_test.go` arrived on `main` (#424) after this port, and its one bash-driver +case depends on a helper inside the `completion_test.go` this branch deletes. Both files +move: `lstk aws ` delegating to `aws_completer` is CLI-observable, and +`completion.test.ts` already drives the generated script under a bare bash. + +`config_test.go` likewise gained `TestConfigWithInvalidContainerNameFails` on `main` +(custom `container_name`). That one is pure CLI output rejected at config load — no +daemon, no token — so it was ported to `tests/config.test.ts` next to its `port is +required` sibling rather than kept. Its companion `TestStartCommandUsesCustomContainerName` +stays in Go: it needs a real start plus a container inspect. + +Trimmed, with the reason each remainder stayed: + +| Go file | Kept | Why it could not move | +| --- | --- | --- | +| `json_flag` | 2 of 7 | Both are table-driven proxy tests covering `az`, which TypeScript cannot reach without a completed `lstk setup azure` | +| `aws_cmd` | 3 of 18 | Spinner timing under a PTY | +| `config` | 1 of 12 | `TestConfigFlagEnvVarsPassedToContainer` inspects the container's environment | +| `emulator_type` | 7 of 10 | | +| `emulator_select` | 7 of 9 | | +| `start` | 34 of 36 | | +| `login` | 1 of 4 | `TestDeviceFlowSuccess` is the only remaining telemetry assertion for `login` | + +## Still owned by Go + +| Area | Go files | Cases | Would need | +| --- | --- | --- | --- | +| Snapshots | `snapshot_*_test.go`, `start_snapshot_test.go` | 78 | Mock cloud/S3 remotes; AWS SDK assertions against a live emulator | +| IaC end-to-end | `terraform_e2e`, `terraform_s3backend_e2e`, `cdk_*`, `sam_*` | 46 | Real terraform/cdk/sam installs (the `_cmd` half of terraform is done) | +| `start` remainder | `start_test.go`, `docker_unhealthy`, `docker_windows` | 41 | Never-healthy image via `docker commit`; bind/port introspection | +| Trimmed leftovers | `emulator_type`, `emulator_select`, `login`, `aws_cmd`, `json_flag`, `config` | 21 | See the table above — each has its own blocker | +| `az` proxy, `setup azure`, `awsconfig` | `az_*`, `setup_azure`, `awsconfig` | 22 | Isolated `~/.azure` assertions; `setup azure` completion marker | +| Extensions, signal forwarding | `extension`, `signal_forwarding` | 22 | Reference extension build; process-group signalling | +| Update & install | `update`, `multiple_installs`, `version_resolution` | 16 | Mock GitHub releases API; fake Homebrew/npm layouts | +| Telemetry, license, logging | `telemetry`, `license`, `logging`, `command_telemetry` | 15 | Mechanism by design — a mock analytics server and a mock license API, neither of which is user-observable | + +## Fixtures available for the remaining work + +`support/`: `lstk()` and `lstkPty()` runners, `tempHome()`, `docker`, +`useExclusiveEmulator()`, `emulator-stub.ts` (stand-in container + log writing), +`fake-binary.ts` (fake `aws`/`terraform`/… recording argv, env, cwd), +`extension-fixture.ts`, `platform.ts` (`mockPlatform()` login flow + `fakeBrowser()`), +`emulator-api.ts` (the emulator's own HTTP API, http or https, for `--endpoint-url`), +`license.ts`, `os-config-dir.ts`, `envelope.ts`, `requirements.ts`. + +## Consequences worth accepting deliberately + +- **The CLI boundary for the ported areas is now covered only by Node.** The e2e job has + to be a required check. +- **Windows loses the login journey.** `pkg/browser` invokes `rundll32` there rather + than a shimmable script. No loss against today: `login_test.go` already skips Windows. +- **Windows terminal coverage may improve** — node-pty drives ConPTY, where + `creack/pty` has no Windows support at all. Unproven until the Windows CI leg reports. +- **Real-keyring coverage narrows to one journey run** (`keyring: "system"`, CI or + opt-in). The adapter logic below it stays covered by the mocked unit tests in + `internal/auth/token_storage_test.go`. +- **Two prerequisites are gated rather than assumed**, both `requirement()`-checked so a + missing one skips instead of failing — and hard-fails on the CI leg that has + everything (`LSTK_E2E_REQUIRE_ALL=1`). The https tests need certificate trust an + exec'd lstk actually reads, which Go's x509 verifier only takes from SSL_CERT_FILE on + Linux; the "status reports the bound port" test needs a daemon that can publish on the + 127.0.0.2 loopback alias, which Docker Desktop's VM networking refuses. +- **Container behaviour is verified on exactly one CI leg.** Ubuntu has Docker and the + auth token, and `LSTK_E2E_REQUIRE_ALL=1` turns a missing prerequisite into a hard + failure there; macOS and Windows runners cannot run Linux containers, so 63 and 72 + tests respectively skip. Roughly 51 tests could stop running on those legs without + anything going red. A per-platform skip budget asserted in CI would close that gap. diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 00000000..783ca68d --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,285 @@ +# lstk e2e tests (TypeScript / Vitest) + +End-to-end tests that drive the **built binary** (`bin/lstk`) as a user would: no lstk +source code is imported, nothing is stubbed inside the process. + +This suite owns the CLI boundary for the areas it covers — the proxy commands, `--json`, +exit codes, lifecycle, `logs`, `volume`, config, completion, `docs`, login and the TUI — +and the Go tests for those areas have been removed. The Go suite in `test/integration/` +keeps everything else. Which suite owns what, and why each remaining Go test could not +move, is tracked in [PORTING.md](PORTING.md). + +## Toolchain + +**Node >= 26** and **pnpm**. Node version is pinned in [.node-version](.node-version) +(`fnm use`, `nvm use`, and `actions/setup-node` all read it). + +Typechecking is **TypeScript 7** (the native compiler). The suite is written in +**type-erasable TypeScript only** — `erasableSyntaxOnly` is on, so no enums, namespaces, +parameter properties or import aliases, and imports name the real file (`./lstk.ts`, not +`./lstk.js`). Nothing here needs a transform beyond deleting the types, which is exactly +what Node itself does natively. + +**Every `@types/*` dependency must be listed in `tsconfig.json`'s `types` array.** +Otherwise global types arrive by accident: `@types/node`'s globals currently reach this +project transitively through execa and vitest, so removing `"node"` from `types` +typechecks fine today and breaks the day a dependency stops referencing it. + +pnpm blocks dependency install scripts by default; the two this suite needs are +allowed in [pnpm-workspace.yaml](pnpm-workspace.yaml) (pnpm 11 reads settings from +there, not from `package.json`). + +## Running + +```bash +make test-e2e +``` + +That builds `bin/lstk` first, installs deps if missing, then runs Vitest. To iterate +on one file: + +```bash +cd test/e2e && pnpm exec vitest tests/emulator-type.test.ts +``` + +Typecheck (also a CI step, since `erasableSyntaxOnly` violations only surface here): + +```bash +cd test/e2e && pnpm exec tsc --noEmit +``` + +Filter by test name across the suite with `make test-e2e RUN="switches an existing config"`. + +### Prerequisites + +The PTY binding is **required** — see "Terminal tests" below. Everything else skips +when absent, so a contributor missing a piece can still run the rest: + +| Prerequisite | Skipped when absent | +| --- | --- | +| A container runtime (`docker info` succeeds) | container tests | +| `LOCALSTACK_AUTH_TOKEN` | tests needing a real license | +| A shimmable browser opener (not Windows) | browser login-flow tests | + +That leniency is also how coverage erodes unnoticed, so **`LSTK_E2E_REQUIRE_ALL=1` +turns a missing prerequisite into a collection-time failure** naming the fix. CI sets +it on the Linux leg, which has everything; macOS runners have no container runtime and +Windows can run neither Linux containers nor a shimmed browser, so those stay lenient. + +Declare a new prerequisite through `requirement()` ([requirements.ts](support/requirements.ts)) +rather than an ad-hoc boolean, or strict mode cannot see it. + +## How a test reads + +```ts +test("switches an existing config in place, preserving comments", async () => { + const home = await tempHome(noDaemon); + await home.writeConfig(`[[containers]]\ntype = "aws" # keep me\nport = "4566"\n`); + + const run = await lstk(["start", "--type", "azure", "--non-interactive"], { home }); + + expect(run).toPrint("Switched configured emulator to Azure"); + expect(await home.readConfig()).toContain(`type = "azure"`); + expect(await home.readConfig(), "the rewrite is surgical").toContain("# keep me"); +}); +``` + +Three blocks, always in this order: arrange the isolated home, run the binary once, +assert. Everything else lives in `support/`. + +## The DSL (`support/`) + +| Module | What it gives a test | +| --- | --- | +| `lstk.ts` | `lstk(args, { home, env, cwd, stdin })` → `{ stdout, stderr, exitCode, command }`. Never throws on non-zero exit. | +| `home.ts` | `tempHome()` → an isolated HOME with its own config dir, cache dir and file keyring; `home.configPath()` asks the binary itself; `writeConfig` / `readConfig` / `configExists`. Cleans up after the test. | +| `pty.ts` | `lstkPty(args, { home })` → `waitFor` / `expectNever` / `press("enter")` / `exitCode()`, with ANSI stripped so assertions match what a human sees. | +| `docker.ts` | `dockerIsAvailable()`, `docker.pull/tag/inspectContainer/containerIsRunning`, and `useExclusiveEmulator()` for describe blocks that start a container. | +| `license.ts` | `mockLicenseServer("grants" \| "rejects" \| { body })` — a stand-in license API, closed automatically. | +| `platform.ts` | `mockPlatform()` — the full browser login flow, so a test can reach a logged-in state via `lstk login`; `fakeBrowser()` shims `open`/`xdg-open` on PATH and records the URL. | +| `envelope.ts` | `parseEnvelope(stdout)` for the `--json` contract. | +| `matchers.ts` | `toSucceed()`, `toFail()`, `toExitWith(n)`, `toPrint(text \| regex)`; failures print the invocation plus both streams. | + +Two deliberate choices worth knowing: + +- **Docker is driven through the `docker` CLI**, not an API client — it resolves + `DOCKER_HOST`, contexts and non-Docker runtimes exactly as a user's shell does, and + keeps the suite free of native dependencies. +- **Every isolated home gets `DOCKER_HOST` set** to the harness's active context + endpoint. Most runtimes keep their socket under the real home + (`~/.docker/run/docker.sock`, `~/.colima/default/docker.sock`), which the binary can + no longer find once `HOME` is a temp dir. It is a no-op on CI, where + `/var/run/docker.sock` is already correct. + +## Terminal tests + +Roughly half the suite drives lstk on a PTY, so the binding +(`@homebridge/node-pty-prebuilt-multiarch`) is a **required** dependency, imported +statically in [pty.ts](support/pty.ts) and checked once in +[global-setup.ts](support/global-setup.ts). It was an `optionalDependency` at first; +that was a mistake — npm blocked its build script, the binding never loaded, every +terminal test skipped, and the run stayed green. + +It is a native module, but it does not build from source here: `node-pty` carries +Node-API prebuilds for macOS, Windows and Linux (x64 and arm64) inside its npm tarball, +so nothing is fetched or compiled at install and a Node major bump does not strand it. +Its install script still has to be allowed in +[pnpm-workspace.yaml](pnpm-workspace.yaml), or no binary is staged at all. When the +binding cannot load, the run fails with the likely causes spelled out instead of +skipping. + +### The version is pinned exactly, on purpose + +`node-pty@1.2.0-beta.14`, not `^1.1.0`. Two reasons, both measured: + +- **1.1.0 (current stable) is broken on macOS.** Its tarball ships + `prebuilds/darwin-*/spawn-helper` with mode `0644`, and neither its `install` nor its + `postinstall` script fixes that, so every spawn dies with `posix_spawnp failed`. Not a + pnpm artifact — plain `npm install` reproduces it. `chmod +x` on the helper is enough + to fix it, which is what the beta does at pack time (`0755`). +- **1.1.0 ships no Linux prebuilds**, so Linux always runs node-gyp — fine on a CI + runner with python3 and a compiler, fatal on `node:26-slim` or `node:26-alpine`. The + beta adds `linux-x64` and `linux-arm64`. + +Loosening the pin therefore needs a check, not a hope: install it and actually spawn a +PTY on macOS and on a toolchain-free Linux image. Alpine/musl is the one gap — the beta's +Linux prebuilds are glibc-only, so a musl image would have to compile (verified: +`node:26-alpine` fails to load the prebuilt `pty.node`). Nothing in CI runs on musl. + +### Windows + +node-pty drives ConPTY, so terminal tests are not gated to Unix — unlike the Go suite, +where `creack/pty` has no Windows support and every TUI test skips. Whether Bubble Tea +renders identically over ConPTY is unproven; the Windows CI leg is what answers it, and +[tui-runtime-error.pty.test.ts](tests/tui-runtime-error.pty.test.ts) is the probe (no +Docker, no browser, so it runs everywhere). `stripAnsi` carries its own tests because +ConPTY emits more escape sequences than a Unix PTY and a missed one would silently make +`waitFor` blind. + +What still cannot run on Windows is the **browser login flow**: `pkg/browser` invokes +`rundll32 url.dll,FileProtocolHandler` there instead of a shimmable `open`/`xdg-open` +script (`browserCanBeFaked`). Since login is the only way to reach a logged-in state +without touching the store, real-keyring coverage on Windows stays out of reach until +either the browser opener or the keyring identity is overridable. + +## Assert behaviour, not mechanism + +Tests here describe what a user can observe from the CLI. Anything the CLI does not +expose — where a credential is stored, which internal type handled a call — is out of +scope, because encoding it makes the test a second copy of the implementation. + +Three rules follow from that: + +1. **Assert through the CLI where the CLI can answer.** "The emulator started" is + `lstk status` reporting `is running` with an endpoint, not a container of a + particular name existing in Docker. +2. **A user-facing artifact is fair game; internal storage is not.** The config file + counts — it is documented, users edit it, `lstk config path` prints it, and + `--type` is *defined* as rewriting it. The keyring does not. +3. **When the CLI's own message is the only observable, say so in the test.** One test + does this ([start-local-image](tests/start-local-image.test.ts)) and the comment + there explains why; treat it as the exception, not a licence. + +Harness self-tests (tests of this suite's own helpers, not of lstk) live under +[tests/harness/](tests/harness) so they are never mistaken for product behaviour. + +## Exact output: `toPrintExactly`, never a snapshot + +lstk's output is a contract we control, so assert it whole and assert it deliberately: + +```ts +expect(run).toExitWith(1); +expect(run.stdout).toPrintExactly(` + Error: LocalStack AWS Emulator is not running + ==> Start LocalStack: lstk + ==> See help: lstk -h +`); +``` + +`toPrintExactly` (in [support/matchers.ts](support/matchers.ts)) dedents the expected +block — leading/trailing blank lines dropped, common indentation stripped — so it can sit +at the indentation of the surrounding code while still matching byte for byte. Nesting +*within* the block survives, which is what makes those `==>` lines assertable. + +**Inline snapshots are not used here**, deliberately: + +- A snapshot is *recorded*; a literal is *authored*. Only one of them reads as a promise + about what users see, and only one cannot be rewritten by a stray `vitest -u`. +- `toMatchInlineSnapshot` throws inside `test.each` ("InlineSnapshot cannot be used + inside of test.each"). That cost this suite a 4× file expansion before the matcher + existed — 14 cases as 14 near-identical tests instead of two tables. + +The rest of the rules stand whatever the mechanism: + +- **Exact assertions are for output only.** Structured data — argv, environment, parsed + JSON envelopes — gets `toEqual` / `toMatchObject`. +- **Only assert output that is identical on every machine and every run.** Reject a temp + path, port, duration, container ID, version, or — the subtle one — text that adapts to + the host, such as the Docker-unreachable message, whose suggested start commands depend + on which runtimes are installed. Fall back to `toPrint` and say why in a comment, as + [aws-proxy.test.ts](tests/aws-proxy.test.ts) does. +- **Mask only what is incidental.** `normalizeCliOutput()` in + [support/cli-output.ts](support/cli-output.ts) masks a test's temp home. If the value + itself is the point — a resolved config path, a specific port — assert it instead. +- **Keep the promise legible.** Exact text says what the output is, not why it matters. + Where the intent is load-bearing — an error must offer a way forward — say so in a + comment. + +Prefer an exact assertion to an absence check: `not.toPrint("lstk setup aws")` became +`toPrintExactly("")`, which also catches any *other* unwanted output. + +Credentials are the worked example. Nothing reads or writes token storage; the +journey in [login-journey.pty.test.ts](tests/login-journey.pty.test.ts) is: + +1. `start` before logging in → fails, "authentication required" +2. `lstk login` → "Login successful" +3. `lstk login` again → "You're already logged in", browser flow not restarted +4. `start` with **no** token in the environment → gets past auth on its own +5. `logout` → "Logged out successfully", and `start` fails again as in (1) + +That is strictly stronger than reading the keyring: it shows the credential is +*usable*, not merely present, and it holds for whichever backend the binary picked. + +`tempHome()` defaults to `LSTK_KEYRING=file` so a test never touches machine-wide +state. `tempHome({ keyring: "system" })` runs the same journey against the real OS +keyring — the one run that would notice a broken platform adapter — and is skipped +unless `LSTK_E2E_REAL_KEYRING=1` or `CI=true`, since service and account are hardcoded +in `internal/auth/token_storage.go`: one slot per machine, which the journey +overwrites and then deletes. + +## Parallelism + +Test files run in parallel workers. Tests that start an emulator call +`useExclusiveEmulator()`, which takes a machine-wide lock and clears leftover +containers before and after — lstk discovers a running emulator by (image, internal +port), so two of them at once would see each other. Same constraint the Go suite +handles by not marking those tests parallel. + +## CI + +The `test-e2e` job in `.github/workflows/ci.yml` runs on ubuntu / macOS / windows, +writes JUnit XML (`CREATE_JUNIT_REPORT=1`), uploads it, and renders it through +`dorny/test-reporter` — same reporting as the Go suite. It is **not** in the release +job's `needs:` yet; it has to be promoted to a required check, since it is now the only +suite covering the CLI boundary for the areas it owns. + +Vitest shards natively if the suite grows enough to need it: set `SHARD_INDEX` / +`SHARD_TOTAL` (`scripts/test-e2e.sh` forwards them to `--shard`). + +## Known costs + +- **Second toolchain.** Node >= 26, pnpm and a lockfile in a Go repo; contributors + need both, and CI grows a Node setup step per leg. +- **The PTY binding is a required native module** pinned to an exact prerelease. Prebuilt + for every platform CI runs on, with no compile and no install-time download — but + bumping it needs a real check, not a version bump. See "Terminal tests". +- **Install-script approvals are a maintenance step.** Adding a dependency that needs + one means updating `pnpm-workspace.yaml`, or the install fails closed. +- **No keyring library.** Reading the store directly from Node is possible but needs + per-platform glue that mirrors `go-keyring` exactly (macOS: `security` plus a + `go-keyring-base64:` value prefix; Windows: no read-capable CLI, so PowerShell + P/Invoke on `CredReadW` with TargetName `:`; Linux: `secret-tool` + with `{service, username}` attributes and a live Secret Service). Off-the-shelf + bindings do not interoperate — `@napi-rs/keyring` wraps keyring-rs, whose Windows + target naming differs, and keytar is archived. The CLI-driven approach above avoids + all of it. diff --git a/test/e2e/package.json b/test/e2e/package.json new file mode 100644 index 00000000..455776e5 --- /dev/null +++ b/test/e2e/package.json @@ -0,0 +1,24 @@ +{ + "name": "@localstack/lstk-e2e", + "private": true, + "type": "module", + "description": "End-to-end tests that drive the built lstk binary. No lstk source code is imported.", + "packageManager": "pnpm@11.13.0", + "engines": { + "node": ">=26" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@aws-sdk/client-s3": "^3.1101.0", + "@aws-sdk/client-sqs": "^3.1101.0", + "@types/node": "^26.0.0", + "execa": "^9.5.2", + "node-pty": "1.2.0-beta.14", + "typescript": "^7.0.2", + "vitest": "^2.1.8" + } +} diff --git a/test/e2e/pnpm-lock.yaml b/test/e2e/pnpm-lock.yaml new file mode 100644 index 00000000..f083b286 --- /dev/null +++ b/test/e2e/pnpm-lock.yaml @@ -0,0 +1,1639 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@aws-sdk/client-s3': + specifier: ^3.1101.0 + version: 3.1101.0 + '@aws-sdk/client-sqs': + specifier: ^3.1101.0 + version: 3.1101.0 + '@types/node': + specifier: ^26.0.0 + version: 26.1.2 + execa: + specifier: ^9.5.2 + version: 9.6.1 + node-pty: + specifier: 1.2.0-beta.14 + version: 1.2.0-beta.14 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@26.1.2) + +packages: + + '@aws-sdk/checksums@3.1000.24': + resolution: {integrity: sha512-7TWLjypP8kk3savsDBRuhZJx7mBuFFA2136BQhwwLllsAnO4Tmq/p+SXZaNxbuulkzUFz3BZzj0bb4YzexZcNQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1101.0': + resolution: {integrity: sha512-16EFb1aTEBgPcfUAWAjjlB57IZCyn7B3rlfT+xqE7M6WoH8AMMU3vFZO0UOitwh/xvvzVx73YED1/n0PU4qBMw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-sqs@3.1101.0': + resolution: {integrity: sha512-Ui4QpE1EII3CfTKxGHN4egx2GpowSb+r8o4g9cqcubc7j9fN3FOCKmQNCVMwQMxr33mXcM9yn8ZHrOiD2/ajeg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.4': + resolution: {integrity: sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.65': + resolution: {integrity: sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.67': + resolution: {integrity: sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.10': + resolution: {integrity: sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.72': + resolution: {integrity: sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.76': + resolution: {integrity: sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.65': + resolution: {integrity: sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.9': + resolution: {integrity: sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.71': + resolution: {integrity: sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.70': + resolution: {integrity: sha512-APdP0iODt39AkjCjzTFIoFrxDH/Cz3CpWRDKLcsJg7eOnfE1htkxL9BhDoe/xL7cXdoMwh2HBYv3DiT1uf64NQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-sqs@3.972.39': + resolution: {integrity: sha512-dlKLmJg1dLQVfFXUPS+f+SqXrRGHDVumY4gjM8sCLGao+zkDXEu8TObTyiaheKjT20ruok9Xs8oLocNItKQ0fw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.39': + resolution: {integrity: sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1100.0': + resolution: {integrity: sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.62.3': + resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.3': + resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.3': + resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.3': + resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.3': + resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.3': + resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.3': + resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.3': + resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.3': + resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.3': + resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.3': + resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.3': + resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.3': + resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.3': + resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} + cpu: [x64] + os: [win32] + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.1.2': + resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-pty@1.2.0-beta.14: + resolution: {integrity: sha512-XORU9BQgpxVgqr7WivjJ17mLenOHUgKKWzuZZNaw3NDYgHc/wPJQMSaoLDrpEgqV6aU1nNwil1o/OqYj6lWmUA==} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + rollup@4.62.3: + resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + +snapshots: + + '@aws-sdk/checksums@3.1000.24': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1101.0': + dependencies: + '@aws-sdk/checksums': 3.1000.24 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/credential-provider-node': 3.972.76 + '@aws-sdk/middleware-sdk-s3': 3.972.70 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-sqs@3.1101.0': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/credential-provider-node': 3.972.76 + '@aws-sdk/middleware-sdk-sqs': 3.972.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.977.4': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.65': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.10': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/credential-provider-env': 3.972.65 + '@aws-sdk/credential-provider-http': 3.972.67 + '@aws-sdk/credential-provider-login': 3.972.72 + '@aws-sdk/credential-provider-process': 3.972.65 + '@aws-sdk/credential-provider-sso': 3.973.9 + '@aws-sdk/credential-provider-web-identity': 3.972.71 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.72': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.76': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.65 + '@aws-sdk/credential-provider-http': 3.972.67 + '@aws-sdk/credential-provider-ini': 3.973.10 + '@aws-sdk/credential-provider-process': 3.972.65 + '@aws-sdk/credential-provider-sso': 3.973.9 + '@aws-sdk/credential-provider-web-identity': 3.972.71 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.65': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.9': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/token-providers': 3.1100.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.71': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.70': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-sqs@3.972.39': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.39': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.43': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1100.0': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.37': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.62.3': + optional: true + + '@rollup/rollup-android-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-x64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.3': + optional: true + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@smithy/core@3.31.1': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.16': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.12': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + + '@types/estree@1.0.9': {} + + '@types/node@26.1.2': + dependencies: + undici-types: 8.3.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.1.2))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@26.1.2) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + assertion-error@2.0.1: {} + + bowser@2.14.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + + expect-type@1.4.0: {} + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + fsevents@2.3.3: + optional: true + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + human-signals@8.0.1: {} + + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + isexe@2.0.0: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + node-addon-api@7.1.1: {} + + node-pty@1.2.0-beta.14: + dependencies: + node-addon-api: 7.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + parse-ms@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + rollup@4.62.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.3 + '@rollup/rollup-android-arm64': 4.62.3 + '@rollup/rollup-darwin-arm64': 4.62.3 + '@rollup/rollup-darwin-x64': 4.62.3 + '@rollup/rollup-freebsd-arm64': 4.62.3 + '@rollup/rollup-freebsd-x64': 4.62.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 + '@rollup/rollup-linux-arm-musleabihf': 4.62.3 + '@rollup/rollup-linux-arm64-gnu': 4.62.3 + '@rollup/rollup-linux-arm64-musl': 4.62.3 + '@rollup/rollup-linux-loong64-gnu': 4.62.3 + '@rollup/rollup-linux-loong64-musl': 4.62.3 + '@rollup/rollup-linux-ppc64-gnu': 4.62.3 + '@rollup/rollup-linux-ppc64-musl': 4.62.3 + '@rollup/rollup-linux-riscv64-gnu': 4.62.3 + '@rollup/rollup-linux-riscv64-musl': 4.62.3 + '@rollup/rollup-linux-s390x-gnu': 4.62.3 + '@rollup/rollup-linux-x64-gnu': 4.62.3 + '@rollup/rollup-linux-x64-musl': 4.62.3 + '@rollup/rollup-openbsd-x64': 4.62.3 + '@rollup/rollup-openharmony-arm64': 4.62.3 + '@rollup/rollup-win32-arm64-msvc': 4.62.3 + '@rollup/rollup-win32-ia32-msvc': 4.62.3 + '@rollup/rollup-win32-x64-gnu': 4.62.3 + '@rollup/rollup-win32-x64-msvc': 4.62.3 + fsevents: 2.3.3 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-final-newline@4.0.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tslib@2.8.1: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + unicorn-magic@0.3.0: {} + + vite-node@2.1.9(@types/node@26.1.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@26.1.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@26.1.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.25 + rollup: 4.62.3 + optionalDependencies: + '@types/node': 26.1.2 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@26.1.2): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.1.2)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@26.1.2) + vite-node: 2.1.9(@types/node@26.1.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.2 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yoctocolors@2.2.0: {} diff --git a/test/e2e/pnpm-workspace.yaml b/test/e2e/pnpm-workspace.yaml new file mode 100644 index 00000000..26a1c8fa --- /dev/null +++ b/test/e2e/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +# pnpm 11 reads project settings from here, not from package.json's "pnpm" field. +# +# Install scripts are blocked by default. node-pty unpacks or compiles a native +# binary — terminal tests are mandatory, so it must be allowed to run — and esbuild +# links vitest's platform binary. Nothing else in the tree may run a script. +allowBuilds: + node-pty: true + esbuild: true diff --git a/test/e2e/support/auth.ts b/test/e2e/support/auth.ts new file mode 100644 index 00000000..ff95645c --- /dev/null +++ b/test/e2e/support/auth.ts @@ -0,0 +1,15 @@ +/** The real auth token, when the environment provides one (CI secret, or a local dev token). */ +export function authToken(): string | undefined { + return process.env.LOCALSTACK_AUTH_TOKEN || undefined; +} + +/** + * The auth token for tests that cannot run without one. Throws rather than + * silently passing; callers guard with `describe.skipIf(!authToken())` so the + * suite still runs for contributors without a token. + */ +export function requireAuthToken(): string { + const token = authToken(); + if (!token) throw new Error("LOCALSTACK_AUTH_TOKEN must be set to run this test"); + return token; +} diff --git a/test/e2e/support/binary.ts b/test/e2e/support/binary.ts new file mode 100644 index 00000000..af7c6d49 --- /dev/null +++ b/test/e2e/support/binary.ts @@ -0,0 +1,16 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +/** Absolute path to the built binary under test. Nothing else in this suite knows where it lives. */ +export const lstkBinary = path.resolve( + here, + "../../../bin", + process.platform === "win32" ? "lstk.exe" : "lstk", +); + +export function binaryExists(): boolean { + return existsSync(lstkBinary); +} diff --git a/test/e2e/support/cli-output.ts b/test/e2e/support/cli-output.ts new file mode 100644 index 00000000..99a296d1 --- /dev/null +++ b/test/e2e/support/cli-output.ts @@ -0,0 +1,47 @@ +import type { Home } from "./home.ts"; + +/** + * Makes CLI output stable enough to inline-snapshot by masking the parts that + * differ per machine or per run. + * + * Only reach for this when the varying part is incidental to what the test is + * about. If the value itself is the point — a specific port, a specific resolved + * config path — assert it explicitly instead of masking it away. + */ +export interface NormalizeOptions { + /** Replaces this home's temp directory with ``. */ + home?: Home; + /** + * Rewrites `\` as `/`, so a path-bearing message can be asserted with one + * expectation on every platform. Only for output whose separators are incidental — + * if the separator itself is what a test is about, assert it directly. + */ + posixSeparators?: boolean; + /** Replaces each `[find, replace]` pair, applied after the built-in masks. */ + extra?: Array<[RegExp | string, string]>; +} + +export function normalizeCliOutput(text: string, options: NormalizeOptions = {}): string { + let out = text; + + if (options.home) { + // macOS reports /private/var/... where the env said /var/..., so mask both + // forms — longest first, or masking the short form would leave a stray + // "/private" in front of the placeholder. + const home = options.home.path; + const variants = [`/private${home}`, home, home.replace(/^\/private/, "")]; + for (const variant of [...new Set(variants)].sort((a, b) => b.length - a.length)) { + out = out.split(variant).join(""); + } + } + + if (options.posixSeparators) { + out = out.split("\\").join("/"); + } + + for (const [find, replace] of options.extra ?? []) { + out = typeof find === "string" ? out.split(find).join(replace) : out.replace(find, replace); + } + + return out; +} diff --git a/test/e2e/support/docker.ts b/test/e2e/support/docker.ts new file mode 100644 index 00000000..0090bf85 --- /dev/null +++ b/test/e2e/support/docker.ts @@ -0,0 +1,219 @@ +import { execa } from "execa"; +import { mkdir, rm, stat } from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, onTestFinished } from "vitest"; + +/** + * Docker is driven through the `docker` CLI rather than an API client: the CLI + * resolves DOCKER_HOST, contexts and non-Docker runtimes exactly the way a user's + * shell does, and it keeps this suite free of native dependencies. + */ +async function docker_(args: string[]): Promise<{ stdout: string; exitCode: number; stderr: string }> { + const result = await execa("docker", args, { reject: false }); + return { + stdout: (result.stdout ?? "").trim(), + stderr: (result.stderr ?? "").trim(), + exitCode: result.exitCode ?? 1, + }; +} + +let availability: Promise | undefined; + +/** True when a container runtime is reachable and container tests can run here. */ +export function dockerIsAvailable(): Promise { + // GitHub's Windows runners cannot run Linux containers (no nested virtualization). + if (process.platform === "win32" && process.env.CI) return Promise.resolve(false); + availability ??= docker_(["info", "--format", "{{.ServerVersion}}"]).then((r) => r.exitCode === 0); + return availability; +} + +let hostEndpoint: Promise | undefined; + +/** + * The daemon endpoint of the harness's active Docker context. + * + * Every isolated home gets this as DOCKER_HOST, because most runtimes put their + * socket under the user's real home (`~/.docker/run/docker.sock`, + * `~/.colima/default/docker.sock`, ...) — which the binary can no longer find + * once HOME points at a temp dir. Resolving it here keeps container tests + * working on Docker Desktop, Colima, Rancher Desktop and OrbStack alike, and is + * a no-op on CI where the native `/var/run/docker.sock` is already correct. + */ +export function dockerHost(): Promise { + hostEndpoint ??= (async () => { + if (!(await dockerIsAvailable())) return undefined; + if (process.env.DOCKER_HOST) return process.env.DOCKER_HOST; + const result = await docker_(["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"]); + return result.exitCode === 0 && result.stdout ? result.stdout : undefined; + })(); + return hostEndpoint; +} + +/** Images this worker has already confirmed present, so the check runs once each. */ +const presentImages = new Map>(); + +export const docker = { + /** + * Ensures `image` is available locally. + * + * `docker pull` costs ~1.8s even when the image is already present, because it + * still round-trips the registry to check the digest — several times the cost of + * the container the test actually wants. `docker image inspect` answers the same + * question locally in ~15ms, so pull only when it is genuinely missing, and + * remember the answer for the rest of the worker's life. + */ + async pull(image: string): Promise { + let pending = presentImages.get(image); + if (!pending) { + pending = (async () => { + if ((await docker_(["image", "inspect", image])).exitCode === 0) return; + const result = await docker_(["pull", image]); + if (result.exitCode !== 0) throw new Error(`docker pull ${image} failed: ${result.stderr}`); + })(); + presentImages.set(image, pending); + } + try { + await pending; + } catch (error) { + // A failed attempt must not be cached as success for later tests. + presentImages.delete(image); + throw error; + } + }, + + /** + * Tags an existing local image under a new reference and removes the tag when + * the test finishes. Used to place a stand-in image where lstk expects a real + * emulator image, so start-path decisions can be asserted without pulling + * gigabytes. + */ + async tag(source: string, target: string): Promise { + const result = await docker_(["tag", source, target]); + if (result.exitCode !== 0) throw new Error(`docker tag ${source} ${target} failed: ${result.stderr}`); + onTestFinished(async () => { + await docker_(["rmi", "--force", target]); + }); + }, + + async removeContainer(name: string): Promise { + await docker_(["rm", "--force", "--volumes", name]); + }, + + /** Container details, or null when no such container exists. */ + async inspectContainer(name: string): Promise { + const result = await docker_(["inspect", "--type", "container", name]); + if (result.exitCode !== 0) return null; + const [info] = JSON.parse(result.stdout) as ContainerInfo[]; + return info ?? null; + }, + + async containerIsRunning(name: string): Promise { + const info = await docker.inspectContainer(name); + return info?.State.Running === true; + }, +}; + +/** A port free on this machine right now, for a bind the caller is about to attempt. */ +function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("probe socket did not bind to a TCP port")); + return; + } + server.close(() => resolve(address.port)); + }); + }); +} + +let loopbackAliasProbe: Promise | undefined; + +/** + * Whether this daemon can publish a container port on a loopback alias such as + * 127.0.0.2. + * + * A native Linux daemon can; Docker Desktop's VM-backed networking answers "bind: + * can't assign requested address". Tests that need a mock server to hold the same + * port number on 127.0.0.1 depend on it, so it is probed rather than assumed — + * once per worker, with a throwaway container. + */ +export function dockerCanBindLoopbackAlias(): Promise { + loopbackAliasProbe ??= (async () => { + await docker.pull("alpine:latest"); + // An explicit host port, because that is what the tests do: Docker Desktop + // accepts `127.0.0.2:0:...` (it assigns the port itself) and only refuses + // the bind once a concrete port is named. + const port = await freePort(); + const name = `lstk-e2e-loopback-probe-${process.pid}`; + await docker_(["rm", "--force", name]); + const result = await docker_([ + "run", "-d", "--name", name, + "-p", `127.0.0.2:${port}:80`, + "alpine:latest", "sleep", "1", + ]); + await docker_(["rm", "--force", name]); + return result.exitCode === 0; + })(); + return loopbackAliasProbe; +} + +export interface ContainerInfo { + Name: string; + State: { Running: boolean; ExitCode: number; Status: string }; + Config: { Image: string; Env: string[] }; + HostConfig: { Binds: string[] | null }; + NetworkSettings: { + Ports: Record | null>; + }; +} + +/** Container names lstk uses; removed before and after any test that starts one. */ +export const emulatorContainers = ["localstack-aws", "localstack-snowflake", "localstack-azure"]; + +/** + * Serializes a whole describe block against every other worker on this machine + * and clears leftover containers around it. + * + * lstk discovers a running emulator by (image, internal port), so two tests + * starting a container at the same time would see each other's. This is the same + * constraint the Go suite handles by not marking those tests parallel. + */ +export function useExclusiveEmulator(): void { + const lockDir = path.join(os.tmpdir(), "lstk-e2e-emulator.lock"); + + beforeAll(async () => { + await acquire(lockDir); + await Promise.all(emulatorContainers.map((name) => docker.removeContainer(name))); + }, 300_000); + + afterAll(async () => { + await Promise.all(emulatorContainers.map((name) => docker.removeContainer(name))); + await rm(lockDir, { recursive: true, force: true }); + }); +} + +async function acquire(lockDir: string, timeoutMs = 240_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + try { + await mkdir(lockDir); + return; + } catch { + // A lock left behind by a killed run must not deadlock the next one. + const age = await stat(lockDir) + .then((s) => Date.now() - s.mtimeMs) + .catch(() => 0); + if (age > 600_000) { + await rm(lockDir, { recursive: true, force: true }); + continue; + } + if (Date.now() > deadline) throw new Error(`timed out waiting for ${lockDir}`); + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } +} diff --git a/test/e2e/support/emulator-api.ts b/test/e2e/support/emulator-api.ts new file mode 100644 index 00000000..e0d71bfd --- /dev/null +++ b/test/e2e/support/emulator-api.ts @@ -0,0 +1,220 @@ +import { execa } from "execa"; +import http from "node:http"; +import https from "node:https"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import zlib from "node:zlib"; +import { onTestFinished } from "vitest"; + +/** + * A stand-in for the emulator's own HTTP API — the endpoints lstk calls once it + * has decided *where* the emulator is: `/_localstack/health`, `/_localstack/info`, + * `/_localstack/resources` and `/_localstack/pods/state`. + * + * This is what `--endpoint-url` points at. Unlike `emulator-stub.ts` (a container + * that merely exists, so discovery finds something) nothing here runs in Docker: + * an externally-managed endpoint is by definition one lstk did not start, so these + * tests pair the server with an unreachable DOCKER_HOST to prove Docker is never + * consulted. + * + * Mirrors `awsHealthHandler` / `awsHealthServer` in + * test/integration/endpoint_url_test.go. + */ +export interface EmulatorApiOptions { + /** + * Version reported by `/_localstack/health`. Omit to leave the key out + * entirely, which is how lstk tells an Azure emulator apart from an AWS one — + * it falls back to `/_localstack/info` for the version. + */ + version?: string; + edition?: string; + services?: Record; + /** Body for `/_localstack/resources` (NDJSON). Defaults to an empty listing. */ + resources?: string; + /** Response for `/_localstack/info`, served only when set. */ + info?: Record; + /** Serve `/_localstack/pods/state` with this body, for `snapshot save`. */ + stateExport?: Buffer; + /** Serve over TLS with a throwaway self-signed certificate for 127.0.0.1. */ + tls?: boolean; +} + +export interface EmulatorApi { + /** Base URL, e.g. `http://127.0.0.1:54321`. */ + readonly url: string; + /** `host:port`, for `LOCALSTACK_HOST`. */ + readonly hostPort: string; + /** + * PEM file holding the server's certificate, for `tls: true` only. + * + * lstk has no `--insecure` flag by design, so a subprocess can only trust a + * throwaway cert through the OS trust mechanism its TLS stack reads. Go's + * crypto/x509 honours SSL_CERT_FILE only on the unix builds listed in + * root_unix.go — not darwin (Security.framework) and not Windows. Hence + * `sslCertFileTrustUnavailable` below. + */ + readonly certFile?: string; + /** Paths requested so far, oldest first. */ + requestedPaths(): string[]; +} + +/** + * Whether this platform's Go TLS verifier honours SSL_CERT_FILE, which is the + * only handle a test has on what an exec'd lstk trusts. + */ +export const sslCertFileTrusted = process.platform === "linux"; + +async function selfSignedCert(): Promise<{ key: string; cert: string; certFile: string }> { + const dir = await mkdtemp(path.join(os.tmpdir(), "lstk-e2e-tls-")); + const keyFile = path.join(dir, "key.pem"); + const certFile = path.join(dir, "cert.pem"); + + // openssl rather than a committed fixture: a checked-in private key trips the + // repo's gitleaks pre-commit hook, and a checked-in cert eventually expires. + await execa("openssl", [ + "req", "-x509", + "-newkey", "rsa:2048", + "-keyout", keyFile, + "-out", certFile, + "-days", "1", + "-nodes", + "-subj", "/CN=127.0.0.1", + "-addext", "subjectAltName=IP:127.0.0.1", + ]); + + return { + key: await readFile(keyFile, "utf8"), + cert: await readFile(certFile, "utf8"), + certFile, + }; +} + +export async function emulatorApi(options: EmulatorApiOptions = {}): Promise { + const paths: string[] = []; + + const handler = (req: http.IncomingMessage, res: http.ServerResponse): void => { + const url = new URL(req.url ?? "/", "http://localhost"); + paths.push(url.pathname); + + switch (url.pathname) { + case "/_localstack/health": { + const body: Record = { + services: options.services ?? { s3: "available", sqs: "available" }, + }; + if (options.version !== undefined) body.version = options.version; + if (options.edition !== undefined) body.edition = options.edition; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + return; + } + case "/_localstack/info": { + if (options.info === undefined) break; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(options.info)); + return; + } + case "/_localstack/resources": { + res.writeHead(200, { "Content-Type": "application/x-ndjson" }); + res.end(options.resources ?? ""); + return; + } + case "/_localstack/pods/state": { + if (options.stateExport === undefined) break; + res.writeHead(200, { "Content-Type": "application/zip" }); + res.end(options.stateExport); + return; + } + default: + break; + } + res.writeHead(404).end(); + }; + + let certFile: string | undefined; + let server: http.Server | https.Server; + if (options.tls) { + const { key, cert, certFile: file } = await selfSignedCert(); + certFile = file; + server = https.createServer({ key, cert }, handler); + } else { + server = http.createServer(handler); + } + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + onTestFinished(() => new Promise((resolve) => server.close(() => resolve()))); + + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("emulator API mock did not bind to a TCP port"); + } + const hostPort = `127.0.0.1:${address.port}`; + + return { + url: `${options.tls ? "https" : "http"}://${hostPort}`, + hostPort, + ...(certFile === undefined ? {} : { certFile }), + requestedPaths: () => [...paths], + }; +} + +/** + * A "server that is reachable but is not LocalStack" — 404 on everything, so type + * detection cannot conclude anything and lstk must fail closed. + */ +export async function notLocalStackServer(): Promise<{ url: string }> { + const server = http.createServer((_req, res) => res.writeHead(404).end()); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + onTestFinished(() => new Promise((resolve) => server.close(() => resolve()))); + + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("mock server did not bind to a TCP port"); + } + return { url: `http://127.0.0.1:${address.port}` }; +} + +/** + * A minimal store-only ZIP, the shape `/_localstack/pods/state` returns. + * + * `snapshot save` streams the response body straight to disk without parsing it, + * so the bytes only have to be a plausible export — but a real archive keeps the + * fixture honest if that ever changes. Mirrors `minimalStateZip` in + * test/integration/endpoint_url_https_test.go. + */ +export function stateExportZip(name = "state.json", content = `{"services":{}}`): Buffer { + const nameBytes = Buffer.from(name, "utf8"); + const data = Buffer.from(content, "utf8"); + const crc = zlib.crc32(data); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); // local file header signature + localHeader.writeUInt16LE(20, 4); // version needed + localHeader.writeUInt16LE(0, 8); // method: store + localHeader.writeUInt32LE(crc, 14); + localHeader.writeUInt32LE(data.length, 18); + localHeader.writeUInt32LE(data.length, 22); + localHeader.writeUInt16LE(nameBytes.length, 26); + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); // central directory header signature + centralHeader.writeUInt16LE(20, 4); // version made by + centralHeader.writeUInt16LE(20, 6); // version needed + centralHeader.writeUInt16LE(0, 10); // method: store + centralHeader.writeUInt32LE(crc, 16); + centralHeader.writeUInt32LE(data.length, 20); + centralHeader.writeUInt32LE(data.length, 24); + centralHeader.writeUInt16LE(nameBytes.length, 28); + + const centralOffset = localHeader.length + nameBytes.length + data.length; + const centralSize = centralHeader.length + nameBytes.length; + + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); // end of central directory signature + end.writeUInt16LE(1, 8); // entries on this disk + end.writeUInt16LE(1, 10); // total entries + end.writeUInt32LE(centralSize, 12); + end.writeUInt32LE(centralOffset, 16); + + return Buffer.concat([localHeader, nameBytes, data, centralHeader, nameBytes, end]); +} diff --git a/test/e2e/support/emulator-stub.ts b/test/e2e/support/emulator-stub.ts new file mode 100644 index 00000000..26ca2862 --- /dev/null +++ b/test/e2e/support/emulator-stub.ts @@ -0,0 +1,160 @@ +import { execa } from "execa"; +import { onTestFinished } from "vitest"; +import { docker } from "./docker.ts"; + +/** + * A stand-in for a running emulator, cheap enough to use in any test that needs + * one without a license or a real image. + * + * `internal/container/running.go`'s ResolveRunningContainerName looks the emulator + * up by container name first (`localstack-`), falling back to (known image + * repo, internal port) for containers started outside lstk. Neither path requires + * a working LocalStack, so a plain image that just stays up reads as "the emulator + * is running" to `lstk stop` / `status` / `restart` / `logs` / `reset`. Mirrors + * `startTestContainer` / `startNamedTestContainer` / `startExternalContainer` in + * test/integration/main_test.go. + * + * What this does NOT provide is a responding emulator API: tests that need + * /_localstack/... to answer point lstk at a local HTTP server via LOCALSTACK_HOST. + */ +const STAND_IN_IMAGE = "alpine:latest"; + +/** + * Stays up until asked to stop, and then stops immediately. + * + * A plain `sleep infinity` ignores SIGTERM, so `lstk stop` — which stops the + * container the polite way — waited out Docker's full 10s grace period before the + * SIGKILL, costing ~9s per stop test for nothing. Trapping TERM makes the same test + * ~1s. PID 1 is still a shell, which is what `writeContainerLogLines` needs in order + * to write to /proc/1/fd/1. + */ +const STAY_UP = ["sh", "-c", 'trap "exit 0" TERM; while :; do sleep 1; done']; + +/** The default AWS emulator's canonical container name (tag "latest"). */ +export const defaultEmulatorName = "localstack-aws"; + +let stubCounter = 0; + +export interface PrivateEmulator { + /** Non-"latest" tag, which is what makes the container name unique. */ + readonly tag: string; + /** Container name lstk derives from that tag: `localstack--`. */ + readonly name: string; + /** config.toml body selecting this emulator. */ + readonly config: string; +} + +/** + * An emulator identity no other test shares, so the test needs no global lock. + * + * `config.ContainerConfig.Name()` returns `localstack-` only for tag "latest"; + * any other tag yields `localstack--`. Giving each test its own tag + * therefore gives it its own container name, and lstk's name-first discovery finds + * exactly that container — so tests that only need "an emulator is running" can run + * concurrently instead of queueing behind `useExclusiveEmulator()`. + * + * The port stays at the caller's choice (4566 by default): stub containers publish + * nothing, and the image/port fallback only matches real `localstack/*` image + * references, so a shared port cannot cross-match a plain stand-in container. Tests + * that deliberately exercise that fallback — or that must use the canonical + * `localstack-` name — still need the lock. + * + * Just as useful for asserting the *absence* of an emulator: write the config and + * never start a stub for it, and "not running" holds no matter what any concurrent + * test is doing. That is stronger than the lock, which only kept other well-behaved + * tests away rather than guaranteeing the name was free. + */ +export function privateEmulator( + type: "aws" | "snowflake" | "azure" = "aws", + options: { port?: string } = {}, +): PrivateEmulator { + const tag = `e2e-${process.pid}-${++stubCounter}`; + const port = options.port ?? "4566"; + return { + tag, + name: `localstack-${type}-${tag}`, + config: `[[containers]]\ntype = "${type}"\ntag = "${tag}"\nport = "${port}"\n`, + }; +} + +export interface StubEmulatorOptions { + /** + * Image to run in place of a real emulator image. Defaults to `alpine:latest`, + * which is pulled automatically. Pass an already-tagged image (e.g. via + * `docker.tag(...)` onto a known emulator repo like + * `localstack/localstack-pro:`) to exercise the image-based fallback used + * for containers not named `localstack-`; a caller-supplied image is never + * pulled, since it usually exists only locally. + */ + image?: string; + /** + * Publishes container port 4566/tcp to this host port, on 127.0.0.1 unless + * `hostIp` names another address. A loopback alias such as 127.0.0.2 frees the + * same port number on 127.0.0.1 for a mock server — see + * `dockerCanBindLoopbackAlias`, since not every daemon allows it. + */ + hostBinding?: { hostPort: string; hostIp?: string }; + /** Extra arguments for `docker run`, inserted before the image. */ + dockerArgs?: string[]; +} + +/** + * Starts a placeholder container under `name`, removed when the test finishes. + * + * Any container already holding the name is force-removed first: the name is fixed + * by lstk's discovery rules, so a crash between creation and cleanup — or a stray + * container from outside this suite — would otherwise block every later run. + */ +export async function startStubEmulator( + name: string = defaultEmulatorName, + options: StubEmulatorOptions = {}, +): Promise { + const image = options.image ?? STAND_IN_IMAGE; + if (image === STAND_IN_IMAGE) { + await docker.pull(image); + } + + await docker.removeContainer(name); + onTestFinished(async () => { + await docker.removeContainer(name); + }); + + const args = ["run", "-d", "--name", name, ...(options.dockerArgs ?? [])]; + if (options.hostBinding) { + const hostIp = options.hostBinding.hostIp ?? "127.0.0.1"; + args.push("-p", `${hostIp}:${options.hostBinding.hostPort}:4566`); + } + args.push(image, ...STAY_UP); + + const result = await execa("docker", args, { reject: false }); + if (result.exitCode !== 0) { + throw new Error(`docker run --name ${name} failed: ${result.stderr}`); + } +} + +/** + * Writes `lines` to the container's PID 1 stdout (so they show up in `docker logs`, + * the same channel `lstk logs` reads) and waits until the last one is visible, so + * tail/follow assertions never race the write. + */ +export async function writeContainerLogLines(name: string, lines: string[]): Promise { + const lastLine = lines.at(-1); + if (lastLine === undefined) return; + + const script = lines.map((line) => `echo '${line.replaceAll("'", `'\\''`)}'`).join("; "); + await execa("docker", ["exec", name, "sh", "-c", `{ ${script}; } >/proc/1/fd/1`]); + await waitForLogLine(name, lastLine); +} + +async function waitForLogLine(name: string, marker: string, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const result = await execa("docker", ["logs", name], { reject: false }); + const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if (combined.includes(marker)) return; + if (Date.now() > deadline) { + throw new Error(`"${marker}" never appeared in docker logs ${name}`); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} diff --git a/test/e2e/support/envelope.ts b/test/e2e/support/envelope.ts new file mode 100644 index 00000000..b2cf1a79 --- /dev/null +++ b/test/e2e/support/envelope.ts @@ -0,0 +1,33 @@ +/** + * The `--json` contract: one Envelope object on stdout and nothing else. + * See docs/structured-output.md. + */ +export interface Envelope { + schemaVersion: number; + command: string; + /** The wire values are "ok" and "error" (internal/output/envelope.go). */ + status: "ok" | "error"; + data?: Data; + warnings: Array<{ code: string; message: string }>; + error?: { + code: string; + category: string; + message: string; + retryable: boolean; + details?: Record; + }; +} + +/** Parses stdout as an envelope, failing loudly if anything else was printed. */ +export function parseEnvelope(stdout: string): Envelope { + let envelope: Envelope; + try { + envelope = JSON.parse(stdout) as Envelope; + } catch { + throw new Error(`stdout should be exactly one JSON object, got:\n${stdout}`); + } + if (!Array.isArray(envelope.warnings)) { + throw new Error("warnings should always be an array, never omitted or null"); + } + return envelope; +} diff --git a/test/e2e/support/extension-fixture.ts b/test/e2e/support/extension-fixture.ts new file mode 100644 index 00000000..8a061313 --- /dev/null +++ b/test/e2e/support/extension-fixture.ts @@ -0,0 +1,45 @@ +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** + * A minimal lstk extension, used only to prove lstk's own JSON/non-interactive + * context conveyance (LSTK_EXT_CONTEXT) — not a stand-in for the fake proxy + * binaries used by the aws/terraform/cdk/sam tests. It echoes its argv and the + * conveyed context in the same line-oriented shape the Go suite's reference + * extension (test/integration/test-samples/extensions/lstk-ref) uses, so + * assertions read the same way, but is a plain Node script rather than a + * compiled Go binary — nothing here builds or imports lstk itself. + */ +const EXTENSION_SCRIPT = ` +const args = process.argv.slice(2); +let ctx = {}; +try { ctx = JSON.parse(process.env.LSTK_EXT_CONTEXT || "{}"); } catch {} +console.log(\`ARGS=[\${args.join(" ")}]\`); +console.log(\`JSON=\${ctx.json === true}\`); +console.log(\`NON_INTERACTIVE=\${ctx.nonInteractive === true}\`); +`; + +/** + * Installs an executable named `lstk-` into `dir`, so that placing `dir` + * on PATH makes lstk resolve and dispatch to it as the `` extension. + */ +export async function installExtension(dir: string, name: string): Promise { + await mkdir(dir, { recursive: true }); + + if (process.platform === "win32") { + // lstk resolves extensions via exec.LookPath, which on Windows searches + // PATHEXT against the exact base name — a .cmd shim re-invoking the script + // through node satisfies that. + const scriptPath = path.join(dir, `lstk-${name}.mjs`); + await writeFile(scriptPath, EXTENSION_SCRIPT); + await writeFile(path.join(dir, `lstk-${name}.cmd`), `@echo off\r\nnode "${scriptPath}" %*\r\n`); + return; + } + + // On Unix, exec.LookPath matches the base name exactly (no extension), so + // the executable itself must be literally named `lstk-`; the shebang + // line is what makes it run under node despite carrying no .mjs suffix. + const execPath = path.join(dir, `lstk-${name}`); + await writeFile(execPath, `#!/usr/bin/env node\n${EXTENSION_SCRIPT}`); + await chmod(execPath, 0o755); +} diff --git a/test/e2e/support/fake-binary.ts b/test/e2e/support/fake-binary.ts new file mode 100644 index 00000000..46415c9c --- /dev/null +++ b/test/e2e/support/fake-binary.ts @@ -0,0 +1,188 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { onTestFinished } from "vitest"; + +/** + * A fake wrapped-tool executable (`aws`, `terraform`, `cdk`, `sam`, `tofu`, ...) + * placed on PATH ahead of the real one. It never talks to a real backend: every + * invocation is appended to a call log (argv, env, cwd, and optionally the + * contents of named files at call time) that the test can read back after + * `lstk()` returns, and it answers with a small, test-declared set of + * canned responses. + * + * The recording/response logic is a single Node script (Node is guaranteed + * present -- it is running the tests) shared by both platforms; only the + * on-PATH launcher differs. On POSIX it is a `#!/usr/bin/env node` file named + * exactly `options.name` with mode 0o755. On Windows, where a shebang can't + * make a file executable, it is a same-named `.cmd` shim that forwards `%*` + * to `node