Move the CLI-level integration tests to a TypeScript e2e suite - #427
Draft
skyrpex wants to merge 16 commits into
Draft
Move the CLI-level integration tests to a TypeScript e2e suite#427skyrpex wants to merge 16 commits into
skyrpex wants to merge 16 commits into
Conversation
174 tests across 22 files, covering ~130 of the Go integration suite's cases at the behaviour level: assertions describe what a user observes from the CLI, never internal mechanism (telemetry events, token storage, container introspection). Exact output is asserted with toPrintExactly rather than inline snapshots, so expectations are authored rather than recorded and compose with test.each. Runs on Node 26 + pnpm as type-erasable TypeScript, typechecked by TypeScript 7. Terminal tests are mandatory rather than best-effort: node-pty is a required dependency pinned to a prerelease that ships prebuilds for every platform CI runs on, and a missing binding fails the run instead of silently skipping every PTY test. LSTK_E2E_REQUIRE_ALL turns a missing prerequisite into a failure on the CI leg that has all of them. Deliberately not a release gate yet: the test-e2e job stays outside the release job's needs while the suite is evaluated against test/integration. Co-authored-by: Claude <noreply@anthropic.com>
Full suite drops from 163s to ~35s. Three changes, in order of effect: docker pull costs ~1.8s even when the image is already present, because it still round-trips the registry for the digest. It ran on nearly every container test. Now a local `docker image inspect` (~15ms) answers the same question, memoized per worker. The stand-in container ran `sleep infinity`, which ignores SIGTERM, so every `lstk stop` test waited out Docker's full 10s grace period before the SIGKILL. Trapping TERM takes those tests from 12s to 1.3s. Most container tests no longer need the machine-wide lock: ContainerConfig.Name() returns the canonical `localstack-<type>` only for tag "latest", so a per-test tag yields a per-test container name that lstk's name-first discovery resolves. privateEmulator() mints that identity. Tests that still need the lock keep it, each with the reason recorded inline: zero-config tests (no config means tag "latest" means the canonical name), and anything standing up a container from a real `localstack/*` image reference. The latter is not obvious — discovery falls back to matching any known localstack image exposing 4566, and ContainerPort() is hardcoded to 4566 regardless of the configured port, so such a container is visible to every other test's "is an emulator running" check no matter what it is named. Getting that wrong made the suite fail roughly 2 runs in 9; the fixed suite ran clean 8 times consecutively. Co-authored-by: Claude <noreply@anthropic.com>
pnpm/action-setup reads its version from a package.json packageManager field, defaulting to the repository root — which has none, since this is a Go repo. All three e2e legs failed at setup with "No pnpm version is specified" before running a single test. Also stop the JUnit reporter from failing on a missing report: when setup fails there is nothing to report, and its "no files found" error buried the real cause. Co-authored-by: Claude <noreply@anthropic.com>
Every one of these passed locally and failed on a runner, in ways a local macOS box with Docker cannot reproduce. Teardown no longer decides a verdict: a test that starts a real emulator gets a volume directory LocalStack populates as root inside the container, so removing the temp home as the runner user failed with EACCES on cache/certs after the assertions had already passed. Those files need root to delete, which a throwaway container has; failing that, the leftovers stay in the OS temp dir. The completion driver calls the completion function directly rather than pressing Tab, so bash warns that compopt is "not currently executing completion function". macOS bash 3.2 has no compopt builtin at all, which is why it never appeared locally. That one line is filtered; the rest of stderr stays asserted. `start` checks the container runtime before auth, so on a runner without one it reports "Docker is not available" and says nothing about credentials — useless as the auth probe the login journey used it for. Skipped where it cannot be meaningful instead of loosened everywhere. Fake tools are now Node scripts with a .cmd shim on Windows, where a `#!/bin/sh` file with no extension cannot execute at all — 16 proxy tests reported "not found in PATH". Path comparisons go through realpath, since Windows may report an 8.3 short path where Node reports the long one, and the config migration message is asserted with separators normalized. Co-authored-by: Claude <noreply@anthropic.com>
The "Docker is not available" error names the endpoint it tried on POSIX
("cannot connect to ... at unix://...") but prints no such detail line on Windows,
so that assertion now runs only where lstk provides it. Worth a product look: a
Windows user cannot tell a wrong DOCKER_HOST from a stopped daemon.
Temp-home removal retries briefly before giving up: on Windows a just-killed PTY
child can still hold a handle, which surfaces as EBUSY in teardown for a test whose
assertions already passed.
Co-authored-by: Claude <noreply@anthropic.com>
A headless macOS runner has a locked login keychain, so a keychain write blocks instead of failing: the system-keyring journey did not fail on macOS CI, it hung until the 120s test timeout. scripts/test-integration.sh forces LSTK_KEYRING=file for the Go suite on macOS for exactly this reason. An explicit LSTK_E2E_REAL_KEYRING=1 still wins, for a developer with an unlocked keychain. Also keep the default reporter alongside github-actions on CI. The annotations reporter alone leaves no pass/fail/skip summary in the job log, which meant every diagnosis this session had to download and parse the JUnit artifact to learn what had actually run. Co-authored-by: Claude <noreply@anthropic.com>
388 test functions across 52 files become 272 across 41. Everything removed here has a counterpart in test/e2e that asserts the same user-visible behaviour, so this is the second half of the migration, not a coverage cut. Deleted outright: json_envelope, exit_code, non_interactive, completion, docs, terraform_cmd, logs, reset, volume, stop, restart. Trimmed where a case needed something TypeScript cannot reach: json_flag keeps the proxy tables (they cover `az`, which needs a completed `lstk setup azure`), aws_cmd keeps the spinner timing tests, status keeps the AWS-SDK resource listing and the 127.0.0.2 loopback-alias case, config keeps the container env-var check. Per-command telemetry was the one thing several deleted tests uniquely asserted, since test/e2e deliberately does not stand up a mock analytics server. It moves to a table in command_telemetry_test.go rather than disappearing. Co-authored-by: Claude <noreply@anthropic.com>
…e split Four tests in logout_test.go and aws_cmd_test.go were kept purely because each was the only remaining assertion of its command's telemetry event; their behavioural halves already live in test/e2e. They become four rows in TestCommandTelemetryPerCommand instead. login stays where it is: its assertion is entangled with the PTY device flow, which is not worth reproducing in a table. The docs were still describing this work as a prototype sampling 11 of 384 Go cases. PORTING.md now records what actually happened (388 -> 272 test functions across 52 -> 42 files) and, for every Go test that stayed behind, why it could not move. CLAUDE.md's Testing section named the Go suite as the default home for new tests, which is now wrong for the migrated areas, so it names the owner per area and points at test/e2e/README.md for the conventions. Co-authored-by: Claude <noreply@anthropic.com>
Two conflicts, both modify/delete: main added an LSTK_ENDPOINT_URL rejection test to stop_test.go and restart_test.go, files this branch deletes. The deletions stand and the two tests move into endpoint_url_test.go, next to the 27 siblings main landed there. They belong in Go regardless of this port: they assert through the Docker SDK that the container was left untouched, and splitting one feature's tests across two suites to save two functions would cost more than it buys. PORTING.md records that reasoning and the refreshed counts (against main: 417 -> 301 test functions, 54 -> 44 files). Co-authored-by: Claude <noreply@anthropic.com>
One conflict, in config_test.go: main's custom-container-name change (#420) added TestConfigWithInvalidContainerNameFails inside a block this branch deletes. That test is pure CLI output rejected at config load — no daemon, no token — so it moves to tests/config.test.ts next to its "port is required" sibling, asserting the whole stderr line rather than a substring. Its companion, TestStartCommandUsesCustomContainerName, stays in Go: it needs a real start plus a container inspect. PORTING.md records both and the refreshed counts (against main: 419 -> 302 test functions, 54 -> 44 files). Co-authored-by: Claude <noreply@anthropic.com>
…ial store Where a credential lives is an implementation detail, so no test should have to know. The Go tests that did know were also the fragile ones: they wrote through the test process's own $HOME while running lstk under a temp one, which passes on Linux CI and fails locally. Six cases move to login-journey.pty.test.ts, each replacing a storage read with the behaviour that read was standing in for. "A failed login stores nothing" is now "start still demands credentials, and a retry reopens the browser" — which also covers the case the Go test could not, that a failed login must not short-circuit the next one. LOCALSTACK_AUTH_TOKEN answering login without a browser, and logout reporting that it cannot remove an env var (and leaving it working), get the same treatment. The three logout cases that name still-running emulators come across too. The foreign-emulator one asserts an absence, so it now runs its control first: the same container, discovered through the image fallback under an AWS config, reported — then not reported under a snowflake one. Only the configured type differs between the two runs. That empties logout_test.go, and leaves login_test.go with TestDeviceFlowSuccess alone for its telemetry assertion. The telemetry table loses its stored-token logout row: both logout rows emitted the same event and exit code, so the row bought nothing and was the last thing in that file reaching into the keyring. Co-authored-by: Claude <noreply@anthropic.com>
--endpoint-url was the largest block still in Go, and the one that kept causing merge conflicts because it spans commands the TypeScript suite already owns. It moves as a unit: 36 cases across endpoint-url.test.ts, its PTY sibling, and an https file. The eighteen rejection cases collapse into one table. Every command with no remote equivalent refuses all three sources — the flag, LSTK_ENDPOINT_URL and AWS_ENDPOINT_URL — with the same sentence, so asserting the whole line is both shorter and stricter than the substring checks it replaces. status output turned out to be fully determined once the endpoint is externally managed (no Container or Uptime line, which is the point), so those assert exactly too. The two status tests left behind get their blockers removed rather than worked around. Resources are now created through the AWS SDK against a real emulator, which is what made that test worth keeping. The bound-port test needs a daemon that can publish on the 127.0.0.2 loopback alias; Docker Desktop refuses, so it is probed and gated instead of dropped as unreproducible. The https cases need certificate trust that an exec'd lstk actually reads, which Go's x509 verifier only takes from SSL_CERT_FILE on Linux — the same gate they had in Go. They were verified on Linux in a container rather than shipped unrun. Go's integration suite loses the AWS SDK entirely as a result: 419 -> 267 test functions against main, across 54 -> 40 files. Co-authored-by: Claude <noreply@anthropic.com>
One conflict: #424 added `lstk aws` completion, whose bash-driver test depends on a helper inside the completion_test.go this branch deletes. Both Go files move rather than one being restored to prop up the other. `lstk aws <TAB>` delegating to aws_completer is CLI-observable and needs neither Docker nor a keyring, and completion.test.ts already drives the generated script under a bare bash — so the six cases land next to the completion tests they belong with. The stand-in aws_completer is the suite's ordinary fake-binary fixture, which records COMP_LINE rather than echoing it; the driver's deliberately-bare PATH gains node's own directory, since that fixture is a node script. Co-authored-by: Claude <noreply@anthropic.com>
All four trace back to two mistakes, both invisible on a machine that skips the container tests. The new "resources a real emulator actually holds" test started a licensed emulator and never stopped it. Block-level cleanup only runs after the last test in the block, so the canonical name and port 4566 stayed occupied — which is why the Snowflake test right after it failed to start at all, and why a later `stop` found something to stop when the suite expected nothing. It now removes the container when it finishes. That test also asserted /SQS\s+my-test-queue/, but the resource table names a queue by its URL, not the bare name it was created with. The Go test it replaced used a substring match and never noticed. The logout foreign-emulator test resolved its control through the image/port fallback, which needs a published port — and 4566 is shared machine state even under the exclusive lock. The control now resolves by container name, the deterministic half of discovery, while the assertion it guards still exercises the fallback: a snowflake-typed config reaching it finds nothing, because the fallback is scoped to the repos of the configured type. Co-authored-by: Claude <noreply@anthropic.com>
The table was assembled from a command list crossed with a source list, through a withSource() helper that decided whether the endpoint became an argument or an environment variable. Reading any single case meant reconstructing it from three places, and the interesting differences between commands — `volume clear` wants --force, `start` wants --non-interactive — were buried in the helper rather than visible in the row. Each of the eighteen rows now carries its own argv and environment. The data is more verbose; the test body is four lines with nothing to chase, and a row reads as a complete statement of what runs and what comes back. Co-authored-by: Claude <noreply@anthropic.com>
The stop/restart pair and the snapshot show/list pair were two-row tables whose rows carried a name and nothing else, so the body had to rebuild both the argv and the expected message. Four plain tests say the same thing without the reader assembling anything. The other ten parameterized tables in the suite are left alone: their rows already carry the argv, the expected output, or both, and the composition that happens in the body is the subject under test rather than an indirection — json-envelope varies where --json sits for the same four proxies, terraform's rows are the argv fragment itself, and strip-ansi is an input/output table. Separately, running the full suite repeatedly surfaced the `stop` idempotency test failing the same way it just did on CI, which I had wrongly attributed to the emulator left running by the new status test. The real cause is older: a private container identity does not isolate this assertion, because the second stop finds nothing under the configured name and falls back to matching (known image repo, port 4566). Any concurrent test running a real localstack image on 4566 answers that, and the second stop succeeds. It moves under the machine-wide lock with the other tests that assert an absence. Three consecutive full runs are clean. Co-authored-by: Claude <noreply@anthropic.com>
Member
|
Hi @skyrpex, I noticed this PR and I wonder what the goals here would be? What would the benefits be besides that some people will find it more readable? (from my perspective Go would be the more readable option FWIW :P) However, I can list some negatives of porting:
I don't want to outright say no to this, and I see benefits of having TS testing here (especially around testing interaction with the VS Code extension), but so far I don't fully understand the argument of why this big change is necessary. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A TypeScript/Vitest e2e suite in
test/e2e/, driving the builtbin/lstkbinary andimporting no lstk source. 225 tests, ~75s for the full run.
It is not additive: the Go integration tests it replaces are deleted in the same PR.
Against
main,test/integrationgoes from 427 test functions across 55 files to 269across 40 (14,758 → 10,623 lines), and loses its AWS SDK dependency entirely. Which
suite owns what — and, for every Go test that stayed behind, why it could not move — is
in
test/e2e/PORTING.md.The
test-e2eCI job is still outside the release job'sneeds:. That now has tochange before this merges, since for the migrated areas it is the only suite covering
the CLI boundary.
Why
test/integrationalready runs the binary and reports to CI, so the gap it leaves isreadability, not capability. The question was whether a suite whose tests read as
statements about user-visible behaviour is worth a second toolchain in a Go repo.
What it asserts, and what it refuses to
Tests describe what a user observes from the CLI. Assertions on mechanism were
deliberately not ported:
assertCommandTelemetry) — which analytics event fired is invisible toa user. This is the one thing the deleted Go tests uniquely covered, so it is
consolidated rather than dropped:
test/integration/command_telemetry_test.goassertsper-command event name and exit code in one table.
docker inspectofConfig.Env,HostConfig.Binds) —replaced by the CLI-observable equivalent where one exists, e.g.
restart --persistis asserted through the
• Persistence: Enabledline.test/e2etouches the credential store;keyring: "file" | "system"only picks which backend the binary uses. Everyassertion the Go tests made by reading the keyring is now made through behaviour:
logging in means
startstops demanding credentials and a secondloginsays"You're already logged in"; a failed login storing nothing means
startstilldemands them and a retry reopens the browser. That is stronger than reading the
store — it shows the credential is usable — and it holds for whichever backend
the binary picked. It is also more robust: the two Go tests that did reach in wrote
through the test process's own
$HOMEwhile running lstk under a temp one, so theypassed on Linux CI and failed locally.
Exact output is asserted with a
toPrintExactlymatcher rather than inline snapshots:the expectation is authored rather than recorded, no
vitest -ucan quietly bless aregression, and it composes with
test.each(inline snapshots throw inside it).What was deleted, and what stayed
Deleted outright:
json_envelope,exit_code,non_interactive,completion,docs,aws_completion,terraform_cmd,logs,reset,volume,stop,restart,logout,status,endpoint_url,endpoint_url_https.--endpoint-url(#419) landed after this port was written and moves across as a unit —36 cases. Its eighteen rejection cases collapse into one table: every command with no
remote equivalent refuses all three sources (the flag,
LSTK_ENDPOINT_URL,AWS_ENDPOINT_URL) with the same sentence, so asserting the whole line is both shorterand stricter than the substring checks it replaces.
lstk awscompletion (#424) arrived the same way, and its bash-driver case depended on ahelper inside
completion_test.go. Both files moved rather than one being restored toprop up the other: the delegation to
aws_completeris CLI-observable and needs noDocker, and
completion.test.tsalready drives the generated script under a bare bash.config_test.golikewise gainedTestConfigWithInvalidContainerNameFailsfrom #420.That one went the other way — rejected at config load, no daemon and no token, so it was
ported to
tests/config.test.tsnext to itsport is requiredsibling. Its companionTestStartCommandUsesCustomContainerNamestays in Go: it needs a real start plus acontainer inspect.
Trimmed, keeping only what TypeScript cannot reach —
json_flag(2 of 7: theaztables, which need a completed
lstk setup azure),aws_cmd(3 of 18: spinner timingunder a PTY),
config(1 of 12: container env introspection),emulator_type(7 of 10),emulator_select(7 of 9),start(34 of 36),login(1 of 4:
TestDeviceFlowSuccess, kept for its telemetry assertion).Untouched: snapshots, IaC end-to-end, most of
start, extensions and signal forwarding,update/install, license,
az/setup azure/awsconfig.Two Go tests turned out to be vacuous:
TestConfigWithUnknownFieldsIsAcceptedandTestConfigWithMissingOptionalTagSucceedsassert config acceptance vialstk config path, which never parses the file — they pass against a nonexistent path. The ports uselstk logout, which actually callsconfig.Get().Findings worth their own PRs
Asserting whole output instead of substrings surfaced inconsistencies no test was
checking. None are fixed here:
ErrorEventfailures print to stdout with==>next steps; several user-facing errors instead fall through to the unstyled stderr
fallback with no guidance —
volume clear/resetconfirmation-required, themissing-port config error, the
config.yamlmigration message, theterraformoverride-file refusal. CLAUDE.md calls that fallback "not the preferred path".
lstk stop/restartnot-running errors offer no next step;status/awsdo.lstk config bogusgives no "See help" hint;lstk bogusdoes.logssays "LocalStack AWS Emulator is notrunning",
resetsays "LocalStack is not running".--typerenders the config path two ways — absolute on first run, literal~/.config/lstk/config.tomlwhen switching.runningNonAWSEmulator(cmd/iac.go) fabricates a container config instead ofreading the user's, unlike its neighbour
resolveAWSContainer. Harmless for thecommon case (the image fallback rescues it), but a custom
imageplus a non-latesttag defeats both probes and produces the misleading "AWS not running".
cannot connect to ... at unix:///…, Windows prints only the suggestions, so a Windows usercannot tell a wrong
DOCKER_HOSTfrom a stopped daemon.lstk loginchecks for a TTY before the already-logged-in short-circuit(
cmd/login.go), so a scriptedlstk loginwhile authenticated says "login requiresan interactive terminal" and exits 1 instead of "You're already logged in".
Toolchain
Node ≥ 26 (pinned in
test/e2e/.node-version) and pnpm. Type-erasable TypeScript only(
erasableSyntaxOnly), typechecked by TypeScript 7,.tsimport specifiers.Terminal tests are mandatory rather than best-effort:
node-ptyis a requireddependency, and a missing binding fails the run instead of silently skipping half the
suite — which is what happened when it was optional and npm blocked its build script.
It is pinned to an exact prerelease because 1.1.0 ships a non-executable
spawn-helperon macOS and no Linux prebuilds;
test/e2e/README.mdrecords the measurements behindthat pin.
LSTK_E2E_REQUIRE_ALL=1turns a missing prerequisite into a failure on theLinux CI leg, so coverage cannot erode into silent skips.
Speed
163s → ~33s over three changes, each measured (the suite has since grown to ~70s,
almost all of it the browser-login PTY tests, which cannot be made much faster):
docker pullcosts ~1.8s even when the image is present (it still checks the registrydigest); a local
docker image inspectis ~15ms.sleep infinity, which ignores SIGTERM, so everylstk stoptest waited out Docker's 10s grace period. Trapping TERM: 12s → 1.3s.privateEmulator()), sinceContainerConfig.Name()only returns the canonical name for taglatest— so they nolonger queue behind one machine-wide lock.
Tests still needing that lock keep it with the reason inline. The subtle case: any
container built from a real
localstack/*image reference is visible to every test's"is an emulator running" check, because discovery falls back to matching a known image
exposing 4566 and
ContainerPort()is hardcoded to 4566 regardless of config. Gettingthat wrong made the suite fail ~2 runs in 9; the fixed suite ran clean 8 times
consecutively.
For the reviewer
token; macOS and Windows runners cannot run Linux containers, so 63 and 72 tests
respectively skip there. Roughly 51 tests could stop running on those legs without
anything going red. Enabling Docker elsewhere was investigated and is a dead end —
docker/setup-docker-actiondoes not work on ARM macOS, and hosted Windows runnerscannot run Linux containers at all — so the proposal is a per-platform skip budget
asserted in CI, in a follow-up.
--endpoint-urltests needcertificate trust an exec'd lstk actually reads, and Go's x509 verifier only takes
that from
SSL_CERT_FILEon Linux (darwin uses Security.framework, Windows its ownstore) — the same gate they had in Go. 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, so it is probed and skipped rather than left flaky. Both are
requirement()-checked, soLSTK_E2E_REQUIRE_ALL=1turns either into a hard failureon the CI leg that has everything. The https cases were verified on Linux in a
container before landing rather than shipped unrun.
pkg/browserinvokesrundll32rather thana shimmable script. No loss against today, since
login_test.goalready skips Windows.test-e2emust be promoted to a required check (and into the release job'sneeds:) as part of merging this, not after.logout_test.go(
TestLogoutCommandNotesWhenEmulatorStillRunning,TestLogoutCommandReportsBothEmulatorsWhenMultipleRunning) were confirmed to fail atthe merge-base too. Untouched here.
Review: please review rather than self-merge. It is large, introduces a second
toolchain, and it deletes Go tests — the strategy was not discussed beforehand.
Not linked to a Linear issue yet — happy to attach one.
Co-authored-by: Claude noreply@anthropic.com