From 9ec7fd7314c3b67cb44786faed58eb8d5397fb20 Mon Sep 17 00:00:00 2001 From: sunny-wego Date: Wed, 23 Sep 2026 23:04:35 +0800 Subject: [PATCH 1/5] feat(release): gate every CLI change on an integration tier, report each next release Integration tier (integration/): the compiled wego binary, run as its own process with real argv and real HTTP, against a Bun.serve fake on loopback that serves the API and the auth server's token endpoint. Every request and every answer is checked against contract/openapi.json, a request no scenario expects fails the scenario, and nothing can leave the machine. Fixtures are hand-written and contract-checked. A scenario also fails when skills/wego/SKILL.md names a command or flag the binary lacks. The split: a unit test never asserts on a command's stdout, stderr or exit code (scripts/unit-tier-guard.test.ts). Command-level tests moved from commands, hotels, info, index, config-command, telemetry-command and target tests to integration scenarios; parsers, settle loops and precedence rules stay unit tests. Pipelines: - ci-cli runs the integration suite as a step, so the one required check on main blocks on it. - release-cli runs it on all five built targets before anything is published, then notify-verify asks wego-ai (OIDC only, literal receiver https://api.wego.com/.well-known/internal/cli-verify) to smoke the release against staging and evaluate its skill. - next-report waits for the cli-next-smoke check, logging every look, and shows its verdict and where the cli-next-evals check is at the top of the run summary; it never turns the run red. - promote-cli opens with a read-only banner of both verdicts. scripts/next-report.ts reads only checks written by the gate App, against example payloads shared byte for byte with wego-ai. Docs: docs/release.md, CONTRIBUTING.md, README.md, integration/README.md; AGENTS.md, CLAUDE.md and the cli-tests skill (.agents/skills, linked from .claude/skills) say how tests are placed and written. CODEOWNERS owns integration/. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016LEU9d3f2jvK3SFUew6iCp --- .agents/skills/cli-tests/SKILL.md | 114 + .claude/skills | 1 + .github/CODEOWNERS | 13 + .github/workflows/ci-cli.yml | 20 +- .github/workflows/promote-cli.yml | 48 + .github/workflows/release-cli.yml | 262 +- .gitignore | 1 + AGENTS.md | 60 + CLAUDE.md | 1 + CONTRIBUTING.md | 9 + README.md | 7 + biome.jsonc | 13 +- docs/release.md | 117 +- integration/README.md | 131 + integration/auth.test.ts | 211 + integration/cli.test.ts | 139 + integration/config.test.ts | 149 + integration/contract.test.ts | 110 + integration/errors.test.ts | 119 + integration/feedback.test.ts | 105 + integration/fixtures.test.ts | 22 + integration/fixtures/airports-near.json | 24 + integration/fixtures/feedback-received.json | 5 + .../fixtures/flights-booking-link.json | 8 + integration/fixtures/flights-experience.json | 23 + .../fixtures/flights-fare-options.json | 25 + integration/fixtures/flights-results.json | 67 + integration/fixtures/flights-search-link.json | 8 + integration/fixtures/flights-search.json | 9 + integration/fixtures/flights-trip.json | 43 + integration/fixtures/holidays.json | 21 + integration/fixtures/hotels-booking-link.json | 8 + integration/fixtures/hotels-details.json | 11 + integration/fixtures/hotels-rates.json | 49 + integration/fixtures/hotels-reviews.json | 26 + integration/fixtures/hotels-rooms-create.json | 10 + .../fixtures/hotels-search-create.json | 10 + integration/fixtures/hotels-search-link.json | 8 + .../fixtures/hotels-search-results.json | 68 + integration/fixtures/places.json | 30 + integration/fixtures/schedules.json | 41 + integration/fixtures/user.json | 11 + integration/fixtures/visa-free.json | 17 + integration/flights.test.ts | 1188 ++++++ integration/harness/binary.ts | 111 + integration/harness/contract.ts | 342 ++ integration/harness/fake.ts | 341 ++ integration/harness/fixtures.ts | 61 + integration/harness/login.ts | 54 + integration/harness/preload.ts | 8 + integration/harness/scenario.ts | 56 + integration/harness/wego.ts | 199 + integration/hotels.test.ts | 1315 ++++++ integration/info.test.ts | 249 ++ integration/login-more.test.ts | 356 ++ integration/places.test.ts | 199 + integration/skill-matches-cli.test.ts | 133 + integration/skill.test.ts | 44 + integration/target.test.ts | 83 + integration/telemetry.test.ts | 131 + package.json | 1 + scripts/next-report.test.ts | 560 +++ scripts/next-report.ts | 833 ++++ .../next-report/payloads/binary-problem.json | 39 + .../payloads/evals-look-first.json | 21 + .../next-report/payloads/evals-partial.json | 22 + scripts/next-report/payloads/evals-ready.json | 21 + .../next-report/payloads/evals-skipped.json | 23 + scripts/next-report/payloads/look-first.json | 39 + scripts/next-report/payloads/malformed.json | 1 + scripts/next-report/payloads/ready.json | 39 + .../next-report/payloads/staging-problem.json | 39 + .../next-report/payloads/unknown-schema.json | 39 + scripts/plugin-git.ts | 2 +- scripts/unit-tier-guard.test.ts | 129 + scripts/workflow-lanes.test.ts | 149 + scripts/workflow-shape.test.ts | 385 +- src/api.test.ts | 21 +- src/commands.test.ts | 3736 +---------------- src/commands.ts | 8 +- src/config-command.test.ts | 166 +- src/hotels.test.ts | 2865 +------------ src/index.test.ts | 299 +- src/info.test.ts | 534 +-- src/target.test.ts | 121 +- src/telemetry-command.test.ts | 173 +- src/testing/cli-runner.ts | 110 - tsconfig.json | 2 +- 88 files changed, 9599 insertions(+), 7822 deletions(-) create mode 100644 .agents/skills/cli-tests/SKILL.md create mode 120000 .claude/skills create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 integration/README.md create mode 100644 integration/auth.test.ts create mode 100644 integration/cli.test.ts create mode 100644 integration/config.test.ts create mode 100644 integration/contract.test.ts create mode 100644 integration/errors.test.ts create mode 100644 integration/feedback.test.ts create mode 100644 integration/fixtures.test.ts create mode 100644 integration/fixtures/airports-near.json create mode 100644 integration/fixtures/feedback-received.json create mode 100644 integration/fixtures/flights-booking-link.json create mode 100644 integration/fixtures/flights-experience.json create mode 100644 integration/fixtures/flights-fare-options.json create mode 100644 integration/fixtures/flights-results.json create mode 100644 integration/fixtures/flights-search-link.json create mode 100644 integration/fixtures/flights-search.json create mode 100644 integration/fixtures/flights-trip.json create mode 100644 integration/fixtures/holidays.json create mode 100644 integration/fixtures/hotels-booking-link.json create mode 100644 integration/fixtures/hotels-details.json create mode 100644 integration/fixtures/hotels-rates.json create mode 100644 integration/fixtures/hotels-reviews.json create mode 100644 integration/fixtures/hotels-rooms-create.json create mode 100644 integration/fixtures/hotels-search-create.json create mode 100644 integration/fixtures/hotels-search-link.json create mode 100644 integration/fixtures/hotels-search-results.json create mode 100644 integration/fixtures/places.json create mode 100644 integration/fixtures/schedules.json create mode 100644 integration/fixtures/user.json create mode 100644 integration/fixtures/visa-free.json create mode 100644 integration/flights.test.ts create mode 100644 integration/harness/binary.ts create mode 100644 integration/harness/contract.ts create mode 100644 integration/harness/fake.ts create mode 100644 integration/harness/fixtures.ts create mode 100644 integration/harness/login.ts create mode 100644 integration/harness/preload.ts create mode 100644 integration/harness/scenario.ts create mode 100644 integration/harness/wego.ts create mode 100644 integration/hotels.test.ts create mode 100644 integration/info.test.ts create mode 100644 integration/login-more.test.ts create mode 100644 integration/places.test.ts create mode 100644 integration/skill-matches-cli.test.ts create mode 100644 integration/skill.test.ts create mode 100644 integration/target.test.ts create mode 100644 integration/telemetry.test.ts create mode 100644 scripts/next-report.test.ts create mode 100644 scripts/next-report.ts create mode 100644 scripts/next-report/payloads/binary-problem.json create mode 100644 scripts/next-report/payloads/evals-look-first.json create mode 100644 scripts/next-report/payloads/evals-partial.json create mode 100644 scripts/next-report/payloads/evals-ready.json create mode 100644 scripts/next-report/payloads/evals-skipped.json create mode 100644 scripts/next-report/payloads/look-first.json create mode 100644 scripts/next-report/payloads/malformed.json create mode 100644 scripts/next-report/payloads/ready.json create mode 100644 scripts/next-report/payloads/staging-problem.json create mode 100644 scripts/next-report/payloads/unknown-schema.json create mode 100644 scripts/unit-tier-guard.test.ts delete mode 100644 src/testing/cli-runner.ts diff --git a/.agents/skills/cli-tests/SKILL.md b/.agents/skills/cli-tests/SKILL.md new file mode 100644 index 0000000..bd3017a --- /dev/null +++ b/.agents/skills/cli-tests/SKILL.md @@ -0,0 +1,114 @@ +--- +name: cli-tests +description: How to test a change to the wego CLI in this repository. Use when adding or changing a command, a flag, output, an error path or an API call, when writing or moving a unit test or an integration scenario, or when a fixture or the contract check fails. +--- + +# Testing a CLI change + +The rules are in `AGENTS.md`; this is the procedure. The short version: parsers and +logic get unit tests, everything a caller sees gets an integration scenario, and +nothing here talks to a real API. + +## 1. Choose the tier + +| You are testing | Tier | Where | +|---|---|---| +| How argv becomes API-call arguments (a flag, a bound, a default) | Unit, on the `parse*Args` function or a plain function | `src/.test.ts` | +| HTTP client behaviour: query mapping, headers, retries, timeouts, tolerant parsing | Unit | `src/api.test.ts` | +| Pure logic: settle loops, formatting, precedence, PKCE, storage | Unit | beside the module | +| Exit code, stdout, stderr, what reaches the wire, files written | Integration scenario | `integration/.test.ts` | +| Install, update, uninstall, signing | Artifact checks | release workflows and `scripts/*.sh` | +| Behaviour against staging, skill quality | Not here | wego-ai's next smoke and evals | + +If a unit test needs a command's output, it is an integration scenario. Importing +`run` or a command handler (`login`, `whoami`, `places`, `info`, `feedback`, +`flights`, `hotels`, `logout`, `config`, `telemetry`) into a unit test fails +`scripts/unit-tier-guard.test.ts`. If the logic you need is inside a handler, +export it as a plain function and unit-test that. + +## 2. Write the scenario + +Read `integration/README.md` once, then copy the shape of a neighbouring file. + +```ts +import { expect, it } from "bun:test"; +import { readFixture, route } from "./harness/fixtures"; +import { useScenario } from "./harness/scenario"; +import { json, signIn } from "./harness/wego"; + +const s = useScenario(); + +it("prints the caller's identity", async () => { + signIn(s.home); // as a previous login left it + const fake = s.fake({ routes: [route("user")] }); + const result = await s.run(["whoami"]); + + expect(result.code).toBe(0); + expect(json(result).sub).toBe(readFixture("user").body.sub); + expect(fake.requests("getCurrentUser")[0]?.token).toBe("access-1"); +}); +``` + +- Assert on `result.code`, `result.out`, `result.err`, `fake.seen` / + `fake.requests(op)` (path, query, body, token), and files in `s.home`. +- Values from the API are read from the fixture; values the scenario sets (argv, + settings) may be written out. +- A route's answers are served in order and the last repeats: a settling search + needs `route(first, then)`, not a read count. +- Logged-out, expired and refreshed sessions: `signIn(s.home, {...})` plus the + fake's `accept` and `refresh` options (see `integration/auth.test.ts`). +- Faults: `{ fault: "non-json" }`, `{ fault: "truncated" }`, `startDropper()`. +- The test fails by itself if any request or answer breaks the contract, or a + request reaches a route nobody declared. Do not assert that separately. +- Keep a scenario under a few seconds. A path that needs a long wait is tested + as a plain function; keep one fast scenario for it end to end. + +### When a command or flag changes + +`skills/wego/SKILL.md` tells the user's agent which commands to run, and it ships +inside the binary. `integration/skill-matches-cli.test.ts` fails when the skill +names a command that no longer answers `--help`, or a `--flag` its help does not +list. Rename a flag, and update the skill in the same change. Whether the skill +still leads the agent well is the evals' question, answered per release in wego-ai. + +## 3. Get the fixture + +There is no recorder or generator. Take the first that fits: + +1. **Reuse** an existing file in `integration/fixtures/`. +2. **Edit a copy** in the scenario: `answer("flights-results", (b) => ({ ...b, results: [] }))`. +3. **Errors inline**: `problem(404, "not_found")`. Codes are the contract's closed + set: `validation_failed`, `invalid_token`, `insufficient_scope`, `not_found`, + `rates_require_hotel_search`, `rate_limited`, `bad_gateway`, + `upstream_unavailable`, `upstream_rate_limited`, `internal_error`. A status the + operation does not declare is rejected: the contract declares no 410 and no + 500, so use 502 `bad_gateway` for a server failure. +4. **New file**, only for an operation nothing answers yet: + `{ "op": "", "status": 200, "body": { … } }`. Then run + `bun test --preload ./integration/harness/preload.ts ./integration/fixtures.test.ts` + and fix each path it names until it passes. A few list items, not a page. + Optionally base the body on a real staging answer with `curl` (the command is + in `integration/README.md`), never on `wego` output, and replace the account's + identity with `integration@example.com`, `Integration Test`, `1001`. + +If the contract rejects a fixture, fix the fixture. Never loosen +`integration/harness/contract.ts` or `fake.ts` to make a scenario pass. + +## 4. When the API changed + +1. Refresh the contract: `bun run api-contract:refresh` (see `CONTRIBUTING.md`, + "When the API changes"), committed on its own. +2. `bun run typecheck`: the static contract checks name what the CLI's types no + longer match. +3. `bun run test:integration`: the fixtures the new contract rejects fail by name + in `fixtures.test.ts`; fix them, then the scenarios. + +## 5. Before you finish + +```sh +bun run check +bun run test:integration +``` + +Both clean. In the pull request, say which tier each new test is in, and for any +test you removed, where its coverage went. diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 67aad8f..ab1ae28 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -175,6 +175,19 @@ src/skill-embed.ts @sunny-wego @yeouchien-wego @chuyeowego # redirected token. contract/openapi.json @sunny-wego @yeouchien-wego @chuyeowego +# THE INTEGRATION TIER. Unlike the unit tests below, it is a release gate: +# `release` needs `integration ()` on all five targets, so what this +# directory accepts is what may be published. Two things in it are a small diff +# away from making that gate vacuous or unsafe: +# +# - `harness/contract.ts` and `harness/fake.ts` hold every exchange to the +# contract. Relaxing the validator, or letting an unexpected request pass, +# turns the tier back into a fake that can only fail when it disagrees with +# itself (#1328), and every scenario still reads green. +# - `fixtures/` is what the fake answers with. A fixture copied from a real +# response must not carry the account it came from; review is the check. +integration/ @sunny-wego @yeouchien-wego @chuyeowego + # Deliberately NOT owned, so the omissions read as decisions rather than gaps: # # - `src/commands.ts` and `src/index.ts` wire the login flow together, but they diff --git a/.github/workflows/ci-cli.yml b/.github/workflows/ci-cli.yml index 36b722d..eb7c742 100644 --- a/.github/workflows/ci-cli.yml +++ b/.github/workflows/ci-cli.yml @@ -1,6 +1,7 @@ name: ci-cli -# Lint, typecheck and unit tests. Runs on every pull request and every push to main. +# Lint, typecheck, unit tests and the integration suite. Runs on every pull +# request and every push to main. # # The job name `ci-cli` is the required status check on `main`. Do not rename it # without a ruleset change. There is no path filter: a filtered workflow does not @@ -231,3 +232,20 @@ jobs: # so it would audit the same commit concurrently. - name: Audit the installed dependency tree (bun.lock) run: bun audit --audit-level=high + + # The compiled `wego` binary against a local fake API (`integration/`). The + # unit tests above run the source; this runs what `bun build --compile` makes + # of it, which is what ships. Hermetic: the fake is a `Bun.serve` on loopback + # and every other request is refused, so a red here is this change, never the + # network. + # + # A step of `ci-cli`, not a job of its own, so the one required check on + # `main` already blocks on it and no ruleset change is needed. linux-x64 only: + # the suite compiles the host binary when `WEGO_INTEGRATION_BINARY` is unset. + # The other four targets run in `release-cli.yml`, against the binaries that + # release built. + - name: Integration tests (compiled binary, fake API) + env: + # The suite drives a production build. Do not post CI runs as product usage. + WEGO_CLI_TELEMETRY: "0" + run: bun run test:integration diff --git a/.github/workflows/promote-cli.yml b/.github/workflows/promote-cli.yml index 40aaf91..d992cdb 100644 --- a/.github/workflows/promote-cli.yml +++ b/.github/workflows/promote-cli.yml @@ -29,6 +29,54 @@ permissions: contents: read jobs: + # wego-ai's verdicts for the tag, the smoke (`cli-next-smoke`) and the skill + # evals (`cli-next-evals`), one line each with a link, at the top of this run's + # summary, so the person promoting sees them beside the gates. The evals can + # take hours, so this is often the first place their result is shown. + # `release-cli.yml`'s `next-report` shows the smoke's full table. + # + # READ-ONLY AND GATES NOTHING. No job needs it, it is `continue-on-error`, and + # the script exits 0 on every state it knows, so a missing, running or bad + # report never holds a promote back and never lets one through. The promoter + # decides; the banner only makes sure the verdict was in front of them. + # + # One look, no polling: "No report yet, started N min ago" is a true answer, and + # a promote that waited behind a smoke would be a gate by another name. + # + # `checks: read` and `contents: read`, nothing else: no environment, no secret, + # no `id-token`. It checks out `main` (the dispatch ref), not the tag: a tag cut + # before `scripts/next-report.ts` existed has no script to run. The script asks + # the API for the tag's commit and reads only a check written by wego-ai's App. + next-report: + name: next report for ${{ inputs.tag }} + runs-on: ubuntu-latest + timeout-minutes: 5 + continue-on-error: true + permissions: + contents: read + checks: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: ./.github/actions/setup-bun + + - name: Show the next report banner + continue-on-error: true + env: + TAG: ${{ inputs.tag }} + GITHUB_TOKEN: ${{ github.token }} + run: | + set +e + bun run scripts/next-report.ts --banner + code=$? + if [ "$code" -ne 0 ]; then + echo "● No report: next-report exited $code" >> "$GITHUB_STEP_SUMMARY" + echo "::warning::next-report exited $code before writing a banner." + fi + exit 0 + # The human gate. # # Not an environment with required reviewers - and no longer because we cannot diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 4de0a13..5e8d4dd 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -20,9 +20,25 @@ name: Release (cli) # wego-darwin-arm64, wego-darwin-x64, wego-linux-arm64, wego-linux-x64, # wego-windows-x64.exe, each also gzipped, plus VERSION and SHA256SUMS.txt. # -# Jobs: prepare, build, sign, leave-macos, release, replace-macos. `sign` is the -# only job with `id-token: write`. `release` is the only job with the store token. -# `scripts/workflow-shape.test.ts` asserts this split. +# Jobs: prepare, build, sign, leave-macos, integration, release, notify-verify, +# next-report, announce, replace-macos. `sign` and `notify-verify` are the only +# jobs with `id-token: write`, and they hold it for unrelated reasons: `sign` +# exchanges the token for a Fulcio certificate, `notify-verify` presents it as an +# identity to a receiver that writes a check run back. `release` is the only job +# with the store token, and neither of the two can reach it. +# `scripts/workflow-shape.test.ts` asserts this split and names the exact set. +# +# `notify-verify` IS ALLOWED TO GO RED. Nothing that publishes needs it, and a red +# there means "verification was not requested", never "the release failed". The +# bytes are published and `cli/next` has moved by the time it runs. It carries no +# `continue-on-error` on purpose: a request that silently did not happen is worse +# than a red job, because the promote banner would then show "No report" for a +# reason nobody saw. +# +# `next-report` IS NEVER RED. It waits for wego-ai's `cli-next-smoke` check, looks +# once at its `cli-next-evals` check, and writes both into this run's summary, for +# a person to read before promoting. It is advice, so every outcome, including +# "no report", is a green job. # # Signing identity (the Fulcio SAN): # https://github.com/wego/cli/.github/workflows/release-cli.yml@refs/tags/vX.Y.Z @@ -43,7 +59,8 @@ on: # Floor for the jobs without their own `permissions:` block. `prepare`, `build` and # the macOS jobs only read. A job-level block replaces this one. `id-token` is -# granted on `sign` only; a workflow-level grant would reach every job. +# granted on `sign` and `notify-verify` only; a workflow-level grant would reach +# every job. permissions: contents: read @@ -303,10 +320,88 @@ jobs: asset: wego-darwin-arm64 version: ${{ needs.prepare.outputs.version }} + # The integration suite (`integration/`) against every binary this release + # built, each on a runner that can execute it. `release` needs this job, so a + # target that fails blocks publication for all of them: `cli/next` serves one + # manifest, and a release with a broken platform in it has no backward path. + # + # `leave-macos` and `release` prove that a build can update and be replaced; + # this proves that the commands themselves work once compiled, on every target + # and not only the two those jobs run on. It is hermetic: the fake API is a + # `Bun.serve` on loopback, so nothing here reaches production or staging, and a + # red is this build. + # + # `WEGO_INTEGRATION_BINARY` names the artifact `build` produced. Without it the + # suite compiles a host binary of its own, which would test the tree, not the + # bytes about to be published. + # + # `fail-fast: false`: one red target should not cancel the others, or the run + # reports one failure where there may be three. + # + # darwin-x64 runs on `macos-15-intel`, GitHub's Intel label since `macos-13` was + # retired. No `environment:` and no `id-token`. + integration: + name: integration (${{ matrix.target }}) + needs: [prepare, build] + strategy: + fail-fast: false + matrix: + include: + - target: linux-x64 + runner: ubuntu-latest + asset: wego-linux-x64 + - target: linux-arm64 + runner: ubuntu-24.04-arm + asset: wego-linux-arm64 + - target: darwin-arm64 + runner: macos-latest + asset: wego-darwin-arm64 + - target: darwin-x64 + runner: macos-15-intel + asset: wego-darwin-x64 + - target: windows-x64 + runner: windows-latest + asset: wego-windows-x64.exe + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + permissions: + contents: read + env: + WEGO_CLI_TELEMETRY: "0" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + + - uses: ./.github/actions/setup-bun + + - name: Download built binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-binaries + path: dist + + # An artifact does not keep the executable bit. Windows has none to restore. + - name: Make the binary executable + if: runner.os != 'Windows' + env: + ASSET: ${{ matrix.asset }} + run: chmod +x "dist/$ASSET" + + # `github.workspace`, not `$PWD`: on Windows the step's bash reports a + # `/d/a/...` path that Bun cannot open, and the runner's own path is one + # every platform's Bun reads. + - name: Integration tests (${{ matrix.asset }}) + shell: bash + env: + WEGO_INTEGRATION_BINARY: ${{ github.workspace }}/dist/${{ matrix.asset }} + run: bun run test:integration + # The only job that moves a ring. release: name: Publish CLI binaries to cli/next - needs: [prepare, build, sign, leave-macos] + needs: [prepare, build, sign, leave-macos, integration] # One group: every release writes `cli/next`. Do not cancel a run in progress: # a cancel between the binaries and the manifest leaves the ring half-moved. concurrency: @@ -522,6 +617,159 @@ jobs: # No skill steps. The SKILL.md ships inside the binary. + # Ask the private receiver to smoke this release against staging. + # + # Hosted runners cannot reach staging, so wego-ai runs the smoke, against the + # bytes `cli/next` now serves. It writes the answer back as a `cli-next-smoke` + # check run on this commit, as its GitHub App (id 4987365), and `next-report` + # below reads it into this run's summary. Nothing gates on it. + # + # THIS REPOSITORY HOLDS NO CREDENTIAL FOR ANY OF IT. What travels is a GitHub + # Actions OIDC token, which GitHub signs and this job merely asks for; the + # receiver decides what it is worth by reading the claims. The identity it admits + # for this lane is: `repository == wego/cli`, `event_name == push`, + # `ref == refs/tags/`, `sha == body.sha`, and a `job_workflow_ref` naming + # THIS file at THIS tag. A fork, a branch run, or a call from another workflow + # produces a token that does not match, and the receiver answers 403. + # + # NO SWITCH ON THIS SIDE. The job always asks; the receiver's own switch, in + # wego-ai, decides. Switched off, it answers 404, read below as "not verified". + # + # `needs: [prepare, release]` is the whole dependency set, and `release` is on it + # because the smoke is of what `cli/next` now serves. That is also the only edge + # to a job holding the store token, and this job cannot reach the token through + # it: `needs` passes outputs, not secrets, and this job declares no + # `environment:`, which is where the token lives. + notify-verify: + name: Request release verification + needs: [prepare, release] + runs-on: ubuntu-latest + timeout-minutes: 5 + # The receiver's answer, for `next-report`: the HTTP code, or `error` when the + # request never got one. Written first as `error` so a step that dies before + # the answer still leaves a value to read; a later write of the same key wins. + outputs: + status: ${{ steps.ask.outputs.status }} + # NO `environment:`, ever. This job holds `id-token: write`, so every step of it + # can mint a token under an identity a receiver trusts; an environment here + # would put the store token in the same job, which is the pairing + # `workflow-shape.test.ts` exists to refuse. + # + # `id-token: write` alone. No `contents: read` either: there is no checkout, + # and a job-level block replaces the workflow floor rather than adding to it. + permissions: + id-token: write + steps: + # No checkout: nothing in the tree is read, so there is no repository code + # beside the OIDC token. Same reasoning as `sign`. + # + # Everything the receiver is told arrives through `env:`, never interpolated + # into the script. The tag comes from `prepare`, which parsed it from the ref, + # and the sha is the commit that started this run, the same one the check run + # will be written onto. + - name: Present this run's identity and ask for verification + id: ask + env: + TAG: ${{ needs.prepare.outputs.tag }} + SHA: ${{ github.sha }} + # A literal, not a variable: one production receiver, and a setting here + # would be a second switch beside the receiver's own. Under `/.well-known/` + # because that prefix on this host is served by the API. + RECEIVER: https://api.wego.com/.well-known/internal/cli-verify + run: | + set -euo pipefail + echo "status=error" >> "$GITHUB_OUTPUT" + JWT=$(curl -sS --retry 3 -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=wego-cli-verify" | jq -r .value) + echo "::add-mask::$JWT" + # The status is read rather than left to `--fail-with-body`, because 409 is + # not a failure here. The receiver spends the token's `jti` for 15 minutes, + # so a `--retry` after a LOST RESPONSE replays a request it already + # dispatched and is answered 409. Failing on that would report "verification + # not requested" for a verification that was requested and is running. + # + # `--retry` therefore covers transport only: without `--fail`, curl exits 0 + # on any HTTP status, so nothing but a connection-level error is retried. + # + # The root cause is that `--retry` REUSES the JWT, so every attempt carries + # the same `jti` and a 409 cannot be told from a first attempt's. Minting a + # fresh token per attempt would remove the 409 entirely and make one worth + # failing on. Not done: it buys a distinction nobody acts on, and the + # receiver's guard is the thing keeping a replay from dispatching twice. + # + # `jq -c` prints exactly `{"tag":"vX.Y.Z","sha":"<40 hex>"}`, and `--arg` + # makes both values jq data rather than jq program text. + BODY="$RUNNER_TEMP/cli-verify.out" + CODE=$(curl -sS --retry 3 -o "$BODY" -w '%{http_code}' -X POST "$RECEIVER" \ + -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \ + -d "$(jq -cn --arg tag "$TAG" --arg sha "$SHA" '{tag:$tag, sha:$sha}')") + echo "status=$CODE" >> "$GITHUB_OUTPUT" + # Bounded: this is another system's body landing in a PUBLIC log. + head -c 500 "$BODY"; echo + case "$CODE" in + 202) echo "::notice::verification started for $TAG (202). next-report shows the result in this run's summary." ;; + 409) echo "::notice::verification had already started for $TAG (409). A retry replayed a token the receiver had spent, so the first attempt dispatched and the smoke is running." ;; + # Switched off is a state, not a fault: a notice, so it reads as + # "not verified" without a red release. + 404) echo "::notice::the receiver is switched off (404), so $TAG was not verified. Re-run this job once it is on." ;; + *) echo "::error::the receiver refused the request for $TAG (HTTP $CODE). $TAG is published and cli/next has moved. This job asks for verification and nothing that publishes depends on it. Re-run this job once the reason above is fixed." + exit 1 ;; + esac + + # wego-ai's answer, in this run's summary: the smoke's verdict, a link to the + # private run, one row per part beside the previous release, and one line on the + # skill evals. Rendered by `scripts/next-report.ts`, which reads only the + # `cli-next-smoke` and `cli-next-evals` checks written by wego-ai's App (id + # 4987365) and ignores those names from anyone else. It waits for the smoke + # only; the evals can take hours, and the promote banner shows them. + # + # Runs whenever `notify-verify` ran, red included, because the report says why + # there is none: a 404 and a refusal are written at once, with no wait. Skipped + # when `notify-verify` was, which is every run that did not publish. After a + # 202 or 409 it looks every 30 s, for up to 45 min. + # + # NEVER RED, three ways over: the script exits 0 on every state it knows, the + # step turns a crash into a summary line and `exit 0`, and the job is + # `continue-on-error`. Nothing needs it. A red here would read as a failed + # release and send someone looking for a fault in the bytes. + # + # `checks: read` for the API read and `contents: read` for the checkout, and + # nothing else: no environment, no secret, no `id-token`. `github.token` is the + # run's own token, scoped to those two grants. + next-report: + name: next report + needs: [prepare, notify-verify] + if: always() && (needs.notify-verify.result == 'success' || needs.notify-verify.result == 'failure') + continue-on-error: true + runs-on: ubuntu-latest + timeout-minutes: 55 + permissions: + contents: read + checks: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + + - uses: ./.github/actions/setup-bun + + - name: Wait for wego-ai's report and show it + env: + TAG: ${{ needs.prepare.outputs.tag }} + SHA: ${{ github.sha }} + NOTIFY_STATUS: ${{ needs.notify-verify.outputs.status }} + GITHUB_TOKEN: ${{ github.token }} + run: | + set +e + bun run scripts/next-report.ts + code=$? + if [ "$code" -ne 0 ]; then + echo "● No report: next-report exited $code" >> "$GITHUB_STEP_SUMMARY" + echo "::warning::next-report exited $code before writing a report." + fi + exit 0 + # The GitHub Release, split out of `release` so no job holds both the store token # and repository write. # @@ -633,8 +881,8 @@ jobs: # # One job, not one per check: the runner boot, checkout and install dominate the # cost; each check takes seconds. No `environment:` and no `id-token`. - # `macos-latest` is Apple silicon. darwin-x64, linux-arm64 and Windows are built - # and not run. + # `macos-latest` is Apple silicon. darwin-x64, linux-arm64 and Windows are not + # replace-tested; `integration` runs the commands on all five before publication. replace-macos: name: Prove the replace path on macOS needs: [prepare, release] diff --git a/.gitignore b/.gitignore index 50692d1..11f45aa 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ node_modules/ # Claude Code — user-local settings .claude/settings.local.json .claude/scheduled_tasks.lock +.claude/worktrees/ !.claude/settings.json # OS / IDE diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1412f43 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# AGENTS.md + +Instructions for coding agents working in this repository. People: the same +rules are in `CONTRIBUTING.md`, which is the longer version. + +## Commands + +```sh +bun install +bun run check # lint, typecheck, unit tests: must be clean +bun run test:integration # the compiled binary against the contract-checked fake +bun run format # biome, writing every safe fix +``` + +Bun only, never npm, node or yarn. Never commit with `--no-verify`. + +## Tests: one test, one tier + +| Tier | Proves | Lives in | Runs | +|---|---|---|---| +| Unit | Each piece of code across its edge cases: parsers (argv to API arguments), the HTTP client, pure logic | `src/*.test.ts`, `scripts/*.test.ts` | Every PR, blocking | +| Integration | The compiled binary works: exit codes, stdout, stderr, the request on the wire, files written | `integration/` | Every PR and every release on 5 targets, blocking | +| Artifact checks | What ships is signed, installable, self-updating | the release and promote workflows | Release and promote, blocking | +| Next smoke and evals | The published binary against staging, and how well its skill performs | wego-ai | Every release, report only | + +Rules that decide where a test goes: + +- **A unit test never asserts on a command's stdout, stderr or exit code.** That is + the integration tier's job. `scripts/unit-tier-guard.test.ts` enforces it by + refusing a unit test that imports a command entry point. +- **Tests here never touch the network, staging, production or a secret.** The + integration fake is on loopback and every other request is refused. +- **Only deterministic checks block.** Anything that depends on staging's data or + timing reports, and lives in wego-ai. +- **Persona and skill evals never enter this repository**: no cases, scores, + transcripts or harness code. They live in wego-ai only. +- **The contract is the boundary.** The CLI is tested against + `contract/openapi.json`, never against a live API. When the API changes, the + contract is refreshed first (`CONTRIBUTING.md`, "When the API changes"). +- **Add a test only when it gives confidence nothing else gives.** Delete a + duplicate rather than keep it. A slow timing path (a settle budget, a timeout) + is tested as a plain function, with one fast scenario end to end. + +The step-by-step for writing tests is the `cli-tests` skill +(`.agents/skills/cli-tests/SKILL.md`). + +## Where else to look + +- `CONTRIBUTING.md`: setup, hooks, signed commits, commit messages, API changes. +- `integration/README.md`: the harness, scenarios and fixtures. +- `docs/release.md`: the rings, the release gates, the next report. +- `SECURITY.md`: what is in scope. + +## Do not + +- Edit `skills/`: it is the agent skill compiled into the shipped binary, not + instructions for working here. Development skills live in `.agents/skills/`. +- Write an em-dash in anything a user reads. +- Weaken `integration/harness/contract.ts` or `fake.ts` to make a scenario pass: + fix the fixture or the CLI. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57b2ab1..3262046 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -246,8 +246,17 @@ bun run lint # biome check: formatting, lint rules, import order bun run format # the same, writing every safe fix bun run typecheck # tsc --noEmit bun test # unit tests +bun run test:integration # the compiled binary against a contract-checked fake ``` +The integration tier builds the binary and runs every command as its own process +against a local fake of the API, which is held to `contract/openapi.json` in both +directions. It needs no network and no account, takes about a minute, and +`ci-cli` runs it on every pull request. A unit test never asserts on +a command's stdout, stderr or exit code; a scenario in `integration/` does +(`scripts/unit-tier-guard.test.ts` holds that line). `integration/README.md` says +how to write one. + All of it must be clean. `ci-cli` runs the same checks — plus gitleaks over every ref, shellcheck, `bun audit`, and a Conventional Commits check on your pull request **title** — and is a required check on `main`. diff --git a/README.md b/README.md index ec45b26..1daa7a9 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,13 @@ Commits on `main`. Merging the open release PR is what releases: the version is computed from the commits, the tag is written, and the tag starts the build and publish. Nobody types a version. +Nothing is published until every built target passes the integration tier +(`integration/`: the compiled binary against a local fake held to the API's +contract). Once `cli/next` serves the new version, wego-ai smokes it against +staging and evaluates its skill, and the verdict appears at the top of the release +run, and again when promoting to `cli/stable`. See +[docs/release.md](docs/release.md#the-next-report). + ## License Copyright 2026 Wego. diff --git a/biome.jsonc b/biome.jsonc index f1aac9d..a473b4d 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -11,7 +11,18 @@ // `api-types:generate` and is not committed. The vendored contract itself // IS formatted here like everything else - `api-contract:refresh` runs // biome over it after writing, so a refresh never leaves this red. - "includes": ["**", "!node_modules", "!dist", "!src/api-types.d.ts"] + // + // `scripts/next-report/payloads/` is the cross-repository interface with + // wego-ai, which tests its writer against the same files byte for byte. They + // are not this repository's to reformat, and `malformed.json` is invalid on + // purpose. + "includes": [ + "**", + "!node_modules", + "!dist", + "!src/api-types.d.ts", + "!scripts/next-report/payloads" + ] }, "formatter": { "enabled": true, diff --git a/docs/release.md b/docs/release.md index a62ce4a..8b976a1 100644 --- a/docs/release.md +++ b/docs/release.md @@ -49,17 +49,25 @@ to the tag is that merge. ### 2. `release-cli.yml`, on the tag -Fires on `push: tags: ['v*']`. Seven jobs: +Fires on `push: tags: ['v*']`. Ten jobs: | Job | Runner | What it is for | |---|---|---| | `prepare` | ubuntu | Refuses a tag that is not on `main`, then lint, format, typecheck and unit tests | | `build` | ubuntu | One job, not a matrix: `bun build --compile` cross-compiles all five targets in it. **No `environment:`**, deliberately, so it cannot reach the store token | -| `sign` | ubuntu | Signs `SHA256SUMS.txt` with keyless cosign. **No `environment:`** either, and `id-token: write` is granted here and nowhere else. The job that can sign cannot write the store, and the job that writes the store cannot sign | +| `integration ()` | one per target | The integration tier (`integration/`) against the binary `build` produced for that target, on that target's own runner: linux x64 and arm64, macOS arm64 and Intel, Windows. `release` **needs** all five, so a binary that fails its scenarios is never published | +| `sign` | ubuntu | Signs `SHA256SUMS.txt` with keyless cosign. **No `environment:`** either. The job that can sign cannot write the store, and the job that writes the store cannot sign | | `leave-macos` | macOS | Runs the pre-publication checks against `wego-darwin-arm64`. `release` **needs** it, so darwin gates publication rather than reporting after it | | `release` | ubuntu | `environment: release`, `concurrency: group: ring-next` with `cancel-in-progress: false`, because a cancel mid-copy is a half-moved ring. The only job that advances a ring. **`contents: read`** – it holds the store token, so it must not also hold repository write | | `announce` | ubuntu | Creates the GitHub Release and attaches `SHA256SUMS.txt`. **`contents: write` and no `environment:`** – the mirror image of `release`, and the reason the two are separate jobs | | `replace-macos` | macOS | The darwin half of the post-pointer replace proof. `needs: release`, because the checks in it read the ring | +| `notify-verify` | ubuntu | Asks wego-ai to smoke and evaluate this release against staging. `id-token: write` and nothing else: no `environment:`, no secret, no variable. See [The next report](#the-next-report) | +| `next-report` | ubuntu | Waits for wego-ai's answer and writes it at the top of this run's summary. `checks: read` and `contents: read`; `continue-on-error`, so it never turns the run red | + +`id-token: write` is granted to exactly three jobs in two files: `sign` in +`edge-cli.yml`, and `sign` and `notify-verify` here. +`scripts/workflow-shape.test.ts` names that set and fails on any other job +holding it, at any level, in any workflow file. The commit-point order inside `release` matters and is fixed: **binaries and the `COMMIT` sidecar first, then the record, then `SHA256SUMS.txt`, then a @@ -120,6 +128,106 @@ a rollback does not reach a machine whose binary cannot take one. --- +## The next report + +Every gate above is about the artifact, and the integration tier is about the +binary against the API's contract. None of them runs the binary against a real +backend, because hosted runners cannot reach one and there is no production login +for CI. That run happens in wego-ai, against staging, and its answer comes back +into the release run before anyone promotes. + +It **reports; it never blocks.** Promoting is a person's decision, made after +reading it. + +### How it works + +1. Once `cli/next` serves the new tag, `notify-verify` posts `{"tag", "sha"}` to + `https://api.wego.com/.well-known/internal/cli-verify` with this run's GitHub OIDC token + (audience `wego-cli-verify`). +2. The receiver accepts exactly one identity: a `push` of `refs/tags/` from + `wego/cli/.github/workflows/release-cli.yml@refs/tags/`, with the body's + `sha`. Anything else is a `400`, `401` or `403`. It then starts wego-ai's + `cli-next-smoke.yml` for that tag. +3. That workflow writes two check runs on the tag's commit, as the gate App (id + `4987365`, `checks: write` on this repository only): + - **`cli-next-smoke`**: it installs the published binary after verifying its + signed record, smokes it against staging, and completes the check with a + verdict. Minutes. + - **`cli-next-evals`**: then, if the smoke passed, the skill evals on the tag's + skill and binary, against the previous version's scores. Up to hours. Which + sets run depends on what changed since the last evaluated release (below). +4. `next-report` waits for the smoke check, up to 45 minutes, logging every look, + and writes its verdict at the top of this run's summary, with one line on + where the evals are. The promote banner shows both. + +**This repository holds no credential for any of it, and no setting.** The OIDC +token is signed by GitHub; nothing here can write a check or widen what the +receiver allows. The eval material (cases, scores, transcripts) never leaves +wego-ai; the check carries only the verdict and a part table. + +`notify-verify` reads the receiver's answer by status: + +| Status | Means | The job | +|---|---|---| +| `202` | Started | passes | +| `409` | A retry replayed a token the receiver had already spent, so the first attempt started it | passes, with a notice | +| `404` | The receiver is switched off | passes, with a notice: **this release was not verified** | +| anything else | Refused | **fails**. Nothing depends on it, the release is already published, but a refused request is a broken pipeline and should be loud | + +### Reading it + +The verdict comes first, then only what changed against the previous release. +The smoke (`cli-next-smoke`): + +| Verdict | Check conclusion | Means | +|---|---|---| +| **✓ Ready** | `success` | Every part matched or improved on the previous version | +| **⚠ Look first** | `neutral` | Something changed past its threshold: a search round trip was not reached twice in a row, or startup is noticeably slower | +| **✗ Staging problem** | `failure` | A must-pass step failed and staging's own health check was failing too: probably not this release | +| **✗ Binary problem** | `failure` | A must-pass step failed while staging was healthy, or the binary is not the tag | +| **● No report** | none | The receiver is switched off, the request was refused, or wego-ai did not start within 10 minutes | +| **● No report yet** | `in_progress` | Still running after 45 minutes; the promote banner shows it once it arrives | + +The skill evals (`cli-next-evals`), one line under the smoke's verdict: + +| Line | Check conclusion | Means | +|---|---|---| +| **Skill evals: ✓ Ready** | `success` | Every set that ran held against its baseline | +| **Skill evals: ⚠ Look first** | `neutral` | A set scored lower than its baseline: read the private report before promoting | +| **Skill evals: ● Skipped** | `skipped` | Nothing the evals measure changed, or they were turned off for this run; the reason is in the line | +| **● Skill evals: running** / **not started yet** | `in_progress` / none | Still going; the promote banner shows the result once it lands | + +Only a check written by the gate App is read. `cli-next-smoke` is a name, and +anything that can write checks on this repository could use it. The check's link +points at the private wego-ai run, which org members can open. + +What the smoke runs: the install with its signed record; that the binary is the +tag (its version, the commit its signed build record names, and the embedded skill equal to the tag's +`skills/wego/SKILL.md`); `whoami`, `places` and the four `info` commands; three +real error responses; flights and hotels round trips to a booking link, judged +tolerantly because staging's inventory varies; and timings, report-only. + +Which evals run is wego-ai's decision, from what changed since the last release +that was evaluated: + +| The release changed | Frozen regression set | Persona subset | +|---|---|---| +| `skills/` | runs | runs | +| any command's `--help` text | runs | runs | +| other `src/` | runs | skipped | +| neither (docs, CI, tests) | skipped | skipped | + +A skipped set says so in the report, with the reason and the version its baseline +comes from. wego-ai's workflow can force a full run or none. + +### Asking again + +Re-run the **`notify-verify`** job alone, then **`next-report`**. Nothing else +depends on either, so nothing is re-published, and the receiver accepts the re-run: +same workflow, same tag, a fresh token. GitHub allows re-runs for 30 days. + +--- + ## An edge build `edge-cli.yml` runs on every push to `main` — and on nothing else. It has no @@ -146,7 +254,10 @@ It has no rollback mode. A promote only ever advances `cli/stable` onto what `cli/next` serves, so every gate below may assume `next == tag` unconditionally. Putting `stable` back on an earlier release is `rollback-cli.yml`. -Two jobs: +Three jobs. The first, **`next-report`**, only reads: it prints the tag's next +report as a banner at the top of the run (`checks: read`, `continue-on-error`, +needed by nothing), so the verdict is in front of whoever approves. It gates +nothing. **`approve`** holds no secrets and no variables, and `promote` cannot begin until it passes. It refuses unless *both* the dispatcher and whoever started this diff --git a/integration/README.md b/integration/README.md new file mode 100644 index 0000000..1398f38 --- /dev/null +++ b/integration/README.md @@ -0,0 +1,131 @@ +# integration/ + +The CLI's integration tier: the **compiled `wego` binary**, run as its own process +with real argv and real HTTP, against a fake API on a loopback port. + +```sh +bun run test:integration # compiles the host binary +WEGO_INTEGRATION_BINARY=dist/wego-linux-x64 bun run test:integration # drives a built one +``` + +It needs no network, no account and no staging access, so it runs on every pull +request (as a step of `ci-cli`) and, in the release run, on each of the five built +targets on its own runner before anything is published (`integration ()`). +Both block. + +## What it proves + +That the binary a user installs does what an agent relies on: the exit code for +each outcome, JSON and only JSON on stdout, prose on stderr, the request each +command sends, the credentials and settings it writes, and the login it completes. +No in-process test can see any of that: the stream split and the exit code only +exist at the process boundary. + +## Why the fake can be trusted + +A fake that only answers what its author expected can only fail when it disagrees +with itself (#1328). This one is held to the API's published contract instead: + +- **Every request** the binary sends is matched to an operation in + `contract/openapi.json` and checked against it: path parameters, query parameters + (an undeclared one fails), and the JSON body. +- **Every answer** the fake sends is checked against the same operation's declared + status, media type and schema before the scenario can pass. +- **A request no scenario expects** fails the scenario, by name. + +So a scenario passes only if the CLI and the API's contract agree. A contract +refresh that the CLI does not keep up with fails here, and so does a fixture that +no longer matches the contract (`fixtures.test.ts`). + +## Layout + +| Path | What | +|---|---| +| `harness/binary.ts` | Compiles the host binary, or copies the one `WEGO_INTEGRATION_BINARY` names, as `wego` | +| `harness/fake.ts` | The fake: the API, the auth server's token endpoint, faults | +| `harness/contract.ts` | The validator for the slice of JSON Schema the contract uses | +| `harness/wego.ts` | Runs the binary in a fresh home with an environment built from nothing | +| `harness/scenario.ts` | Per-test setup, and the check that nothing broke the contract | +| `harness/login.ts` | Plays the browser's part in `wego login` | +| `harness/fixtures.ts` | Reads fixtures; `route()` and `answer()` build a route's answers from them | +| `harness/preload.ts` | Resolves the binary once, before any scenario loads | +| `fixtures/*.json` | One API answer each, naming its `operationId` | +| `*.test.ts` | Scenarios, one file per area | + +## Writing a scenario + +```ts +const s = useScenario(); + +it("prints the caller's identity", async () => { + signIn(s.home); // as a previous login left it + const fake = s.fake({ routes: [route("user")] }); // answers getCurrentUser from fixtures/user.json + const result = await s.run(["whoami"]); + + expect(result.code).toBe(0); + expect(json(result).sub).toBe(readFixture("user").body.sub); + expect(fake.requests("getCurrentUser")[0]?.token).toBe("access-1"); +}); +``` + +- Assert on what a caller sees: `result.code`, `result.out`, `result.err`, what the + fake received (`fake.seen`), and files in `s.home`. +- A value that comes from the API is read from the fixture, never typed into the + scenario, so editing a fixture cannot break it. A value the scenario sets (argv, + settings) may be written out. +- A route's answers are served in order and the last one repeats, so a command that + re-reads until a search settles needs no read count. +- `problem(status, code)` builds an error answer; the contract's `code` values are + a closed set. + +A unit test never asserts on a command's stdout, stderr or exit code; that is this +tier's job. `scripts/unit-tier-guard.test.ts` enforces it. + +## Getting a fixture + +There is no recorder and no generator: a fixture is a small JSON file you write, +and the contract check is what keeps it honest. Take the first of these that fits. + +1. **Reuse one.** `route("flights-results")` answers every scenario that needs a + results page. +2. **Edit a copy for one scenario.** `answer("flights-results", (b) => ({ ...b, + results: [] }))` is an empty page; the edit is checked against the contract as + it is served, like any answer. +3. **An error is inline.** `problem(404, "not_found")`, with a `code` from the + contract's closed set. +4. **Write a new file** only for an operation nothing answers yet, or an answer an + edit cannot express: + + ```json + { "op": "", "status": 200, "body": { } } + ``` + + Fill the body, then let the contract tell you what is wrong with it: + + ```sh + bun test --preload ./integration/harness/preload.ts ./integration/fixtures.test.ts + ``` + + Each failure names the file and the path, for example + `200 body.metadata.totalCandidates: required`. Repeat until it passes. Keep the + body to what a scenario reads plus what the contract requires; a few list items, + not a page. + +**Basing a body on a real answer** is optional and needs no tool: with a staging +login (`wego --target staging login`), ask the API directly and trim the result. + +```sh +TOKEN=$(jq -r .accessToken "${XDG_CONFIG_HOME:-$HOME/.config}/wego/auth.wegostaging.com/credentials.json") +curl -s -H "Authorization: Bearer $TOKEN" \ + "https://api.wegostaging.com/v1/places?query=London" | jq '.results |= .[:3]' +``` + +Before committing it, replace anything that identifies the account (email, name, +user id) with `integration@example.com`, `Integration Test` and `1001`. +`integration/` is code-owned, and a reviewer checks exactly that. Do not copy a +`wego …` command's output instead: the CLI reshapes what it prints, so it is not +what the API sends. + +Real traffic is what the next smoke is for: it runs the published binary against +staging on every release. Fixtures only have to be what the contract says the API +may send. diff --git a/integration/auth.test.ts b/integration/auth.test.ts new file mode 100644 index 0000000..9f4ec63 --- /dev/null +++ b/integration/auth.test.ts @@ -0,0 +1,211 @@ +/** + * Login, whoami and logout: the PKCE login over the binary's own loopback + * listener, the stored session every other command runs on, and the ways a + * session ends. + */ + +import { describe, expect, it } from "bun:test"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { TEST_CLIENT_ID } from "./harness/fake"; +import { route } from "./harness/fixtures"; +import { loginThroughBrowser } from "./harness/login"; +import { useScenario } from "./harness/scenario"; +import { idToken, json, signIn } from "./harness/wego"; + +const s = useScenario(); + +describe("login", () => { + it("logs in with PKCE over the loopback and stores the tokens 0600", async () => { + const fake = s.fake(); + const { result, authorize } = await loginThroughBrowser(fake, s.home, { + tokens: { + access_token: "access-9", + refresh_token: "refresh-9", + expires_in: 3600, + id_token: idToken({ country_code: "SG", exp: 4_102_444_800 }), + }, + }); + + expect(result.code).toBe(0); + expect(result.out).toBe(""); + expect(result.err).toContain("Login successful"); + expect(authorize.get("response_type")).toBe("code"); + expect(authorize.get("client_id")).toBe(TEST_CLIENT_ID); + expect(authorize.get("code_challenge_method")).toBe("S256"); + expect(authorize.get("state")).toMatch(/.{16,}/); + expect(authorize.get("redirect_uri")).toMatch( + /^http:\/\/127\.0\.0\.1:\d+\/callback$/, + ); + const stored = JSON.parse(readFileSync(s.home.credentialsPath, "utf8")); + expect(stored).toMatchObject({ + accessToken: "access-9", + refreshToken: "refresh-9", + market: "SG", + }); + if (process.platform !== "win32") { + expect(statSync(s.home.credentialsPath).mode & 0o777).toBe(0o600); + } + }); + + it("ignores a callback whose state is not the one it sent", async () => { + const fake = s.fake(); + const { result } = await loginThroughBrowser(fake, s.home, { + tokens: { access_token: "access-9" }, + // A forged callback first, then the real one: only the real one may count. + before: (redirectUri) => `${redirectUri}?code=forged-code&state=forged`, + }); + + expect(result.code).toBe(0); + expect(fake.tokenRequests.map((t) => t.form.get("code"))).toEqual([ + "code-1", + ]); + }); + + it("ignores a callback that claims another host, even with the right state", async () => { + const fake = s.fake(); + const { result } = await loginThroughBrowser(fake, s.home, { + tokens: { access_token: "access-9" }, + // Right state, wrong origin: it may neither complete nor cancel the login. + before: (redirectUri, state) => ({ + url: `${redirectUri}?error=access_denied&state=${encodeURIComponent(state)}`, + headers: { host: "evil.example" }, + }), + }); + + expect(result.code).toBe(0); + expect(result.err).toContain("Login successful"); + expect(fake.tokenRequests.map((t) => t.form.get("code"))).toEqual([ + "code-1", + ]); + }); + + it("fails with exit 2 when the token exchange is rejected", async () => { + const fake = s.fake(); + const { result } = await loginThroughBrowser(fake, s.home, { + tokens: { access_token: "access-9" }, + callback: (redirectUri, state) => + `${redirectUri}?code=not-issued&state=${encodeURIComponent(state)}`, + }); + + expect(result.code).toBe(2); + expect(result.err).toContain("Login failed"); + expect(existsSync(s.home.credentialsPath)).toBe(false); + }); + + it("rejects an unknown option before touching the network", async () => { + const fake = s.fake(); + const result = await s.run(["login", "--nope"]); + + expect(result.code).toBe(2); + expect(fake.tokenRequests).toEqual([]); + }); +}); + +describe("whoami", () => { + it("prints the caller's identity as JSON", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("user")] }); + const result = await s.run(["whoami"]); + + expect(result.code).toBe(0); + expect(json(result)).toMatchObject({ sub: "integration@example.com" }); + expect(fake.requests("getCurrentUser")[0]?.token).toBe("access-1"); + }); + + it("tells a logged-out user to run login, with exit 3", async () => { + const fake = s.fake(); + const result = await s.run(["whoami"]); + + expect(result.code).toBe(3); + expect(result.out).toBe(""); + expect(result.err).toMatch(/login/); + expect(fake.seen).toEqual([]); + }); + + it("refreshes an expired token first and stores the rotation", async () => { + signIn(s.home, { + accessToken: "access-old", + refreshToken: "refresh-1", + expiresAt: Date.now() - 1000, + }); + const fake = s.fake({ + accept: [], + routes: [route("user")], + refresh: [ + { + access_token: "access-2", + refresh_token: "refresh-2", + expires_in: 3600, + }, + ], + }); + const result = await s.run(["whoami"]); + + expect(result.code).toBe(0); + expect(fake.tokenRequests.map((t) => t.grantType)).toEqual([ + "refresh_token", + ]); + expect(fake.tokenRequests[0]?.form.get("refresh_token")).toBe("refresh-1"); + expect(fake.requests("getCurrentUser").map((r) => r.token)).toEqual([ + "access-2", + ]); + const stored = JSON.parse(readFileSync(s.home.credentialsPath, "utf8")); + expect(stored).toMatchObject({ + accessToken: "access-2", + refreshToken: "refresh-2", + }); + }); + + it("recovers from a 401 by refreshing once and retrying", async () => { + signIn(s.home, { + accessToken: "access-revoked", + refreshToken: "refresh-1", + }); + const fake = s.fake({ + accept: [], + routes: [route("user")], + refresh: [{ access_token: "access-2", refresh_token: "refresh-2" }], + }); + const result = await s.run(["whoami"]); + + expect(result.code).toBe(0); + expect(fake.requests("getCurrentUser").map((r) => r.token)).toEqual([ + "access-revoked", + "access-2", + ]); + }); + + it("exits 3 and points to login when the refresh token is rejected", async () => { + signIn(s.home, { + accessToken: "access-old", + refreshToken: "refresh-dead", + expiresAt: Date.now() - 1000, + }); + s.fake({ accept: [], refresh: [] }); + const result = await s.run(["whoami"]); + + expect(result.code).toBe(3); + expect(result.out).toBe(""); + expect(result.err).toMatch(/login/); + }); +}); + +describe("logout", () => { + it("removes the stored credentials", async () => { + signIn(s.home); + s.fake(); + const result = await s.run(["logout"]); + + expect(result.code).toBe(0); + expect(existsSync(s.home.credentialsPath)).toBe(false); + }); + + it("leaves the next whoami logged out", async () => { + signIn(s.home); + s.fake(); + await s.run(["logout"]); + const result = await s.run(["whoami"]); + + expect(result.code).toBe(3); + }); +}); diff --git a/integration/cli.test.ts b/integration/cli.test.ts new file mode 100644 index 0000000..6186699 --- /dev/null +++ b/integration/cli.test.ts @@ -0,0 +1,139 @@ +/** + * The entrypoint: version, help, dispatch errors, and help that works with no + * backend configured at all. + */ + +import { describe, expect, it } from "bun:test"; +import { useScenario } from "./harness/scenario"; +import { makeHome, wego } from "./harness/wego"; + +const s = useScenario(); + +/** A release binary bakes the prod endpoints, so "no backend" only exists for an + * unbaked one; that is what a source run and a pull request's build are. */ +const baked = await (async () => { + const home = makeHome(); + try { + return (await wego(["version"], { home })).out.trim() !== "0.0.0-dev"; + } finally { + home.cleanup(); + } +})(); + +/** No endpoint at all: what a source run with no `.env.local` has. */ +const NO_BACKEND = { + WEGO_API_URL: "", + WEGO_AUTH_AUTHORIZE_URL: "", + WEGO_AUTH_TOKEN_URL: "", + WEGO_CLI_CLIENT_ID: "", +}; + +describe("the entrypoint", () => { + it("prints the version as semver on stdout", async () => { + const result = await s.run(["version"]); + expect(result.code).toBe(0); + expect(result.out.trim()).toMatch(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/); + }); + + for (const args of [[], ["help"], ["-h"], ["--help"]]) { + it(`prints the root help for \`wego ${args.join(" ")}\`: stdout, exit 0`, async () => { + const result = await s.run(args); + expect(result.code).toBe(0); + expect(result.out).toMatch(/Usage:/); + expect(result.err).toBe(""); + }); + } + + for (const help of ["-h", "--help", "help"]) { + it(`help ${help}: prints the root help, exit 0`, async () => { + const result = await s.run(["help", help]); + expect(result.code).toBe(0); + expect(result.out).toMatch(/^wego – Wego API CLI/); + expect(result.err).toBe(""); + }); + } + + it("help rejects a stray argument, exit 2", async () => { + const result = await s.run(["help", "extra"]); + expect(result.code).toBe(2); + expect(result.err).toMatch(/^Unexpected argument: extra\n/); + }); + + it("exits 2 on an unknown command and names it", async () => { + const result = await s.run(["frobnicate"]); + expect(result.code).toBe(2); + expect(result.out).toBe(""); + expect(result.err).toMatch(/Unknown command: frobnicate/); + }); + + for (const cmd of ["login", "whoami", "logout", "version"]) { + for (const help of ["--help", "-h", "help"]) { + it(`${cmd} ${help}: its own usage on stdout, exit 0, nothing run`, async () => { + const fake = s.fake(); + const result = await s.run([cmd, help]); + expect(result.code).toBe(0); + expect(result.out).toMatch(new RegExp(`^Usage: wego ${cmd}`)); + expect(result.err).toBe(""); + expect(fake.seen).toEqual([]); + expect(fake.tokenRequests).toEqual([]); + }); + } + } + + for (const cmd of ["whoami", "logout", "version"]) { + it(`${cmd} rejects an unknown flag with its usage, exit 2`, async () => { + const fake = s.fake(); + const result = await s.run([cmd, "--frobnicate"]); + expect(result.code).toBe(2); + expect(result.err).toMatch( + new RegExp(`^Unknown option: --frobnicate\\nUsage: wego ${cmd}`), + ); + expect(fake.seen).toEqual([]); + }); + } + + it("answers every --help with no backend configured", async () => { + for (const args of [ + ["flights", "--help"], + ["flights", "results", "--help"], + ["hotels", "rooms", "-h"], + ["info", "holidays", "help"], + ["places", "--help"], + ["feedback", "--help"], + ]) { + const result = await s.run(args, { env: NO_BACKEND }); + expect({ args, code: result.code }).toEqual({ args, code: 0 }); + expect(result.out).toMatch(/Usage:/); + } + }); + + it.skipIf(baked)( + "reads a flag value that happens to be `help` as a value, not a help request", + async () => { + // `--locale help` asks for the locale "help"; it must reach the config load + // (and fail there, with no backend), not print usage. + const result = await s.run(["places", "--locale", "help", "dubai"], { + env: NO_BACKEND, + }); + expect(result.code).not.toBe(0); + expect(result.out).toBe(""); + expect(result.err).toMatch(/is required for source usage/); + }, + ); + + it.skipIf(baked)( + "fails a real call with no backend configured, naming what is missing", + async () => { + for (const args of [ + ["flights", "results", "abc"], + ["places", "dubai"], + ["feedback", "--message", "x"], + ]) { + const result = await s.run(args, { env: NO_BACKEND }); + expect(result.code).not.toBe(0); + expect(result.out).toBe(""); + expect(result.err).toMatch(/is required for source usage/); + } + }, + ); +}); diff --git a/integration/config.test.ts b/integration/config.test.ts new file mode 100644 index 0000000..2feb39d --- /dev/null +++ b/integration/config.test.ts @@ -0,0 +1,149 @@ +/** + * `wego config`: the stored travel preferences, read and written in the binary's + * own settings file, and the layer that decided each effective value. + */ + +import { describe, expect, it } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { useScenario } from "./harness/scenario"; +import { type CliResult, json, signIn, writeSettings } from "./harness/wego"; + +const s = useScenario(); + +const settingsPath = () => join(s.home.configDir, "settings.json"); +const stored = () => + existsSync(settingsPath()) + ? JSON.parse(readFileSync(settingsPath(), "utf8")) + : {}; +type Effective = { value: string | null; source: string }; +const printed = (r: CliResult) => + json<{ + currency: Effective; + site: Effective; + locale: Effective; + path: string; + }>(r); + +/** Logged in with an account market, which `site` falls back to. */ +function signInWithMarket(market: string) { + signIn(s.home, { accessToken: "access-1", market }); +} + +describe("config list", () => { + it("prints every value with the layer that decided it, plus the path", async () => { + writeSettings(s.home, { currency: "SAR" }); + signInWithMarket("SG"); + const result = await s.run(["config", "list"]); + + expect(result.code).toBe(0); + expect(printed(result)).toEqual({ + currency: { value: "SAR", source: "setting" }, + site: { value: "SG", source: "account" }, + locale: { value: null, source: "default" }, + path: settingsPath(), + }); + }); + + it("defaults to `list` when no subcommand is given", async () => { + const result = await s.run(["config"]); + expect(result.code).toBe(0); + expect(printed(result).path).toBe(settingsPath()); + }); + + it("reports `setting` for a site that overrides the account market", async () => { + writeSettings(s.home, { site: "SA" }); + signInWithMarket("SG"); + const result = await s.run(["config", "list"]); + expect(printed(result).site).toEqual({ value: "SA", source: "setting" }); + }); + + it("reports `default` for site when logged out and nothing is stored", async () => { + const result = await s.run(["config", "list"]); + expect(printed(result).site).toEqual({ value: null, source: "default" }); + }); + + it("prints usage on --help, on stdout, exit 0", async () => { + const result = await s.run(["config", "--help"]); + expect(result.code).toBe(0); + expect(result.out).toMatch(/^Usage: wego config/); + }); + + it("rejects a stray argument, exit 2", async () => { + const result = await s.run(["config", "list", "extra"]); + expect(result.code).toBe(2); + expect(result.err).toContain("Unexpected argument: extra"); + }); +}); + +describe("config set", () => { + it("stores a normalized value and prints the new effective config", async () => { + const result = await s.run(["config", "set", "currency", "sar"]); + expect(result.code).toBe(0); + expect(stored()).toEqual({ currency: "SAR" }); + expect(printed(result).currency).toEqual({ + value: "SAR", + source: "setting", + }); + }); + + it("keeps the other keys", async () => { + writeSettings(s.home, { locale: "ar" }); + expect((await s.run(["config", "set", "site", "SA"])).code).toBe(0); + expect(stored()).toEqual({ locale: "ar", site: "SA" }); + }); + + it("rejects a value the API would reject, writing nothing", async () => { + const result = await s.run(["config", "set", "currency", "riyal"]); + expect(result.code).toBe(2); + expect(result.err).toContain("ISO 4217"); + expect(stored()).toEqual({}); + }); + + it("rejects an unknown setting name", async () => { + const result = await s.run(["config", "set", "cabin", "business"]); + expect(result.code).toBe(2); + expect(result.err).toContain("Unknown setting: cabin"); + }); + + it("needs a value", async () => { + const result = await s.run(["config", "set", "currency"]); + expect(result.code).toBe(2); + expect(result.err).toContain("needs a value"); + }); +}); + +describe("config unset", () => { + it("drops one key and leaves the rest", async () => { + writeSettings(s.home, { currency: "SAR", site: "SA" }); + expect((await s.run(["config", "unset", "currency"])).code).toBe(0); + expect(stored()).toEqual({ site: "SA" }); + }); + + it("falls back to the account market once the site setting is gone", async () => { + writeSettings(s.home, { site: "SA" }); + signInWithMarket("SG"); + const result = await s.run(["config", "unset", "site"]); + expect(result.code).toBe(0); + expect(printed(result).site).toEqual({ value: "SG", source: "account" }); + }); + + it("is a no-op on a key that was never set", async () => { + expect((await s.run(["config", "unset", "locale"])).code).toBe(0); + expect(stored()).toEqual({}); + }); +}); + +describe("config (bad input)", () => { + it("rejects an unknown subcommand with usage", async () => { + const result = await s.run(["config", "show"]); + expect(result.code).toBe(2); + expect(result.err).toContain("Unknown subcommand: show"); + }); + + it("calls an unknown flag an option, not a subcommand", async () => { + const result = await s.run(["config", "--all"]); + expect(result.code).toBe(2); + expect(result.err).toContain("Unknown option: --all"); + }); +}); diff --git a/integration/contract.test.ts b/integration/contract.test.ts new file mode 100644 index 0000000..4fd53be --- /dev/null +++ b/integration/contract.test.ts @@ -0,0 +1,110 @@ +/** + * The contract check can fail. A validator that passes everything would make every + * scenario vacuous, so each way it rejects is pinned here once. + */ + +import { describe, expect, it } from "bun:test"; +import { + matchOperation, + operationById, + validate, + validateRequest, + validateResponse, +} from "./harness/contract"; +import { problem, startFake } from "./harness/fake"; + +describe("contract check", () => { + it("matches a literal segment over a template", () => { + expect(matchOperation("GET", "/v1/places/nearby")?.op.operationId).toBe( + "getNearbyPlaces", + ); + expect(matchOperation("GET", "/v1/flights/trips/T1")?.pathParams).toEqual({ + tripId: "T1", + }); + expect(matchOperation("GET", "/v1/nowhere")).toBeUndefined(); + }); + + it("rejects a body missing a required field", () => { + const op = operationById("getNearbyPlaces"); + // The shape the retired in-process stub served: no totalCandidates, hasMore + // or origin.resolvedFrom. The fake would have caught it. + const errors = validateResponse(op, 200, "application/json", { + results: [{ code: "LCY", name: "London City Airport", type: "airport" }], + metadata: { + resultCount: 1, + origin: { latitude: 51.5, longitude: -0.12 }, + }, + }); + expect(errors).toContain("200 body.metadata.totalCandidates: required"); + expect(errors).toContain("200 body.metadata.origin.resolvedFrom: required"); + }); + + it("rejects an undeclared status and an undeclared media type", () => { + const op = operationById("getCurrentUser"); + expect(validateResponse(op, 410, "application/json", {})).toEqual([ + "status 410: not declared by getCurrentUser", + ]); + expect(validateResponse(op, 200, "text/html", {})[0]).toContain( + "media type text/html", + ); + }); + + it("rejects an undeclared or out-of-range query parameter", () => { + const op = operationById("getFlightSearchResults"); + const url = new URL( + "http://x/v1/flights/searches/s1/results?pageSize=51&nope=1", + ); + expect(validateRequest(op, url, { searchId: "s1" }, undefined)).toEqual([ + "query pageSize: above 50", + "query nope: not declared by getFlightSearchResults", + ]); + }); + + it("rejects a value outside an enum and a wrong type", () => { + expect(validate("x", { type: "string", enum: ["a"] })).toEqual([ + '$: "x" not in enum', + ]); + expect(validate(1, { type: "string" })).toEqual([ + "$: expected string, got integer", + ]); + }); + + it("fails loudly on a keyword it does not implement", () => { + expect(validate({}, { type: "object", patternProperties: {} })).toEqual([ + '$: unsupported schema keyword "patternProperties"', + ]); + }); + + it("records a request no route expects, and an invalid fixture", async () => { + const fake = startFake({ + routes: [{ op: "getCurrentUser", answers: [{ status: 200, body: {} }] }], + }); + try { + const auth = { authorization: "Bearer access-1" }; + await fetch(`${fake.url}/v1/user`, { headers: auth }); + await fetch(`${fake.url}/v1/places?query=x`, { headers: auth }); + expect(fake.violations).toEqual([ + "getCurrentUser fixture 200 body.sub: required", + "getPlaces /v1/places?query=x: no route expects it", + ]); + } finally { + fake.stop(); + } + }); + + it("answers a token it does not accept with the contract's 401", async () => { + const fake = startFake({ + routes: [{ op: "getCurrentUser", answers: [problem(401, "x")] }], + }); + try { + const res = await fetch(`${fake.url}/v1/user`, { + headers: { authorization: "Bearer stolen" }, + }); + expect(res.status).toBe(401); + expect(res.headers.get("content-type")).toBe("application/problem+json"); + expect(fake.violations).toEqual([]); + } finally { + fake.stop(); + } + }); +}); diff --git a/integration/errors.test.ts b/integration/errors.test.ts new file mode 100644 index 0000000..dc05e5d --- /dev/null +++ b/integration/errors.test.ts @@ -0,0 +1,119 @@ +/** + * Each error class once, through one representative read: the exit code an agent + * branches on, nothing on stdout, and the API's problem details on stderr. Which + * class each status maps to is `error-report.ts`'s unit tests; here is proof the + * binary carries it to the process boundary. + */ + +import { describe, expect, it } from "bun:test"; +import { type Answer, problem, startDropper } from "./harness/fake"; +import { answer } from "./harness/fixtures"; +import { useScenario } from "./harness/scenario"; +import { signIn } from "./harness/wego"; + +const s = useScenario(); + +async function placesAnswering(...answers: Answer[]) { + signIn(s.home); + const fake = s.fake({ routes: [{ op: "getPlaces", answers }] }); + const result = await s.run(["places", "London"]); + return { fake, result }; +} + +describe("error classes", () => { + it("a persistent 401 with no refresh token exits 3 and names login", async () => { + signIn(s.home, { accessToken: "access-revoked" }); + s.fake({ accept: [] }); + const result = await s.run(["places", "London"]); + expect(result.code).toBe(3); + expect(result.out).toBe(""); + expect(result.err).toMatch(/login/); + }); + + it("a 404 exits 4 with a hint to start again", async () => { + signIn(s.home); + s.fake({ + routes: [{ op: "getFlightTrip", answers: [problem(404, "not_found")] }], + }); + const result = await s.run([ + "flights", + "trip", + "gone-trip", + "--search", + "gone-search", + ]); + expect(result.code).toBe(4); + expect(result.out).toBe(""); + expect(result.err).not.toBe(""); + }); + + it("a 400 exits 6 and prints the API's detail and trace id", async () => { + const { result } = await placesAnswering( + problem(400, "validation_failed", "query is too short"), + ); + expect(result.code).toBe(6); + expect(result.out).toBe(""); + expect(result.err).toContain("query is too short"); + expect(result.err).toContain("trace-400"); + }); + + it("a 429 is retried after Retry-After, then succeeds", async () => { + const { fake, result } = await placesAnswering( + problem(429, "rate_limited", "slow down", { "retry-after": "1" }), + answer("places"), + ); + expect(result.code).toBe(0); + expect(fake.requests("getPlaces")).toHaveLength(2); + }); + + it("a 503 that persists exits 5, retryable", async () => { + const { fake, result } = await placesAnswering( + problem(503, "upstream_unavailable", "try later", { "retry-after": "1" }), + ); + expect(result.code).toBe(5); + expect(result.out).toBe(""); + expect(fake.requests("getPlaces").length).toBeGreaterThanOrEqual(2); + }); + + it("a connection closed before any answer exits 7", async () => { + signIn(s.home); + const dropper = startDropper(); + try { + const result = await s.run(["places", "London"], { + env: { WEGO_API_URL: dropper.url }, + }); + expect(result.code).toBe(7); + expect(result.out).toBe(""); + } finally { + dropper.stop(); + } + }); + + for (const fault of ["non-json", "truncated"] as const) { + it(`a ${fault} body exits non-zero and prints nothing on stdout`, async () => { + const { result } = await placesAnswering({ fault }); + expect(result.code).not.toBe(0); + expect(result.out).toBe(""); + expect(result.err).not.toBe(""); + }); + } + + it("reaches nothing outside this machine", async () => { + // The suite's own guarantee, not the CLI's: a real host is refused before a + // byte leaves, so no scenario can reach production by mistake. + signIn(s.home); + const result = await s.run(["places", "London"], { + env: { WEGO_API_URL: "https://api.wego.com" }, + }); + expect(result.code).toBe(7); + expect(result.out).toBe(""); + }); + + it("an unreachable API exits 7", async () => { + signIn(s.home); + // No fake: the binary is pointed at a closed port. + const result = await s.run(["places", "London"]); + expect(result.code).toBe(7); + expect(result.out).toBe(""); + }); +}); diff --git a/integration/feedback.test.ts b/integration/feedback.test.ts new file mode 100644 index 0000000..c060770 --- /dev/null +++ b/integration/feedback.test.ts @@ -0,0 +1,105 @@ +/** + * `wego feedback`: the body it posts, the usage errors it refuses locally, and the + * session it runs on. The flag rules themselves are `parseFeedbackArgs`'s unit + * tests in `src/commands.test.ts`. + */ + +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { route } from "./harness/fixtures"; +import { useScenario } from "./harness/scenario"; +import { signIn } from "./harness/wego"; + +const s = useScenario(); + +describe("feedback", () => { + it("posts the submission stamped with the CLI version and confirms it", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("feedback-received")] }); + const result = await s.run([ + "feedback", + "--rating", + "5", + "--category", + "flights", + "--message", + "nice", + ]); + + expect(result.code).toBe(0); + expect(result.out).toContain("Thanks"); + const [sent] = fake.requests("submitFeedback"); + expect(sent?.token).toBe("access-1"); + // Stamped with the version the binary reports: 0.0.0-dev unbaked, the + // release's own on a release build. + const version = (await s.run(["version"])).out.trim(); + expect(sent?.body).toEqual({ + rating: 5, + category: "flights", + message: "nice", + version, + }); + }); + + it("prints its usage on --help with exit 0", async () => { + const result = await s.run(["feedback", "--help"]); + expect(result.code).toBe(0); + expect(result.out).toContain("feedback"); + expect(result.err).toBe(""); + }); + + it("refuses a malformed submission with exit 2 and no request", async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run(["feedback", "--rating", "9"]); + expect(result.code).toBe(2); + expect(result.err).toContain("--rating"); + expect(fake.seen).toEqual([]); + }); + + it("recovers from a 401 by refreshing once, keeping the refresh token", async () => { + signIn(s.home, { + accessToken: "access-revoked", + refreshToken: "refresh-1", + }); + const fake = s.fake({ + accept: [], + routes: [route("feedback-received")], + // No refresh_token in the answer: the stored one must survive. + refresh: [{ access_token: "access-2" }], + }); + const result = await s.run(["feedback", "--rating", "5"]); + + expect(result.code).toBe(0); + const sent = fake.requests("submitFeedback"); + expect(sent.map((r) => r.token)).toEqual(["access-revoked", "access-2"]); + expect(sent[1]?.body).toMatchObject({ rating: 5 }); + expect( + JSON.parse(readFileSync(s.home.credentialsPath, "utf8")), + ).toMatchObject({ accessToken: "access-2", refreshToken: "refresh-1" }); + }); + + it("exits 3 on a persistent 401 with no refresh token", async () => { + signIn(s.home, { accessToken: "access-revoked" }); + s.fake({ accept: [] }); + const result = await s.run(["feedback", "--rating", "5"]); + expect(result.code).toBe(3); + expect(result.err).not.toBe(""); + }); + + it("exits 3 and names login when logged out", async () => { + const fake = s.fake(); + const result = await s.run(["feedback", "--rating", "5"]); + expect(result.code).toBe(3); + expect(result.err).toMatch(/login/); + expect(fake.seen).toEqual([]); + }); + + it("exits 7 when the API cannot be reached", async () => { + signIn(s.home); + // No fake: the binary is pointed at a closed port. + const result = await s.run(["feedback", "--rating", "5"]); + expect(result.code).toBe(7); + expect(result.err).toMatch(/reach/i); + }); +}); diff --git a/integration/fixtures.test.ts b/integration/fixtures.test.ts new file mode 100644 index 0000000..f5058a0 --- /dev/null +++ b/integration/fixtures.test.ts @@ -0,0 +1,22 @@ +/** + * Every fixture satisfies the contract. The fake checks each answer as it serves + * it; this checks the ones no scenario happens to serve, and names the file when + * a contract refresh invalidates one. + */ + +import { describe, expect, it } from "bun:test"; +import { operationById, validateResponse } from "./harness/contract"; +import { allFixtures } from "./harness/fixtures"; + +describe("fixtures", () => { + for (const [name, fixture] of allFixtures()) { + it(`${name} is a valid ${fixture.op} ${fixture.status}`, () => { + const op = operationById(fixture.op); + const media = + fixture.status >= 400 ? "application/problem+json" : "application/json"; + expect(validateResponse(op, fixture.status, media, fixture.body)).toEqual( + [], + ); + }); + } +}); diff --git a/integration/fixtures/airports-near.json b/integration/fixtures/airports-near.json new file mode 100644 index 0000000..50aa9fc --- /dev/null +++ b/integration/fixtures/airports-near.json @@ -0,0 +1,24 @@ +{ + "op": "getNearbyPlaces", + "status": 200, + "body": { + "results": [ + { + "id": 212, + "code": "LCY", + "name": "London City Airport", + "type": "airport" + } + ], + "metadata": { + "resultCount": 1, + "totalCandidates": 1, + "hasMore": false, + "origin": { + "latitude": 51.5, + "longitude": -0.12, + "resolvedFrom": "place" + } + } + } +} diff --git a/integration/fixtures/feedback-received.json b/integration/fixtures/feedback-received.json new file mode 100644 index 0000000..3f07764 --- /dev/null +++ b/integration/fixtures/feedback-received.json @@ -0,0 +1,5 @@ +{ + "op": "submitFeedback", + "status": 202, + "body": { "status": "received" } +} diff --git a/integration/fixtures/flights-booking-link.json b/integration/fixtures/flights-booking-link.json new file mode 100644 index 0000000..855d294 --- /dev/null +++ b/integration/fixtures/flights-booking-link.json @@ -0,0 +1,8 @@ +{ + "op": "getFareBookingLink", + "status": 200, + "body": { + "bookingUrl": "https://www.wego.com/flights/searches/x/economy/1a:0c:0i/s1msr:TR610~10/f_88_1/booking?ulang=en&placement_type=integrated_booking&from_v2=true", + "expires": true + } +} diff --git a/integration/fixtures/flights-experience.json b/integration/fixtures/flights-experience.json new file mode 100644 index 0000000..fb59e67 --- /dev/null +++ b/integration/fixtures/flights-experience.json @@ -0,0 +1,23 @@ +{ + "op": "getTripExperience", + "status": 200, + "body": { + "tripId": "s1msr:TR638~3~1250~1425", + "legs": [ + { + "id": "SIN-BKK:TR638~3:0", + "departureAirportCode": "SIN", + "arrivalAirportCode": "BKK", + "stopsCount": 0, + "signals": { + "overnight": false, + "longStopover": false, + "earlyDeparture": false, + "lateArrival": true, + "oldAircraft": true + } + } + ], + "metadata": { "legCount": 1 } + } +} diff --git a/integration/fixtures/flights-fare-options.json b/integration/fixtures/flights-fare-options.json new file mode 100644 index 0000000..68f2148 --- /dev/null +++ b/integration/fixtures/flights-fare-options.json @@ -0,0 +1,25 @@ +{ + "op": "getFareOptions", + "status": 200, + "body": { + "fareId": "f_88_1", + "currencyCode": "USD", + "options": [ + { + "fareOptionId": "SQ_ECO_LITE", + "name": "Economy Lite", + "price": { "total": 512.3, "totalUsd": 512.3, "currency": "USD" }, + "refundable": false, + "exchangeable": false, + "baggage": { "cabin": "7kg included" }, + "penalties": [] + } + ], + "metadata": { + "currencyCode": "USD", + "currencyCodeSource": "explicit", + "locale": "en", + "localeSource": "explicit" + } + } +} diff --git a/integration/fixtures/flights-results.json b/integration/fixtures/flights-results.json new file mode 100644 index 0000000..96e61c4 --- /dev/null +++ b/integration/fixtures/flights-results.json @@ -0,0 +1,67 @@ +{ + "op": "getFlightSearchResults", + "status": 200, + "body": { + "searchId": "s1msr", + "currencyCode": "USD", + "metadata": { + "page": 1, + "pageSize": 10, + "resultCount": 1, + "totalCandidates": 1, + "hasMore": false, + "filterOptions": { + "alliances": [{ "code": "star_alliance", "count": 1 }], + "airlines": [ + { "code": "SQ", "name": "Singapore Airlines", "count": 1 } + ], + "bookingSites": [{ "code": "expedia.com", "count": 1 }], + "stopoverAirports": [], + "aircraft": [{ "code": "333", "count": 1 }] + }, + "snapshotTripCount": 1, + "snapshotFareCount": 1, + "currencyCode": "USD", + "currencyCodeSource": "explicit", + "locale": "en", + "localeSource": "explicit" + }, + "results": [ + { + "tripId": "s1msr:TR610~10", + "badges": ["cheapest"], + "stops": 0, + "durationMinutes": 205, + "price": { + "total": 100, + "currency": "USD", + "scope": "party", + "websiteCount": 11, + "hasWegoFare": true + }, + "legs": [ + { + "from": "SIN", + "to": "BKK", + "departsAt": "2026-03-01T08:00:00", + "arrivesAt": "2026-03-01T09:25:00", + "arrivalDayOffset": 0, + "overnight": false, + "durationMinutes": 205, + "stops": 0, + "via": [], + "airlines": [ + { + "code": "SQ", + "name": "Singapore Airlines", + "logoUrl": "https://l/SQ" + } + ], + "aircraft": ["A330"], + "transportTypes": ["FLIGHT"] + } + ] + } + ] + } +} diff --git a/integration/fixtures/flights-search-link.json b/integration/fixtures/flights-search-link.json new file mode 100644 index 0000000..4305f48 --- /dev/null +++ b/integration/fixtures/flights-search-link.json @@ -0,0 +1,8 @@ +{ + "op": "getFlightSearchLink", + "status": 200, + "body": { + "searchUrl": "https://www.wego.com/flights/searches/SIN-BKK-2026-09-15/economy/1a:0c:0i?ulang=en", + "expires": false + } +} diff --git a/integration/fixtures/flights-search.json b/integration/fixtures/flights-search.json new file mode 100644 index 0000000..8bfd1d6 --- /dev/null +++ b/integration/fixtures/flights-search.json @@ -0,0 +1,9 @@ +{ + "op": "createFlightSearch", + "status": 201, + "body": { + "searchId": "s1msr", + "siteCode": "US", + "siteCodeSource": "default" + } +} diff --git a/integration/fixtures/flights-trip.json b/integration/fixtures/flights-trip.json new file mode 100644 index 0000000..969f76c --- /dev/null +++ b/integration/fixtures/flights-trip.json @@ -0,0 +1,43 @@ +{ + "op": "getFlightTrip", + "status": 200, + "body": { + "tripId": "s1msr:TR610~10", + "stops": 0, + "durationMinutes": 205, + "outbound": { + "from": "SIN", + "to": "BKK", + "departsAt": "2026-03-01T08:00:00", + "arrivesAt": "2026-03-01T09:25:00", + "durationMinutes": 205, + "stops": 0, + "airlines": ["SQ"], + "transportTypes": ["FLIGHT"] + }, + "fares": [ + { + "kind": "wego", + "fareId": "f_88_1", + "providerCode": "wego.com", + "providerName": "Wego", + "price": { + "total": 100, + "totalUsd": 100, + "currency": "USD", + "scope": "party", + "includesFees": true + }, + "refundable": false, + "hasFareOptions": true, + "handoffUrl": "https://www.wego.com/flights/booking" + } + ], + "metadata": { + "currencyCode": "USD", + "currencyCodeSource": "explicit", + "locale": "en", + "localeSource": "explicit" + } + } +} diff --git a/integration/fixtures/holidays.json b/integration/fixtures/holidays.json new file mode 100644 index 0000000..a8dea07 --- /dev/null +++ b/integration/fixtures/holidays.json @@ -0,0 +1,21 @@ +{ + "op": "getCountryHolidays", + "status": 200, + "body": { + "results": [ + { + "name": "National Day", + "key": "national_day", + "startDate": "2026-08-09", + "endDate": "2026-08-09" + } + ], + "metadata": { + "resultCount": 1, + "countryCode": "SG", + "window": "upcoming", + "from": "2026-07-31", + "to": "2026-10-29" + } + } +} diff --git a/integration/fixtures/hotels-booking-link.json b/integration/fixtures/hotels-booking-link.json new file mode 100644 index 0000000..d17fcf8 --- /dev/null +++ b/integration/fixtures/hotels-booking-link.json @@ -0,0 +1,8 @@ +{ + "op": "getHotelRateBookingLink", + "status": 200, + "body": { + "bookingUrl": "https://www.wego.com/hotels/booking/checkout?search_id=hs-85481-0001", + "expires": true + } +} diff --git a/integration/fixtures/hotels-details.json b/integration/fixtures/hotels-details.json new file mode 100644 index 0000000..55aa1e9 --- /dev/null +++ b/integration/fixtures/hotels-details.json @@ -0,0 +1,11 @@ +{ + "op": "getHotel", + "status": 200, + "body": { + "hotelId": 85481, + "name": "Grand Hyatt Dubai", + "pageUrl": "https://www.wego.com/hotels/united-arab-emirates/dubai/grand-hyatt-dubai-85481", + "star": 5, + "location": {} + } +} diff --git a/integration/fixtures/hotels-rates.json b/integration/fixtures/hotels-rates.json new file mode 100644 index 0000000..0dc38d6 --- /dev/null +++ b/integration/fixtures/hotels-rates.json @@ -0,0 +1,49 @@ +{ + "op": "getHotelRates", + "status": 200, + "body": { + "hotelId": 85481, + "searchId": "hs-85481-0001", + "currencyCode": "USD", + "searchComplete": true, + "stay": { + "checkIn": "2026-10-23", + "checkOut": "2026-10-25", + "nights": 2, + "occupancy": { "adults": 2, "childrenAges": [], "rooms": 1 } + }, + "rates": [ + { + "id": "hs-85481-0001:hotels.wego.com:85481:abc123:7", + "roomName": "Grand King Room", + "board": "Room only", + "refundable": false, + "price": { + "scope": "booking", + "amountPerNight": 214.5, + "totalUsd": 429, + "currency": "USD" + } + }, + { + "id": "hs-85481-0001:hotels.wego.com:85481:def456:3", + "roomName": "Grand Twin Room", + "board": "Breakfast included", + "refundable": true, + "cancellationPolicy": "Free cancellation until 2 days before check-in", + "price": { + "scope": "booking", + "amountPerNight": 248, + "totalUsd": 496, + "currency": "USD" + } + } + ], + "metadata": { + "currencyCode": "USD", + "currencyCodeSource": "explicit", + "locale": "en", + "localeSource": "explicit" + } + } +} diff --git a/integration/fixtures/hotels-reviews.json b/integration/fixtures/hotels-reviews.json new file mode 100644 index 0000000..69b5ad7 --- /dev/null +++ b/integration/fixtures/hotels-reviews.json @@ -0,0 +1,26 @@ +{ + "op": "getHotelReviews", + "status": 200, + "body": { + "hotelId": 85481, + "metadata": { + "page": 1, + "pageSize": 10, + "resultCount": 1, + "totalCandidates": 49, + "hasMore": true, + "topics": ["breakfast"], + "matchedTerms": ["breakfast", "Breakfast"] + }, + "results": [ + { + "rating": 9.2, + "postedAt": "2026-06-14", + "providerCode": "booking.com", + "guestType": "couple", + "pros": ["Breakfast spread was huge"], + "cons": [] + } + ] + } +} diff --git a/integration/fixtures/hotels-rooms-create.json b/integration/fixtures/hotels-rooms-create.json new file mode 100644 index 0000000..3e5f09a --- /dev/null +++ b/integration/fixtures/hotels-rooms-create.json @@ -0,0 +1,10 @@ +{ + "op": "createHotelSearch", + "status": 201, + "body": { + "searchId": "hs-85481-0001", + "occupancy": { "adults": 2, "childrenAges": [], "rooms": 1 }, + "siteCode": "US", + "siteCodeSource": "default" + } +} diff --git a/integration/fixtures/hotels-search-create.json b/integration/fixtures/hotels-search-create.json new file mode 100644 index 0000000..c03c545 --- /dev/null +++ b/integration/fixtures/hotels-search-create.json @@ -0,0 +1,10 @@ +{ + "op": "createHotelSearch", + "status": 201, + "body": { + "searchId": "hs-dxb-0001", + "occupancy": { "adults": 2, "childrenAges": [11], "rooms": 1 }, + "siteCode": "US", + "siteCodeSource": "default" + } +} diff --git a/integration/fixtures/hotels-search-link.json b/integration/fixtures/hotels-search-link.json new file mode 100644 index 0000000..58d7654 --- /dev/null +++ b/integration/fixtures/hotels-search-link.json @@ -0,0 +1,8 @@ +{ + "op": "getHotelSearchLink", + "status": 200, + "body": { + "searchUrl": "https://www.wego.com/hotels/searches/bkk/2026-10-23/2026-10-25?guests=2&ulang=en", + "expires": false + } +} diff --git a/integration/fixtures/hotels-search-results.json b/integration/fixtures/hotels-search-results.json new file mode 100644 index 0000000..66fecc8 --- /dev/null +++ b/integration/fixtures/hotels-search-results.json @@ -0,0 +1,68 @@ +{ + "op": "getHotelSearchResults", + "status": 200, + "body": { + "searchId": "hs-dxb-0001", + "currencyCode": "USD", + "searchComplete": true, + "stay": { + "checkIn": "2026-10-23", + "checkOut": "2026-10-25", + "nights": 2, + "occupancy": { "adults": 2, "childrenAges": [11], "rooms": 1 } + }, + "metadata": { + "page": 1, + "pageSize": 10, + "resultCount": 2, + "totalCandidates": 415, + "totalBeforeFilters": 415, + "filterOptions": { + "amenities": [{ "name": "Swimming Pool", "count": 310 }], + "propertyTypes": [{ "name": "Hotel", "count": 380 }], + "brands": [{ "name": "Hyatt", "count": 6 }], + "chains": [{ "name": "Hyatt Hotels", "count": 6 }], + "districts": [{ "name": "Bur Dubai", "count": 42 }], + "rateTypes": [{ "name": "Free Cancellation", "count": 201 }], + "guestTypes": [{ "name": "couple", "count": 390 }] + }, + "hasMore": true, + "snapshotCandidateCount": 415, + "currencyCode": "USD", + "currencyCodeSource": "explicit", + "locale": "en", + "localeSource": "explicit" + }, + "results": [ + { + "hotelId": 85481, + "name": "Grand Hyatt Dubai", + "pageUrl": "https://www.wego.com/hotels/united-arab-emirates/dubai/grand-hyatt-dubai-85481", + "star": 5, + "review": { "score": 8.9, "count": 5120 }, + "price": { + "scope": "booking", + "amountPerNight": 214.5, + "totalUsd": 429, + "currency": "USD" + }, + "refundable": "available", + "badges": [] + }, + { + "hotelId": 262411, + "name": "Rove Downtown", + "pageUrl": "https://www.wego.com/hotels/united-arab-emirates/dubai/rove-downtown-262411", + "star": 3, + "price": { + "scope": "booking", + "amountPerNight": 96, + "totalUsd": 192, + "currency": "USD" + }, + "refundable": "unknown", + "badges": [] + } + ] + } +} diff --git a/integration/fixtures/places.json b/integration/fixtures/places.json new file mode 100644 index 0000000..51119a3 --- /dev/null +++ b/integration/fixtures/places.json @@ -0,0 +1,30 @@ +{ + "op": "getPlaces", + "status": 200, + "body": { + "results": [ + { + "id": 1, + "code": "LON", + "name": "London", + "type": "city", + "cityCode": "LON", + "latitude": 51.5, + "longitude": -0.12 + }, + { + "id": 2, + "code": "LHR", + "name": "London Heathrow Airport", + "type": "airport", + "cityCode": "LON" + } + ], + "metadata": { + "resultCount": 2, + "totalCandidates": 2, + "hasMore": false, + "hasAmbiguity": false + } + } +} diff --git a/integration/fixtures/schedules.json b/integration/fixtures/schedules.json new file mode 100644 index 0000000..ecff93a --- /dev/null +++ b/integration/fixtures/schedules.json @@ -0,0 +1,41 @@ +{ + "op": "getFlightSchedules", + "status": 200, + "body": { + "results": [ + { + "airlineCode": "SQ", + "flightNumber": "SQ 322", + "departureAirportCode": "SIN", + "arrivalAirportCode": "LHR", + "departureTime": "23:05", + "arrivalTime": "05:50", + "durationMinutes": 825, + "stopsCount": 0, + "arrivalDayOffset": 1, + "segments": [ + { + "departureAirportCode": "SIN", + "arrivalAirportCode": "LHR", + "departureTime": "23:05", + "arrivalTime": "05:50", + "airlineCode": "SQ" + } + ], + "operatingPeriods": [] + } + ], + "metadata": { + "page": 1, + "pageSize": 200, + "resultCount": 1, + "totalCandidates": 1, + "hasMore": false, + "coverage": "complete", + "from": { "requested": "SIN", "resolvedCityCode": "SIN" }, + "to": { "requested": "LHR", "resolvedCityCode": "LON" }, + "siteCode": "SG", + "siteCodeSource": "explicit" + } + } +} diff --git a/integration/fixtures/user.json b/integration/fixtures/user.json new file mode 100644 index 0000000..43d7888 --- /dev/null +++ b/integration/fixtures/user.json @@ -0,0 +1,11 @@ +{ + "op": "getCurrentUser", + "status": 200, + "body": { + "sub": "integration@example.com", + "scope": "openid profile users", + "email": "integration@example.com", + "name": "Integration Test", + "uid": 1001 + } +} diff --git a/integration/fixtures/visa-free.json b/integration/fixtures/visa-free.json new file mode 100644 index 0000000..bde56d1 --- /dev/null +++ b/integration/fixtures/visa-free.json @@ -0,0 +1,17 @@ +{ + "op": "getVisaFreeDestinations", + "status": 200, + "body": { + "results": [ + { "countryCode": "TH", "name": "Thailand", "keyCityCode": "BKK" } + ], + "metadata": { + "resultCount": 1, + "totalCandidates": 1, + "hasMore": false, + "passportCountryCode": "PH", + "upstreamPagesFetched": 1, + "coverage": "complete" + } + } +} diff --git a/integration/flights.test.ts b/integration/flights.test.ts new file mode 100644 index 0000000..9e6c143 --- /dev/null +++ b/integration/flights.test.ts @@ -0,0 +1,1188 @@ +/** + * `wego flights`: the funnel from search to booking link, as a caller sees it. What + * each command sends, what it prints, the block-to-settled read after a search, + * where the market and the currency come from, and the usage errors refused + * before any request. + * + * The settle loop's own rules (the transient drop, the exhausted budget, the + * count-less fallback) are `src/search-engine.test.ts`: here is one of each + * outcome a fast scenario can reach. The results filter flags are + * `parseFlightResultsArgs`'s unit tests. + */ + +import { describe, expect, it } from "bun:test"; +import { type Answer, problem } from "./harness/fake"; +import { answer, readFixture, route } from "./harness/fixtures"; +import { useScenario } from "./harness/scenario"; +import { json, signIn, writeSettings } from "./harness/wego"; + +const s = useScenario(); + +// biome-ignore lint/suspicious/noExplicitAny: a fixture body is untyped JSON +const body = (name: string) => readFixture(name).body as any; + +/** The ids a scenario passes on argv. The fake answers any id with its fixture. */ +const SEARCH_ID = "s1msr"; +const TRIP_ID = "s1msr:TR610~10"; +const FARE_ID = "f_88_1"; +const EXPERIENCE_TRIP = "s1msr:TR638~3~1250~1425"; + +type Printed = Record & { + metadata: Record; +}; + +/** A results page whose snapshot counter reads `count`; zero is a cold, empty + * snapshot. */ +function resultsAt(count: number): Answer { + // biome-ignore lint/suspicious/noExplicitAny: a fixture body is untyped JSON + return answer("flights-results", (b) => { + b.metadata.snapshotFareCount = count; + if (count === 0) { + b.results = []; + b.metadata.resultCount = 0; + b.metadata.totalCandidates = 0; + b.metadata.snapshotTripCount = 0; + } else { + b.metadata.snapshotTripCount = Math.max(b.metadata.snapshotTripCount, 1); + } + return b; + }); +} + +/** A create that echoes `siteCode`, as the API does for the market it was sent. */ +const createEchoing = (siteCode: string): Answer => + answer("flights-search", (b) => ({ + ...b, + siteCode, + siteCodeSource: "explicit", + })); + +/** Settle reads that converge on the second one, whatever the fixture holds. */ +function settledReads() { + return { op: "getFlightSearchResults", answers: [resultsAt(1)] }; +} + +/** The routes a `flights search` touches: the create, then its settle reads. */ +function searchRoutes(...reads: Answer[]) { + return [ + route("flights-search"), + reads.length > 0 + ? { op: "getFlightSearchResults", answers: reads } + : settledReads(), + ]; +} + +/** A create answering with `create` only, not the fixture first. */ +function createRoute(create: Answer) { + return { op: "createFlightSearch", answers: [create] }; +} + +const query = (seen: { query: URLSearchParams } | undefined) => + Object.fromEntries(seen?.query ?? []); + +describe("flights search", () => { + it("creates, blocks to settled, and prints the page with its searchId", async () => { + signIn(s.home); + const fake = s.fake({ routes: searchRoutes() }); + const result = await s.run([ + "flights", + "search", + "SIN", + "BKK", + "2099-03-01", + "--return", + "2099-03-08", + ]); + + expect(result.code).toBe(0); + const printed = json(result); + expect(printed.searchId).toBe(body("flights-search").searchId); + expect(printed.settled).toBe("converged"); + // A card page, not trips: a price summary and no fares (#1308). + expect(printed.results[0]?.price).toEqual( + body("flights-results").results[0].price, + ); + expect(result.out).not.toContain('"fares"'); + expect(fake.requests("createFlightSearch")[0]?.body).toEqual({ + from: "SIN", + to: "BKK", + fromDate: "2099-03-01", + toDate: "2099-03-08", + }); + expect(fake.requests("getFlightSearchResults")[0]?.path).toBe( + `/v1/flights/searches/${body("flights-search").searchId}/results`, + ); + }); + + for (const flags of [ + ["--infants", "9"], + ["--adults", "1", "--infants", "2"], + ["--infants", "2"], + ]) { + it(`search ${flags.join(" ")} is a usage error, not a 400`, async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run([ + "flights", + "search", + "SIN", + "BKK", + "2099-03-01", + ...flags, + ]); + expect(result.code).toBe(2); + expect(fake.seen).toEqual([]); + }); + } + + for (const dates of [ + ["01-03-2099"], + ["2099-02-30"], + ["2099-03-01", "--return", "2099-13-01"], + ["2099-03-01", "--return", "nope"], + ]) { + it(`search with ${dates.join(" ")} is a usage error, not a 400`, async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run(["flights", "search", "SIN", "BKK", ...dates]); + expect(result.code).toBe(2); + expect(fake.seen).toEqual([]); + }); + } + + it("derives the site from the account's market (source: account)", async () => { + signIn(s.home, { accessToken: "access-1", market: "AE" }); + const fake = s.fake({ + routes: [createRoute(createEchoing("AE")), settledReads()], + }); + const result = await s.run([ + "flights", + "search", + "SIN", + "BKK", + "2099-03-01", + ]); + + expect(result.code).toBe(0); + expect(fake.requests("createFlightSearch")[0]?.body).toMatchObject({ + siteCode: "AE", + }); + expect(json(result)).toMatchObject({ + siteCode: "AE", + siteCodeSource: "account", + }); + }); + + it("an explicit --site beats the account's market (source: explicit)", async () => { + signIn(s.home, { accessToken: "access-1", market: "AE" }); + const fake = s.fake({ + routes: [createRoute(createEchoing("SG")), settledReads()], + }); + const result = await s.run([ + "flights", + "search", + "SIN", + "BKK", + "2099-03-01", + "--site", + "SG", + ]); + + expect(result.code).toBe(0); + expect(fake.requests("createFlightSearch")[0]?.body).toMatchObject({ + siteCode: "SG", + }); + expect(json(result).siteCodeSource).toBe("explicit"); + }); + + it("with no flag, setting or market sends no site and reports `default`", async () => { + signIn(s.home); + const fake = s.fake({ routes: searchRoutes() }); + const result = await s.run([ + "flights", + "search", + "SIN", + "BKK", + "2099-03-01", + ]); + + expect(result.code).toBe(0); + expect(fake.requests("createFlightSearch")[0]?.body).not.toHaveProperty( + "siteCode", + ); + expect(json(result)).toMatchObject({ + siteCode: body("flights-search").siteCode, + siteCodeSource: "default", + }); + }); + + it("a stored site beats the account's market (source: setting)", async () => { + signIn(s.home, { accessToken: "access-1", market: "AE" }); + writeSettings(s.home, { site: "SA", currency: "SAR" }); + const fake = s.fake({ + routes: [createRoute(createEchoing("SA")), settledReads()], + }); + const result = await s.run([ + "flights", + "search", + "RUH", + "DXB", + "2099-03-01", + ]); + + expect(result.code).toBe(0); + expect(fake.requests("createFlightSearch")[0]?.body).toMatchObject({ + siteCode: "SA", + }); + expect(json(result)).toMatchObject({ + siteCode: "SA", + siteCodeSource: "setting", + }); + }); + + it("reads the first page in the search's --currency and --locale", async () => { + signIn(s.home); + const fake = s.fake({ routes: searchRoutes() }); + const result = await s.run([ + "flights", + "search", + "SIN", + "BKK", + "2099-03-01", + "--currency", + "SGD", + "--locale", + "ar", + ]); + + expect(result.code).toBe(0); + expect(query(fake.requests("getFlightSearchResults")[0])).toEqual({ + currency: "SGD", + locale: "ar", + }); + }); + + for (const [rung, settings, flags, sent] of [ + ["explicit", { currency: "SAR" }, ["--currency", "USD"], "USD"], + ["setting", { currency: "SAR" }, [], "SAR"], + ["default", {}, [], undefined], + ] as const) { + it(`names the currency's rung (${rung}); the create and its read agree`, async () => { + signIn(s.home); + writeSettings(s.home, settings); + const fake = s.fake({ routes: searchRoutes() }); + const result = await s.run([ + "flights", + "search", + "RUH", + "DXB", + "2099-03-01", + ...flags, + ]); + + expect(result.code).toBe(0); + expect(json(result).currencyCodeSource).toBe(rung); + const created = fake.requests("createFlightSearch")[0]?.body as { + currency?: string; + }; + expect(created.currency).toBe(sent); + for (const read of fake.requests("getFlightSearchResults")) { + expect(read.query.get("currency") ?? undefined).toBe(sent); + } + }); + } + + it("prints the currency hint on a fresh machine, and not once one is stored", async () => { + signIn(s.home); + s.fake({ routes: searchRoutes() }); + const argv = ["flights", "search", "RUH", "DXB", "2099-03-01"]; + + const fresh = await s.run(argv); + expect(fresh.code).toBe(0); + expect(fresh.err).toContain("config set currency"); + + writeSettings(s.home, { currency: "SAR" }); + const configured = await s.run(argv); + expect(configured.code).toBe(0); + expect(configured.err).not.toContain("config set currency"); + }); + + it("an explicit --currency also silences the hint", async () => { + signIn(s.home); + s.fake({ routes: searchRoutes() }); + const result = await s.run([ + "flights", + "search", + "RUH", + "DXB", + "2099-03-01", + "--currency", + "SAR", + ]); + expect(result.code).toBe(0); + expect(result.err).not.toContain("config set currency"); + }); +}); + +describe("flights search settle", () => { + it("re-reads past an empty first snapshot and stamps `converged`", async () => { + signIn(s.home); + const fake = s.fake({ + routes: searchRoutes(resultsAt(0), resultsAt(1)), + }); + const result = await s.run([ + "flights", + "search", + "SIN", + "BKK", + "2099-03-01", + ]); + + expect(result.code).toBe(0); + expect(fake.requests("getFlightSearchResults").length).toBeGreaterThan(1); + const printed = json(result); + expect(printed.settled).toBe("converged"); + expect(printed.results.length).toBeGreaterThan(0); + }); + + it("an empty settled page is still only JSON on stdout, the hint on stderr", async () => { + signIn(s.home); + // The snapshot holds trips but the page is empty: it settles, then explains. + const empty = answer( + "flights-results", + (b) => ({ + ...b, + results: [], + metadata: { + ...b.metadata, + resultCount: 0, + snapshotTripCount: 3, + snapshotFareCount: 5, + }, + }), + ); + s.fake({ routes: searchRoutes(empty) }); + const result = await s.run([ + "flights", + "search", + "SIN", + "BKK", + "2099-03-01", + ]); + + expect(result.code).toBe(0); + const printed = json(result); + const searchId = body("flights-search").searchId; + expect(printed.searchId).toBe(searchId); + expect(printed.results).toEqual([]); + expect(result.err).toContain(`wego flights results ${searchId}`); + expect(result.out).not.toContain("re-run"); + }); + + it("a 401 on the read refreshes and retries the read, never the create", async () => { + signIn(s.home); + const fake = s.fake({ + routes: searchRoutes( + problem(401, "invalid_token", "Missing or invalid token"), + resultsAt(1), + ), + refresh: [{ access_token: "access-2" }], + }); + const result = await s.run([ + "flights", + "search", + "SIN", + "BKK", + "2099-03-01", + ]); + + expect(result.code).toBe(0); + expect(fake.requests("createFlightSearch")).toHaveLength(1); + expect( + fake.requests("getFlightSearchResults").map((r) => r.token), + ).toContain("access-2"); + expect(json(result).results).toEqual( + body("flights-results").results, + ); + }); + + it("a failed first read exits 1 with only the re-run hint", async () => { + signIn(s.home); + s.fake({ + routes: searchRoutes(problem(502, "bad_gateway", "boom")), + }); + const result = await s.run([ + "flights", + "search", + "SIN", + "BKK", + "2099-03-01", + ]); + + expect(result.code).toBe(1); + expect(result.out).toBe(""); + expect(result.err).toContain( + `Search created – re-run: wego flights results ${body("flights-search").searchId} --wait`, + ); + expect(result.err).not.toMatch(/boom|trace-502/); + }); + + it("a failed read mid-settle exits with its own class, not the re-run fold", async () => { + signIn(s.home); + const fake = s.fake({ + routes: searchRoutes(resultsAt(1), problem(502, "bad_gateway", "boom")), + }); + const result = await s.run([ + "flights", + "search", + "SIN", + "BKK", + "2099-03-01", + ]); + + expect(result.code).toBe(6); + expect(result.out).toBe(""); + expect(result.err).not.toContain("Search created – re-run"); + expect(fake.requests("getFlightSearchResults").length).toBeGreaterThan(1); + }); +}); + +describe("flights results", () => { + it("without --wait reads once and stamps `unsettled`", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [ + { + op: "getFlightSearchResults", + answers: [resultsAt(1), resultsAt(2), resultsAt(3)], + }, + ], + }); + const result = await s.run(["flights", "results", SEARCH_ID]); + + expect(result.code).toBe(0); + expect(fake.requests("getFlightSearchResults")).toHaveLength(1); + expect(json(result).settled).toBe("unsettled"); + }); + + it("prints the fares-less cards and sends no view param", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("flights-results")] }); + const result = await s.run(["flights", "results", SEARCH_ID]); + + expect(result.code).toBe(0); + expect(fake.requests("getFlightSearchResults")[0]?.query.has("view")).toBe( + false, + ); + const card = body("flights-results").results[0]; + const printed = json<{ + results: { tripId: string; durationMinutes: number; price: unknown }[]; + }>(result); + expect(printed.results[0]).toMatchObject({ + tripId: card.tripId, + durationMinutes: card.durationMinutes, + price: card.price, + }); + expect(result.out).not.toContain('"fares"'); + }); + + it("--wait settles once the snapshot count stops growing", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [ + { + op: "getFlightSearchResults", + answers: [resultsAt(0), resultsAt(3), resultsAt(3)], + }, + ], + }); + const result = await s.run(["flights", "results", SEARCH_ID, "--wait"]); + + expect(result.code).toBe(0); + const printed = json(result); + expect(printed.searchId).toBe(body("flights-results").searchId); + expect(printed.settled).toBe("converged"); + expect(printed.metadata.snapshotFareCount).toBe(3); + expect(fake.requests("getFlightSearchResults")).toHaveLength(3); + }); + + it("--wait restarts the whole poll after a 401 mid-settle", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [ + { + op: "getFlightSearchResults", + answers: [ + resultsAt(0), + resultsAt(2), + problem(401, "invalid_token", "Missing or invalid token"), + resultsAt(2), + ], + }, + ], + refresh: [{ access_token: "access-2" }], + }); + const result = await s.run(["flights", "results", SEARCH_ID, "--wait"]); + + expect(result.code).toBe(0); + const printed = json(result); + expect(printed.settled).toBe("converged"); + expect(printed.metadata.snapshotFareCount).toBe(2); + // The restart re-walks from its own first read, on the refreshed token. + const reads = fake.requests("getFlightSearchResults"); + expect(reads.length).toBeGreaterThan(3); + expect(reads.at(-1)?.token).toBe("access-2"); + }); + + it("after a refresh, a retried read's own failure keeps its class (exit 5)", async () => { + signIn(s.home); + s.fake({ + routes: [ + { + op: "getFlightSearchResults", + answers: [ + problem(401, "invalid_token", "Missing or invalid token"), + problem(503, "upstream_unavailable", "try later", { + "retry-after": "0", + }), + ], + }, + ], + refresh: [{ access_token: "access-2" }], + }); + const result = await s.run(["flights", "results", SEARCH_ID]); + expect(result.code).toBe(5); + }); + + it("an expired search exits 4 with a message saying so", async () => { + signIn(s.home); + s.fake({ + routes: [ + { op: "getFlightSearchResults", answers: [problem(404, "not_found")] }, + ], + }); + const result = await s.run(["flights", "results", "gone123msr"]); + expect(result.code).toBe(4); + expect(result.out).toBe(""); + expect(result.err).toMatch(/expired|not found/i); + }); + + it("a bare read inherits the stored currency and locale", async () => { + // Issue #1386: a plain read after a SAR search used to come back in USD. + signIn(s.home); + writeSettings(s.home, { currency: "SAR", locale: "ar" }); + const fake = s.fake({ routes: [route("flights-results")] }); + expect((await s.run(["flights", "results", SEARCH_ID])).code).toBe(0); + expect(query(fake.requests("getFlightSearchResults")[0])).toMatchObject({ + currency: "SAR", + locale: "ar", + }); + }); + + it("an explicit --currency beats the stored one", async () => { + signIn(s.home); + writeSettings(s.home, { currency: "SAR" }); + const fake = s.fake({ routes: [route("flights-results")] }); + expect( + (await s.run(["flights", "results", SEARCH_ID, "--currency", "USD"])) + .code, + ).toBe(0); + expect( + fake.requests("getFlightSearchResults")[0]?.query.get("currency"), + ).toBe("USD"); + }); +}); + +describe("priced reads and their provenance", () => { + const priced = [ + { + argv: ["flights", "results", SEARCH_ID], + routes: () => [route("flights-results")], + }, + { + argv: ["flights", "trip", TRIP_ID, "--search", SEARCH_ID], + routes: () => [route("flights-trip")], + }, + { + argv: ["flights", "fares", FARE_ID], + routes: () => [route("flights-fare-options")], + }, + ]; + + for (const [rung, settings, flags] of [ + ["default", {}, []], + ["setting", { currency: "SAR" }, []], + ["explicit", { currency: "SAR" }, ["--currency", "USD"]], + ] as const) { + for (const read of priced) { + it(`${read.argv.slice(0, 2).join(" ")} names the currency rung: ${rung}`, async () => { + signIn(s.home); + writeSettings(s.home, settings); + s.fake({ routes: read.routes() }); + const result = await s.run([...read.argv, ...flags]); + expect(result.code).toBe(0); + expect(json(result).currencyCodeSource).toBe(rung); + }); + } + } + + const everyPriced = [ + { + argv: ["flights", "search", "RUH", "DXB", "2099-03-01"], + routes: () => searchRoutes(), + }, + ...priced, + ]; + for (const read of everyPriced) { + it(`${read.argv.slice(0, 2).join(" ")} prints one *Source per knob, top level`, async () => { + // The API's request-scoped copies inside `metadata` disagree with the CLI's + // label by construction when a preference is stored, so they are stripped + // at print time and the echoes themselves are kept (#1534). + signIn(s.home); + writeSettings(s.home, { currency: "SAR" }); + s.fake({ routes: read.routes() }); + const result = await s.run(read.argv); + + expect(result.code).toBe(0); + const printed = json(result); + expect(printed.currencyCodeSource).toBe("setting"); + expect(result.out.split('"currencyCodeSource"').length - 1).toBe(1); + expect(result.out).not.toContain("localeSource"); + expect( + Object.keys(printed.metadata).filter((k) => k.endsWith("Source")), + ).toEqual([]); + expect(printed.metadata).toHaveProperty("locale"); + }); + } +}); + +describe("flights trip", () => { + it("forwards --view, and sends none without the flag", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("flights-trip")] }); + const argv = ["flights", "trip", TRIP_ID, "--search", SEARCH_ID]; + expect((await s.run([...argv, "--view", "detail"])).code).toBe(0); + expect((await s.run(argv)).code).toBe(0); + + const [flagged, bare] = fake.requests("getFlightTrip"); + expect(flagged?.pathParams.tripId).toBe(TRIP_ID); + expect(flagged?.query.get("searchId")).toBe(SEARCH_ID); + expect(flagged?.query.get("view")).toBe("detail"); + // Omitted, not spelled as the server's own default. + expect(bare?.query.has("view")).toBe(false); + }); + + it("refuses an unknown --view locally, exit 2", async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run([ + "flights", + "trip", + TRIP_ID, + "--search", + SEARCH_ID, + "--view", + "detials", + ]); + expect(result.code).toBe(2); + expect(result.err).toContain("--view must be one of default, detail"); + expect(fake.seen).toEqual([]); + }); + + it("requires --search, exit 2", async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run(["flights", "trip", TRIP_ID]); + expect(result.code).toBe(2); + expect(result.err).toMatch(/--search/); + expect(fake.seen).toEqual([]); + }); +}); + +describe("flights fares and experience", () => { + it("fares prints the fare options and forwards currency and locale", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("flights-fare-options")] }); + const result = await s.run([ + "flights", + "fares", + FARE_ID, + "--currency", + "USD", + "--locale", + "en", + ]); + + expect(result.code).toBe(0); + expect( + json<{ options: { fareOptionId: string }[] }>(result).options[0] + ?.fareOptionId, + ).toBe(body("flights-fare-options").options[0].fareOptionId); + const [sent] = fake.requests("getFareOptions"); + expect(sent?.pathParams.fareId).toBe(FARE_ID); + expect(query(sent)).toEqual({ currency: "USD", locale: "en" }); + }); + + it("an expired fare exits 4 with the re-search hint", async () => { + signIn(s.home); + s.fake({ + routes: [{ op: "getFareOptions", answers: [problem(404, "not_found")] }], + }); + const result = await s.run(["flights", "fares", FARE_ID]); + expect(result.code).toBe(4); + expect(result.err).toMatch(/expired|search again|re-open/i); + }); + + it("experience prints the per-leg signals and sends no query by default", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("flights-experience")] }); + const result = await s.run(["flights", "experience", EXPERIENCE_TRIP]); + + expect(result.code).toBe(0); + expect(json<{ legs: unknown[] }>(result).legs).toEqual( + body("flights-experience").legs, + ); + const [sent] = fake.requests("getTripExperience"); + expect(sent?.pathParams.tripId).toBe(EXPERIENCE_TRIP); + expect(query(sent)).toEqual({}); + }); + + it("experience forwards --search as the cross-check", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("flights-experience")] }); + expect( + ( + await s.run([ + "flights", + "experience", + EXPERIENCE_TRIP, + "--search", + SEARCH_ID, + ]) + ).code, + ).toBe(0); + expect(query(fake.requests("getTripExperience")[0])).toEqual({ + searchId: SEARCH_ID, + }); + }); + + it("experience on an expired trip exits 4 with the re-search hint", async () => { + signIn(s.home); + s.fake({ + routes: [ + { op: "getTripExperience", answers: [problem(404, "not_found")] }, + ], + }); + const result = await s.run(["flights", "experience", EXPERIENCE_TRIP]); + expect(result.code).toBe(4); + expect(result.err).toMatch(/expired|search again|re-open/i); + }); + + it("experience without a tripId is a usage error, no request", async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run(["flights", "experience"]); + expect(result.code).toBe(2); + expect(result.err).toContain("flights experience "); + expect(fake.seen).toEqual([]); + }); +}); + +describe("flights booking-link", () => { + const required = [ + "--trip", + TRIP_ID, + "--from", + "SIN", + "--to", + "BKK", + "--date", + "2099-08-01", + ]; + + it("maps every flag to the query and prints the booking URL", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("flights-booking-link")] }); + const result = await s.run([ + "flights", + "booking-link", + FARE_ID, + "--trip", + TRIP_ID, + "--search", + SEARCH_ID, + "--fare-option", + "uuid-1", + "--from", + "SIN", + "--to", + "BKK", + "--date", + "2099-08-01", + "--return", + "2099-08-08", + "--cabin", + "business", + "--adults", + "2", + "--children", + "1", + "--infants", + "1", + "--site", + "SG", + "--currency", + "USD", + "--locale", + "en", + "--from-city", + "--to-city", + ]); + + expect(result.code).toBe(0); + expect(json(result)).toEqual(body("flights-booking-link")); + const [sent] = fake.requests("getFareBookingLink"); + expect(sent?.pathParams.fareId).toBe(FARE_ID); + expect(query(sent)).toEqual({ + tripId: TRIP_ID, + searchId: SEARCH_ID, + fareOptionId: "uuid-1", + from: "SIN", + to: "BKK", + fromDate: "2099-08-01", + toDate: "2099-08-08", + cabin: "business", + adults: "2", + children: "1", + infants: "1", + siteCode: "SG", + currency: "USD", + locale: "en", + fromCity: "true", + toCity: "true", + }); + }); + + it("accepts --children 0 and --infants 0", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("flights-booking-link")] }); + const result = await s.run([ + "flights", + "booking-link", + FARE_ID, + "--fare-option", + "uuid-1", + ...required, + "--children", + "0", + "--infants", + "0", + ]); + expect(result.code).toBe(0); + expect(query(fake.requests("getFareBookingLink")[0])).toMatchObject({ + children: "0", + infants: "0", + }); + }); + + for (const form of [ + ["--fare-option", "uuid-1", "--fare-option", "uuid-2"], + ["--fare-option", "uuid-1,uuid-2"], + ]) { + it(`${form.join(" ")} sends one fare option per leg`, async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("flights-booking-link")] }); + const result = await s.run([ + "flights", + "booking-link", + FARE_ID, + ...form, + ...required, + ]); + expect(result.code).toBe(0); + expect( + fake.requests("getFareBookingLink")[0]?.query.getAll("fareOptionId"), + ).toEqual(["uuid-1,uuid-2"]); + }); + } + + it("without the required flags is a usage error, no request", async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run(["flights", "booking-link", FARE_ID]); + expect(result.code).toBe(2); + expect(result.err).toMatch(/--trip is required|Usage/); + expect(fake.seen).toEqual([]); + }); + + it("without --fare-option is a usage error that points at `flights fares`", async () => { + // A Book-on-Wego link with no selected branded fare dead-ends at checkout. + signIn(s.home); + const fake = s.fake(); + const result = await s.run([ + "flights", + "booking-link", + FARE_ID, + ...required, + ]); + expect(result.code).toBe(2); + expect(result.err).toMatch(/--fare-option is required/); + expect(result.err).toContain("wego flights fares"); + expect(fake.seen).toEqual([]); + }); + + for (const extra of [ + ["--date", "2099-02-30"], + ["--date", "01-08-2099"], + ["--adults", "10"], + ["--children", "9"], + ["--infants", "9"], + ["--adults", "1", "--infants", "2"], + ["--return", "2099-13-01"], + ]) { + it(`booking-link ${extra.join(" ")} is a usage error, no request`, async () => { + signIn(s.home); + const fake = s.fake(); + // The later --date wins over the one in `required`. + const result = await s.run([ + "flights", + "booking-link", + FARE_ID, + "--fare-option", + "uuid-1", + ...required, + ...extra, + ]); + expect(result.code).toBe(2); + expect(fake.seen).toEqual([]); + }); + } + + it("a repeated fare option is a usage error, no request", async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run([ + "flights", + "booking-link", + FARE_ID, + "--fare-option", + "uuid-1", + "--fare-option", + "uuid-1", + ...required, + ]); + expect(result.code).toBe(2); + expect(result.err).toMatch(/must not repeat a fare option id/); + expect(fake.seen).toEqual([]); + }); + + for (const form of [ + ["--fare-option", "uuid-1,,uuid-2"], + ["--fare-option", "uuid-1,"], + ["--fare-option", ",uuid-1"], + ["--fare-option", " "], + ["--fare-option=uuid-1,,uuid-2"], + ["--fare-option", "uuid-1", "--fare-option", " "], + ]) { + it(`a blank fare option id (${JSON.stringify(form)}) is a usage error, never dropped`, async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run([ + "flights", + "booking-link", + FARE_ID, + ...form, + ...required, + ]); + expect(result.code).toBe(2); + expect(result.err).toMatch(/blank fare option id/); + expect(fake.seen).toEqual([]); + }); + } +}); + +describe("flights share", () => { + const leg = ["flights", "share", "SIN", "BKK", "2099-09-15"]; + + it("maps positionals and every flag to the query and prints the durable URL", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("flights-search-link")] }); + const result = await s.run([ + ...leg, + "--return", + "2099-09-22", + "--cabin", + "business", + "--adults", + "2", + "--children", + "1", + "--infants", + "1", + "--site", + "SG", + "--currency", + "USD", + "--locale", + "en", + "--from-city", + "--to-city", + ]); + + expect(result.code).toBe(0); + expect(json(result)).toEqual(body("flights-search-link")); + // No search-scoped id rides along: the link outlives any search. + expect(query(fake.requests("getFlightSearchLink")[0])).toEqual({ + from: "SIN", + to: "BKK", + fromDate: "2099-09-15", + toDate: "2099-09-22", + cabin: "business", + adults: "2", + children: "1", + infants: "1", + siteCode: "SG", + currency: "USD", + locale: "en", + fromCity: "true", + toCity: "true", + }); + }); + + it("inherits the stored currency, locale and site, over the account market", async () => { + signIn(s.home, { accessToken: "access-1", market: "AE" }); + writeSettings(s.home, { currency: "SAR", locale: "ar", site: "SA" }); + const fake = s.fake({ routes: [route("flights-search-link")] }); + expect((await s.run(leg)).code).toBe(0); + expect(query(fake.requests("getFlightSearchLink")[0])).toMatchObject({ + currency: "SAR", + locale: "ar", + siteCode: "SA", + }); + }); + + it("an explicit flag beats the stored setting", async () => { + signIn(s.home); + writeSettings(s.home, { currency: "SAR", site: "SA" }); + const fake = s.fake({ routes: [route("flights-search-link")] }); + expect( + (await s.run([...leg, "--currency", "USD", "--site", "SG"])).code, + ).toBe(0); + expect(query(fake.requests("getFlightSearchLink")[0])).toMatchObject({ + currency: "USD", + siteCode: "SG", + }); + }); + + for (const cabin of ["economy", "premium_economy", "business", "first"]) { + it(`accepts the published cabin ${cabin}`, async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("flights-search-link")] }); + expect((await s.run([...leg, "--cabin", cabin])).code).toBe(0); + expect(fake.requests("getFlightSearchLink")[0]?.query.get("cabin")).toBe( + cabin, + ); + }); + } + + const refused: [string, string[]][] = [ + ["no positionals", ["flights", "share"]], + ["one positional", ["flights", "share", "SIN"]], + ["two positionals", ["flights", "share", "SIN", "BKK"]], + ["an unknown flag", [...leg, "--trip", "abc:TR1"]], + ["a fourth positional", [...leg, "extra"]], + ["--adults 0", [...leg, "--adults", "0"]], + ["--adults 10", [...leg, "--adults", "10"]], + ["--children -1", [...leg, "--children", "-1"]], + ["--children 9", [...leg, "--children", "9"]], + ["--infants -1", [...leg, "--infants", "-1"]], + ["--infants 9", [...leg, "--infants", "9"]], + ["more infants than adults", [...leg, "--adults", "1", "--infants", "2"]], + ["more infants than the default adult", [...leg, "--infants", "2"]], + ["a wrong date shape", ["flights", "share", "SIN", "BKK", "15-09-2099"]], + ["a non-calendar date", ["flights", "share", "SIN", "BKK", "2099-02-30"]], + ["a non-calendar return", [...leg, "--return", "2099-13-01"]], + ["a malformed return", [...leg, "--return", "nope"]], + ]; + for (const [what, argv] of refused) { + it(`share with ${what} is a usage error, no request`, async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run(argv); + expect(result.code).toBe(2); + expect(fake.seen).toEqual([]); + }); + } + + it("an unknown --cabin names the published set, no request", async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run([...leg, "--cabin", "coach"]); + expect(result.code).toBe(2); + expect(result.err).toContain("--cabin must be one of"); + expect(fake.seen).toEqual([]); + }); + + it("--help prints its usage on stdout with exit 0", async () => { + const result = await s.run(["flights", "share", "--help"]); + expect(result.code).toBe(0); + expect(result.out).toContain("flights share"); + expect(result.err).toBe(""); + }); +}); + +describe("flights help and dispatch", () => { + for (const help of ["-h", "--help", "help"]) { + it(`flights ${help}: the group usage on stdout, exit 0, empty stderr`, async () => { + const result = await s.run(["flights", help]); + expect(result.code).toBe(0); + expect(result.out).toMatch(/^Usage: wego flights/); + expect(result.out).toMatch(/^ {2}search /m); + expect(result.out).toMatch(/^ {2}booking-link /m); + expect(result.err).toBe(""); + }); + } + + for (const sub of [ + "search", + "results", + "trip", + "experience", + "fares", + "booking-link", + ]) { + for (const help of ["help", "--help", "-h"]) { + it(`flights ${sub} ${help}: that command's usage, exit 0, no request`, async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run(["flights", sub, help]); + expect(result.code).toBe(0); + expect(result.out).toContain(`Usage: wego flights ${sub}`); + expect(result.err).toBe(""); + expect(fake.seen).toEqual([]); + }); + } + } + + it("an unknown sub-command exits 2 with the usage on stderr", async () => { + const result = await s.run(["flights", "bogus"]); + expect(result.code).toBe(2); + expect(result.out).toBe(""); + expect(result.err).toMatch(/Unknown flights sub-command: bogus/); + expect(result.err).toMatch(/Usage:/); + }); + + it("a bare `flights` exits 2 with the usage on stderr", async () => { + const result = await s.run(["flights"]); + expect(result.code).toBe(2); + expect(result.out).toBe(""); + expect(result.err).toMatch(/Usage/); + }); + + it("an unknown leaf option exits 2, not confused with --help", async () => { + const result = await s.run(["flights", "results", "--bogus"]); + expect(result.code).toBe(2); + expect(result.out).toBe(""); + expect(result.err).toMatch(/Unknown option: --bogus/); + }); +}); diff --git a/integration/harness/binary.ts b/integration/harness/binary.ts new file mode 100644 index 0000000..2abd775 --- /dev/null +++ b/integration/harness/binary.ts @@ -0,0 +1,111 @@ +/** + * The binary this suite drives: the one it is handed, or one it compiles. + * + * `WEGO_INTEGRATION_BINARY` names an already-built binary, which is how the release + * run tests the exact artifact it is about to publish, on each target's own runner. + * Without it, the host binary is compiled from this checkout, the way a pull request + * and a developer run it. Either way the suite drives a single-file binary as its + * own process: its argv parsing, exit codes and stream split are what an agent + * depends on, and no in-process test sees them. + * + * A compiled binary bakes nothing (`--env 'WEGO_BUILD_*'` is not passed), so it + * reports `0.0.0-dev`. Every endpoint comes from the runtime env `wego.ts` sets. + */ + +import { + accessSync, + chmodSync, + constants, + copyFileSync, + mkdtempSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const BINARY_ENV = "WEGO_INTEGRATION_BINARY"; + +const CLI_DIR = fileURLToPath(new URL("../../", import.meta.url)); + +/** A cold `bun build --compile` is slow, not unbounded. */ +const COMPILE_TIMEOUT_MS = 180_000; + +function checkProvided(path: string): string { + let isFile = false; + try { + isFile = statSync(path).isFile(); + } catch { + throw new Error(`${BINARY_ENV}=${path} does not exist`); + } + if (!isFile) throw new Error(`${BINARY_ENV}=${path} is not a file`); + if (process.platform !== "win32") { + try { + accessSync(path, constants.X_OK); + } catch { + throw new Error(`${BINARY_ENV}=${path} is not executable`); + } + } + return path; +} + +async function compileHost(): Promise { + const dir = mkdtempSync(join(tmpdir(), "wego-integration-bin-")); + const path = join(dir, process.platform === "win32" ? "wego.exe" : "wego"); + // `process.execPath` pins the compiler to the Bun running this suite. stdout is + // ignored, not piped: only stderr is drained, and a full pipe would block. + const build = Bun.spawn( + [ + process.execPath, + "build", + "--compile", + "--no-compile-autoload-dotenv", + "--outfile", + path, + "./src/index.ts", + ], + { cwd: CLI_DIR, stdout: "ignore", stderr: "pipe" }, + ); + const timer = setTimeout(() => build.kill(), COMPILE_TIMEOUT_MS); + const [stderr, code] = await Promise.all([ + new Response(build.stderr).text(), + build.exited, + ]); + clearTimeout(timer); + if (code !== 0) { + throw new Error(`compiling the host binary failed (${code}):\n${stderr}`); + } + return path; +} + +/** A binary names itself after its file (`program-name.ts`), and a release asset is + * `wego-linux-x64`. Installed, it is `wego`, so the suite drives a copy by that + * name: what a user's help and usage lines actually say. */ +function installAsWego(provided: string): string { + const dir = mkdtempSync(join(tmpdir(), "wego-integration-bin-")); + const path = join(dir, process.platform === "win32" ? "wego.exe" : "wego"); + copyFileSync(provided, path); + chmodSync(path, 0o755); + return path; +} + +/** The binary's path, compiled at most once per `bun test` run. */ +export async function resolveBinary(): Promise { + const provided = process.env[BINARY_ENV]?.trim(); + const path = provided + ? installAsWego(checkProvided(provided)) + : await compileHost(); + process.env[BINARY_ENV] = path; + return path; +} + +/** For scenario files: the path `preload.ts` resolved. */ +export function binaryPath(): string { + const path = process.env[BINARY_ENV]; + if (!path) { + throw new Error( + "no binary: run through `bun run test:integration`, which preloads integration/harness/preload.ts", + ); + } + return path; +} diff --git a/integration/harness/contract.ts b/integration/harness/contract.ts new file mode 100644 index 0000000..a7fc913 --- /dev/null +++ b/integration/harness/contract.ts @@ -0,0 +1,342 @@ +/** + * The contract every exchange in the fake is checked against: `contract/openapi.json`, + * the vendored copy of the API's published document. + * + * This is what keeps the fake honest (#1328). A hand-written stand-in can only fail + * when it disagrees with itself; a fake whose every request and every answer must + * satisfy the API's own published schema fails when the CLI and the API disagree, + * which is the one disagreement this tier exists to catch. + * + * A validator for the slice of JSON Schema the document uses, not a general one: + * `type`, `properties`, `required`, `additionalProperties`, `items`, `enum`, `const`, + * `anyOf`, `oneOf`, `pattern`, the length/size/range bounds and local `$ref`. A + * keyword outside that slice fails loudly (`unsupported`), so a contract refresh that + * starts using one cannot be validated vacuously. + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +type Schema = Record; + +interface Parameter { + in: "path" | "query" | "header" | "cookie"; + name: string; + required?: boolean; + schema: Schema; +} + +export interface Operation { + method: string; + /** The template, e.g. `/v1/flights/trips/{tripId}`. */ + path: string; + operationId: string; + parameters: Parameter[]; + requestBody?: Schema; + /** Status → media type → schema. */ + responses: Record>; +} + +const CONTRACT_PATH = fileURLToPath( + new URL("../../contract/openapi.json", import.meta.url), +); + +const document = JSON.parse(readFileSync(CONTRACT_PATH, "utf8")) as { + paths: Record>>; + components: { schemas: Record }; +}; + +const KNOWN_KEYWORDS = new Set([ + "type", + "properties", + "required", + "additionalProperties", + "items", + "enum", + "const", + "anyOf", + "oneOf", + "pattern", + "minLength", + "maxLength", + "minimum", + "maximum", + "minItems", + "maxItems", + "$ref", + // Annotations: no bearing on validity. + "description", + "default", + "example", + "examples", + "format", + "title", + "deprecated", + "readOnly", + "writeOnly", +]); + +function operations(): Operation[] { + const out: Operation[] = []; + for (const [path, item] of Object.entries(document.paths)) { + for (const [method, raw] of Object.entries(item)) { + const responses: Operation["responses"] = {}; + const rawResponses = (raw.responses ?? {}) as Record< + string, + { content?: Record } + >; + for (const [status, response] of Object.entries(rawResponses)) { + responses[status] = Object.fromEntries( + Object.entries(response.content ?? {}).map(([type, media]) => [ + type, + media.schema, + ]), + ); + } + const body = raw.requestBody as + | { content?: Record } + | undefined; + out.push({ + method: method.toUpperCase(), + path, + operationId: String(raw.operationId), + parameters: (raw.parameters ?? []) as Parameter[], + requestBody: body?.content?.["application/json"]?.schema, + responses, + }); + } + } + return out; +} + +const OPERATIONS = operations(); + +export function operationById(id: string): Operation { + const op = OPERATIONS.find((o) => o.operationId === id); + if (!op) throw new Error(`contract: no operation "${id}"`); + return op; +} + +/** The operation a request addresses, and its path parameters. Literal segments + * win over templated ones, so `/v1/places/nearby` is never read as a place id. */ +export function matchOperation( + method: string, + pathname: string, +): { op: Operation; pathParams: Record } | undefined { + const segments = pathname.split("/"); + let best: + | { op: Operation; pathParams: Record; literals: number } + | undefined; + for (const op of OPERATIONS) { + if (op.method !== method) continue; + const template = op.path.split("/"); + if (template.length !== segments.length) continue; + const pathParams: Record = {}; + let literals = 0; + let ok = true; + template.forEach((part, i) => { + const actual = segments[i] ?? ""; + const name = /^\{(.+)\}$/.exec(part)?.[1]; + if (name) pathParams[name] = decodeURIComponent(actual); + else if (part === actual) literals += 1; + else ok = false; + }); + if (ok && (!best || literals > best.literals)) { + best = { op, pathParams, literals }; + } + } + return best && { op: best.op, pathParams: best.pathParams }; +} + +function resolve(schema: Schema): Schema { + const ref = schema.$ref; + if (typeof ref !== "string") return schema; + const name = /^#\/components\/schemas\/(.+)$/.exec(ref)?.[1]; + const target = name ? document.components.schemas[name] : undefined; + if (!target) throw new Error(`contract: unresolvable $ref ${ref}`); + return resolve(target); +} + +function typeOf(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (typeof value === "number") { + return Number.isInteger(value) ? "integer" : "number"; + } + return typeof value; +} + +function typeMatches(declared: string, actual: string): boolean { + return declared === actual || (declared === "number" && actual === "integer"); +} + +/** Every violation of `schema` by `value`, as `: `. Empty = valid. */ +export function validate(value: unknown, schema: Schema, at = "$"): string[] { + const s = resolve(schema); + for (const key of Object.keys(s)) { + if (!KNOWN_KEYWORDS.has(key) && !key.startsWith("x-")) { + return [`${at}: unsupported schema keyword "${key}"`]; + } + } + const errors: string[] = []; + const actual = typeOf(value); + + if (s.anyOf || s.oneOf) { + const branches = (s.anyOf ?? s.oneOf) as Schema[]; + const passing = branches.filter((b) => validate(value, b, at).length === 0); + if (s.anyOf && passing.length === 0) { + errors.push(`${at}: matches none of anyOf`); + } + if (s.oneOf && passing.length !== 1) { + errors.push(`${at}: matches ${passing.length} of oneOf, expected 1`); + } + } + if (s.type !== undefined) { + const declared = Array.isArray(s.type) ? s.type : [s.type]; + if (!declared.some((t) => typeMatches(String(t), actual))) { + return [ + ...errors, + `${at}: expected ${declared.join("|")}, got ${actual}`, + ]; + } + } + if ("const" in s && value !== s.const) { + errors.push(`${at}: expected ${JSON.stringify(s.const)}`); + } + if (Array.isArray(s.enum) && !s.enum.includes(value)) { + errors.push(`${at}: ${JSON.stringify(value)} not in enum`); + } + if (typeof value === "string") { + if (typeof s.pattern === "string" && !new RegExp(s.pattern).test(value)) { + errors.push(`${at}: does not match ${s.pattern}`); + } + if (typeof s.minLength === "number" && value.length < s.minLength) { + errors.push(`${at}: shorter than ${s.minLength}`); + } + if (typeof s.maxLength === "number" && value.length > s.maxLength) { + errors.push(`${at}: longer than ${s.maxLength}`); + } + } + if (typeof value === "number") { + if (typeof s.minimum === "number" && value < s.minimum) { + errors.push(`${at}: below ${s.minimum}`); + } + if (typeof s.maximum === "number" && value > s.maximum) { + errors.push(`${at}: above ${s.maximum}`); + } + } + if (Array.isArray(value)) { + if (typeof s.minItems === "number" && value.length < s.minItems) { + errors.push(`${at}: fewer than ${s.minItems} items`); + } + if (typeof s.maxItems === "number" && value.length > s.maxItems) { + errors.push(`${at}: more than ${s.maxItems} items`); + } + if (s.items) { + value.forEach((item, i) => { + errors.push(...validate(item, s.items as Schema, `${at}[${i}]`)); + }); + } + } + if (actual === "object") { + const obj = value as Record; + const props = (s.properties ?? {}) as Record; + for (const name of (s.required ?? []) as string[]) { + if (!(name in obj)) errors.push(`${at}.${name}: required`); + } + for (const [name, v] of Object.entries(obj)) { + const prop = props[name]; + if (prop) errors.push(...validate(v, prop, `${at}.${name}`)); + else if (s.additionalProperties === false) { + errors.push(`${at}.${name}: not declared`); + } else if (typeof s.additionalProperties === "object") { + errors.push( + ...validate(v, s.additionalProperties as Schema, `${at}.${name}`), + ); + } + } + } + return errors; +} + +/** A query or path value arrives as text; coerce it to what its schema declares + * before validating, the way the API's own parser does. */ +function coerce(raw: string, schema: Schema): unknown { + const s = resolve(schema); + const types = Array.isArray(s.type) ? s.type : [s.type]; + if (types.includes("integer") || types.includes("number")) { + const n = Number(raw); + return raw.trim() !== "" && Number.isFinite(n) ? n : raw; + } + if (types.includes("boolean")) { + return raw === "true" ? true : raw === "false" ? false : raw; + } + return raw; +} + +/** Every way a request breaks the contract: unknown or invalid query parameters, + * invalid path parameters, a missing required parameter, an invalid JSON body. */ +export function validateRequest( + op: Operation, + url: URL, + pathParams: Record, + body: unknown, +): string[] { + const errors: string[] = []; + const declared = new Map( + op.parameters.filter((p) => p.in === "query").map((p) => [p.name, p]), + ); + for (const name of new Set(url.searchParams.keys())) { + const param = declared.get(name); + if (!param) { + errors.push(`query ${name}: not declared by ${op.operationId}`); + continue; + } + const values = url.searchParams.getAll(name); + const schema = resolve(param.schema); + const value = + schema.type === "array" + ? values + .flatMap((v) => v.split(",")) + .map((v) => coerce(v, (schema.items ?? {}) as Schema)) + : coerce(values[values.length - 1] ?? "", schema); + errors.push(...validate(value, schema, `query ${name}`)); + } + for (const param of op.parameters) { + if (param.in === "path") { + errors.push( + ...validate( + coerce(pathParams[param.name] ?? "", param.schema), + param.schema, + `path ${param.name}`, + ), + ); + } else if ( + param.in === "query" && + param.required && + !url.searchParams.has(param.name) + ) { + errors.push(`query ${param.name}: required`); + } + } + if (op.requestBody) errors.push(...validate(body, op.requestBody, "body")); + return errors; +} + +/** Every way an answer breaks the contract: an undeclared status, an undeclared + * media type, or a body its schema rejects. */ +export function validateResponse( + op: Operation, + status: number, + contentType: string, + body: unknown, +): string[] { + const response = op.responses[String(status)]; + if (!response) return [`status ${status}: not declared by ${op.operationId}`]; + const media = contentType.split(";")[0]?.trim() ?? ""; + if (!(media in response)) { + return [`status ${status}: media type ${media} not declared`]; + } + const schema = response[media]; + return schema ? validate(body, schema, `${status} body`) : []; +} diff --git a/integration/harness/fake.ts b/integration/harness/fake.ts new file mode 100644 index 0000000..5e45b12 --- /dev/null +++ b/integration/harness/fake.ts @@ -0,0 +1,341 @@ +/** + * The fake the binary talks to: one `Bun.serve` on a free loopback port, serving + * the API (`/v1/…`) and the auth server's token endpoint. + * + * Every request is matched to a scenario route by `operationId`, checked against + * the contract, and answered from that route's queue (the last answer repeats, so a + * settle loop that re-reads is served without the scenario counting reads). Every + * answer is checked against the contract too. A request no route expects, or any + * contract violation in either direction, is recorded, and `expectClean` fails the + * test with all of them. The fake itself never throws into the binary: it answers a + * 500 the contract does not declare, so the violation is visible from both sides. + */ + +import { + matchOperation, + operationById, + validateRequest, + validateResponse, +} from "./contract"; + +/** What a route answers with. A JSON body is sent as `application/json` below 400 + * and `application/problem+json` from 400, which is what the API does. */ +export type Answer = + | { status: number; body?: unknown; headers?: Record } + /** Close the connection mid-body: the length promises more than arrives. */ + | { fault: "truncated" } + /** A 200 whose body is not JSON: what a proxy's error page looks like. */ + | { fault: "non-json" }; + +export interface Route { + /** The contract's `operationId`, e.g. `getCurrentUser`. */ + op: string; + answers: Answer[]; + /** Match only requests this accepts; lets two routes share an operation. */ + when?: (seen: Seen) => boolean; +} + +/** One request, as the binary sent it. */ +export interface Seen { + op: string; + method: string; + path: string; + query: URLSearchParams; + pathParams: Record; + headers: Headers; + body: unknown; + /** The bearer token, if one was sent. */ + token?: string; +} + +/** One request to the auth server's token endpoint. */ +export interface TokenRequest { + grantType: string; + form: URLSearchParams; +} + +export interface TokenSet { + access_token: string; + refresh_token?: string; + expires_in?: number; + id_token?: string; + token_type?: string; +} + +export interface FakeOptions { + routes?: Route[]; + /** Bearer tokens the API accepts; any other token is answered 401. A refresh + * adds the token it issues. */ + accept?: string[]; + /** Answers for `grant_type=refresh_token`, in order, the last repeating. A + * `TokenSet` is a 200; an `Answer` is sent as is. */ + refresh?: (TokenSet | Answer)[]; +} + +export interface Fake { + url: string; + /** The auth server's authorize and token endpoints, served by the same fake. */ + authorizeUrl: string; + tokenUrl: string; + seen: Seen[]; + tokenRequests: TokenRequest[]; + violations: string[]; + /** Arm the authorization-code grant: what `authorize()` in `login.ts` issued. */ + armCode: (grant: { + code: string; + codeChallenge: string; + redirectUri: string; + tokens: TokenSet; + }) => void; + /** The requests one operation received, in order. */ + requests: (op: string) => Seen[]; + stop: () => void; +} + +export const TEST_CLIENT_ID = "integration-client"; + +const TOKEN_PATH = "/oauth/token"; +const AUTHORIZE_PATH = "/oauth/authorize"; + +export function problem( + status: number, + code: string, + detail = code, + headers: Record = {}, +): Answer { + return { + status, + headers: { "x-trace-id": `trace-${status}`, ...headers }, + body: { + type: "about:blank", + title: code, + status, + detail, + instance: "/v1", + code, + trace_id: `trace-${status}`, + }, + }; +} + +async function sha256Base64Url(text: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(text), + ); + return Buffer.from(digest).toString("base64url"); +} + +function json( + status: number, + body: unknown, + headers: Record = {}, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": + status >= 400 ? "application/problem+json" : "application/json", + ...headers, + }, + }); +} + +function oauthError(error: string): Response { + return new Response(JSON.stringify({ error }), { + status: 400, + headers: { "content-type": "application/json" }, + }); +} + +/** A port that accepts a connection and closes it before any answer: a reset, which + * a `Bun.serve` handler cannot produce. Point `WEGO_API_URL` at it. */ +export function startDropper(): { url: string; stop: () => void } { + const listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + data(socket) { + socket.end(); + }, + }, + }); + return { + url: `http://127.0.0.1:${listener.port}`, + stop: () => listener.stop(true), + }; +} + +export function startFake(options: FakeOptions = {}): Fake { + const routes = (options.routes ?? []).map((route) => { + operationById(route.op); // a typo in a scenario fails here, by name + return { ...route, served: 0 }; + }); + const accepted = new Set(options.accept ?? ["access-1"]); + const refreshAnswers = options.refresh ?? []; + let refreshServed = 0; + let armed: + | { + code: string; + codeChallenge: string; + redirectUri: string; + tokens: TokenSet; + } + | undefined; + const seen: Seen[] = []; + const tokenRequests: TokenRequest[] = []; + const violations: string[] = []; + + async function token(req: Request): Promise { + const form = new URLSearchParams(await req.text()); + const grantType = form.get("grant_type") ?? ""; + tokenRequests.push({ grantType, form }); + if (form.get("client_id") !== TEST_CLIENT_ID) { + violations.push(`token: client_id ${form.get("client_id")}`); + return oauthError("invalid_client"); + } + if (grantType === "authorization_code") { + if (!armed || form.get("code") !== armed.code) { + return oauthError("invalid_grant"); + } + const verifier = form.get("code_verifier") ?? ""; + if ((await sha256Base64Url(verifier)) !== armed.codeChallenge) { + violations.push( + "token: code_verifier does not match the S256 challenge", + ); + return oauthError("invalid_grant"); + } + if (form.get("redirect_uri") !== armed.redirectUri) { + violations.push(`token: redirect_uri ${form.get("redirect_uri")}`); + return oauthError("invalid_grant"); + } + accepted.add(armed.tokens.access_token); + return json(200, { token_type: "Bearer", ...armed.tokens }); + } + if (grantType === "refresh_token") { + const answer = + refreshAnswers[Math.min(refreshServed, refreshAnswers.length - 1)]; + refreshServed += 1; + if (!answer) return oauthError("invalid_grant"); + if ("access_token" in answer) { + accepted.add(answer.access_token); + return json(200, { token_type: "Bearer", ...answer }); + } + return send(answer); + } + return oauthError("unsupported_grant_type"); + } + + function send(answer: Answer): Response { + if ("fault" in answer) { + if (answer.fault === "non-json") { + return new Response("Bad gateway", { + status: 200, + headers: { "content-type": "text/html" }, + }); + } + return new Response("{", { + status: 200, + headers: { + "content-type": "application/json", + "content-length": "4096", + }, + }); + } + return json(answer.status, answer.body ?? {}, answer.headers); + } + + async function api(req: Request, url: URL): Promise { + const match = matchOperation(req.method, url.pathname); + if (!match) { + violations.push( + `${req.method} ${url.pathname}: not an operation in the contract`, + ); + return new Response("not in contract", { status: 500 }); + } + const { op, pathParams } = match; + const text = await req.text(); + let body: unknown; + if (text) { + try { + body = JSON.parse(text); + } catch { + violations.push(`${op.operationId}: request body is not JSON`); + } + } + const token = /^Bearer (.+)$/.exec( + req.headers.get("authorization") ?? "", + )?.[1]; + const s: Seen = { + op: op.operationId, + method: req.method, + path: url.pathname, + query: url.searchParams, + pathParams, + headers: req.headers, + body, + token, + }; + seen.push(s); + for (const v of validateRequest(op, url, pathParams, body)) { + violations.push(`${op.operationId} request ${v}`); + } + if (op.operationId !== "getHealth" && (!token || !accepted.has(token))) { + return send(problem(401, "invalid_token", "Missing or invalid token")); + } + const route = routes.find( + (r) => r.op === op.operationId && (r.when?.(s) ?? true), + ); + if (!route || route.answers.length === 0) { + violations.push( + `${op.operationId} ${url.pathname}${url.search}: no route expects it`, + ); + return new Response("unexpected", { status: 500 }); + } + const answer = + route.answers[Math.min(route.served, route.answers.length - 1)] ?? + route.answers[0]; + route.served += 1; + if (answer && !("fault" in answer)) { + for (const v of validateResponse( + op, + answer.status, + answer.status >= 400 ? "application/problem+json" : "application/json", + answer.body ?? {}, + )) { + violations.push(`${op.operationId} fixture ${v}`); + } + } + return send(answer as Answer); + } + + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === TOKEN_PATH && req.method === "POST") { + return token(req); + } + if (url.pathname === AUTHORIZE_PATH) { + violations.push("authorize: the binary fetched the authorize page"); + return new Response("browser only", { status: 400 }); + } + return api(req, url); + }, + }); + const url = `http://127.0.0.1:${server.port}`; + return { + url, + authorizeUrl: `${url}${AUTHORIZE_PATH}`, + tokenUrl: `${url}${TOKEN_PATH}`, + seen, + tokenRequests, + violations, + armCode: (grant) => { + armed = grant; + }, + requests: (op) => seen.filter((s) => s.op === op), + stop: () => server.stop(true), + }; +} diff --git a/integration/harness/fixtures.ts b/integration/harness/fixtures.ts new file mode 100644 index 0000000..07b61ca --- /dev/null +++ b/integration/harness/fixtures.ts @@ -0,0 +1,61 @@ +/** + * Fixtures: one API answer per file under `integration/fixtures/`, each naming the + * operation it answers so the contract check knows which schema applies. + * + * They are written by hand, usually by copying or editing another one, and + * `fixtures.test.ts` checks every one against the contract, so a contract refresh + * that invalidates a fixture fails it by name. + */ + +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Answer, Route } from "./fake"; + +export const FIXTURES_DIR = fileURLToPath( + new URL("../fixtures/", import.meta.url), +); + +export interface Fixture { + op: string; + status: number; + body: unknown; +} + +export function readFixture(name: string): Fixture { + return JSON.parse( + readFileSync(join(FIXTURES_DIR, `${name}.json`), "utf8"), + ) as Fixture; +} + +export function allFixtures(): [string, Fixture][] { + return readdirSync(FIXTURES_DIR) + .filter((f) => f.endsWith(".json")) + .sort() + .map((f) => { + const name = f.replace(/\.json$/, ""); + return [name, readFixture(name)]; + }); +} + +/** A fixture as an answer, optionally with its body adjusted for one scenario. */ +export function answer>( + name: string, + edit?: (body: T) => unknown, +): Answer { + const f = readFixture(name); + const body = structuredClone(f.body) as T; + return { status: f.status, body: edit ? edit(body) : body }; +} + +/** A route answering with fixtures, its operation read from the first one. */ +export function route( + ...answers: (string | Answer)[] +): Route & { answers: Answer[] } { + const first = answers.find((a): a is string => typeof a === "string"); + if (!first) throw new Error("route: name at least one fixture"); + return { + op: readFixture(first).op, + answers: answers.map((a) => (typeof a === "string" ? answer(a) : a)), + }; +} diff --git a/integration/harness/login.ts b/integration/harness/login.ts new file mode 100644 index 0000000..b764181 --- /dev/null +++ b/integration/harness/login.ts @@ -0,0 +1,54 @@ +/** + * Play the browser's part in `wego login`: read the authorize URL the binary + * prints, "approve" it, and deliver the callback to the binary's loopback listener. + */ + +import type { Fake, TokenSet } from "./fake"; +import type { CliResult, Home } from "./wego"; +import { spawnWego } from "./wego"; + +export interface LoginRun { + result: CliResult; + /** The authorize URL's query, as the binary built it. */ + authorize: URLSearchParams; +} + +export async function loginThroughBrowser( + fake: Fake, + home: Home, + opts: { + tokens: TokenSet; + /** Override what the "browser" sends back, to test a refusal. */ + callback?: (redirectUri: string, state: string) => string; + /** A callback delivered before the real one, e.g. a forged state, or one + * that claims another host. */ + before?: ( + redirectUri: string, + state: string, + ) => string | { url: string; headers: Record }; + }, +): Promise { + const running = spawnWego(["login", "--no-browser"], { fake, home }); + const escaped = fake.authorizeUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const [printed] = await running.waitForErr(new RegExp(`${escaped}\\?\\S+`)); + const authorize = new URL(printed).searchParams; + const redirectUri = authorize.get("redirect_uri") ?? ""; + const state = authorize.get("state") ?? ""; + fake.armCode({ + code: "code-1", + codeChallenge: authorize.get("code_challenge") ?? "", + redirectUri, + tokens: opts.tokens, + }); + if (opts.before) { + const early = opts.before(redirectUri, state); + const { url, headers } = + typeof early === "string" ? { url: early, headers: {} } : early; + await fetch(url, { headers }).catch(() => undefined); + } + const target = + opts.callback?.(redirectUri, state) ?? + `${redirectUri}?code=code-1&state=${encodeURIComponent(state)}`; + await fetch(target).catch(() => undefined); + return { result: await running.result, authorize }; +} diff --git a/integration/harness/preload.ts b/integration/harness/preload.ts new file mode 100644 index 0000000..3fa342c --- /dev/null +++ b/integration/harness/preload.ts @@ -0,0 +1,8 @@ +/** + * Resolve the binary once, before any scenario file loads, so a compile failure is + * one clear error rather than one per file. + */ +import { resolveBinary } from "./binary"; + +const path = await resolveBinary(); +console.log(`integration: driving ${path}`); diff --git a/integration/harness/scenario.ts b/integration/harness/scenario.ts new file mode 100644 index 0000000..be9c752 --- /dev/null +++ b/integration/harness/scenario.ts @@ -0,0 +1,56 @@ +/** + * Per-scenario setup: a fresh home, the fakes it starts, and one check at the end + * that neither side broke the contract and nothing unexpected was requested. + */ + +import { afterEach, beforeEach, expect } from "bun:test"; +import { type Fake, type FakeOptions, startFake } from "./fake"; +import { + type CliResult, + type Home, + makeHome, + type RunOptions, + wego, +} from "./wego"; + +export interface Scenario { + readonly home: Home; + /** Start the fake this scenario's binary talks to. */ + fake: (options?: FakeOptions) => Fake; + /** Run the binary against the last fake started (or none). */ + run: ( + args: string[], + opts?: Omit, + ) => Promise; +} + +export function useScenario(): Scenario { + let home: Home | undefined; + let fakes: Fake[] = []; + beforeEach(() => { + home = makeHome(); + fakes = []; + }); + afterEach(() => { + const violations = fakes.flatMap((f) => f.violations); + for (const f of fakes) f.stop(); + home?.cleanup(); + expect(violations).toEqual([]); + }); + const current = (): Home => { + if (!home) throw new Error("useScenario: no home outside a test"); + return home; + }; + return { + get home() { + return current(); + }, + fake: (options) => { + const f = startFake(options); + fakes.push(f); + return f; + }, + run: (args, opts = {}) => + wego(args, { ...opts, home: current(), fake: fakes[fakes.length - 1] }), + }; +} diff --git a/integration/harness/wego.ts b/integration/harness/wego.ts new file mode 100644 index 0000000..e34f530 --- /dev/null +++ b/integration/harness/wego.ts @@ -0,0 +1,199 @@ +/** + * Run the binary the way a user's shell does: real argv, real environment, its own + * process, and a home of its own. + * + * The environment is built from nothing rather than copied from the suite's, so a + * developer's `WEGO_*` exports, `.env.local` or real `~/.config/wego/` cannot reach + * a scenario. Every endpoint points at the fake; telemetry, the update notice and + * the background skill sync are off unless a scenario turns one on. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { binaryPath } from "./binary"; +import { type Fake, TEST_CLIENT_ID } from "./fake"; + +export type CliResult = { code: number; out: string; err: string }; + +export interface Home { + dir: string; + /** `$XDG_CONFIG_HOME/wego`, where every per-install file lives. */ + configDir: string; + credentialsPath: string; + cleanup: () => void; +} + +export function makeHome(): Home { + const dir = mkdtempSync(join(tmpdir(), "wego-integration-home-")); + const configDir = join(dir, ".config", "wego"); + return { + dir, + configDir, + credentialsPath: join(configDir, "credentials.json"), + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +export interface StoredCredentials { + accessToken: string; + refreshToken?: string; + expiresAt?: number; + market?: string; + idToken?: string; +} + +/** Start a scenario already logged in, as a previous `wego login` would have left + * it. `login.test.ts` is where the login itself is driven. */ +export function signIn( + home: Home, + creds: StoredCredentials = { + accessToken: "access-1", + refreshToken: "refresh-1", + expiresAt: Date.now() + 3_600_000, + }, +): void { + mkdirSync(home.configDir, { recursive: true, mode: 0o700 }); + writeFileSync(home.credentialsPath, `${JSON.stringify(creds, null, 2)}\n`, { + mode: 0o600, + }); +} + +export function writeSettings(home: Home, settings: Record) { + mkdirSync(home.configDir, { recursive: true, mode: 0o700 }); + writeFileSync( + join(home.configDir, "settings.json"), + `${JSON.stringify(settings, null, 2)}\n`, + { mode: 0o600 }, + ); +} + +/** An unsigned id_token carrying `claims`: the CLI decodes it, never verifies it. */ +export function idToken(claims: Record): string { + const part = (v: unknown) => + Buffer.from(JSON.stringify(v)).toString("base64url"); + return `${part({ alg: "none" })}.${part(claims)}.sig`; +} + +export interface RunOptions { + fake?: Fake; + home: Home; + env?: Record; + stdin?: string; +} + +/** A loopback port nothing listens on: the discard port. */ +const DEAD_PROXY = "http://127.0.0.1:9"; + +function environment(opts: RunOptions): Record { + const inherited = [ + "PATH", + "TMPDIR", + "TEMP", + "TMP", + "SystemRoot", + "SYSTEMROOT", + "ComSpec", + "PATHEXT", + "WINDIR", + ]; + const env: Record = {}; + for (const key of inherited) { + const value = process.env[key]; + if (value !== undefined) env[key] = value; + } + const fakeUrl = opts.fake?.url ?? "http://127.0.0.1:9"; + return { + ...env, + HOME: opts.home.dir, + USERPROFILE: opts.home.dir, + XDG_CONFIG_HOME: join(opts.home.dir, ".config"), + WEGO_API_URL: fakeUrl, + WEGO_AUTH_AUTHORIZE_URL: + opts.fake?.authorizeUrl ?? `${fakeUrl}/oauth/authorize`, + WEGO_AUTH_TOKEN_URL: opts.fake?.tokenUrl ?? `${fakeUrl}/oauth/token`, + WEGO_CLI_CLIENT_ID: TEST_CLIENT_ID, + WEGO_CLI_TELEMETRY: "0", + WEGO_CLI_NO_UPDATE_NOTICE: "1", + // Nothing leaves the machine. A release binary bakes a real analytics key, + // and a scenario that turns telemetry on must not send an event from CI, so + // every request that is not to loopback goes to a proxy that is not there. + HTTPS_PROXY: DEAD_PROXY, + HTTP_PROXY: DEAD_PROXY, + NO_PROXY: "127.0.0.1,localhost", + ...opts.env, + }; +} + +export interface Running { + /** Resolves with the first stderr text matching `pattern`. */ + waitForErr: (pattern: RegExp, timeoutMs?: number) => Promise; + result: Promise; + kill: () => void; +} + +/** Start the binary and keep talking to it: for login, where the scenario has to + * read the authorize URL off stderr before the command can finish. */ +export function spawnWego(args: string[], opts: RunOptions): Running { + const proc = Bun.spawn([binaryPath(), ...args], { + cwd: opts.home.dir, + env: environment(opts), + stdin: opts.stdin === undefined ? "ignore" : new Blob([opts.stdin]), + stdout: "pipe", + stderr: "pipe", + }); + let err = ""; + const listeners: (() => void)[] = []; + const errDone = (async () => { + const decoder = new TextDecoder(); + for await (const chunk of proc.stderr) { + err += decoder.decode(chunk, { stream: true }); + for (const notify of listeners) notify(); + } + })(); + const result = (async () => { + const [out, code] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + errDone, + ]).then(([o, c]) => [o, c] as const); + return { code, out, err }; + })(); + const waitForErr = (pattern: RegExp, timeoutMs = 10_000) => + new Promise((resolve, reject) => { + const check = () => { + const m = pattern.exec(err); + if (m) { + clearTimeout(timer); + resolve(m); + } + }; + const timer = setTimeout( + () => reject(new Error(`stderr never matched ${pattern}:\n${err}`)), + timeoutMs, + ); + listeners.push(check); + check(); + }); + return { waitForErr, result, kill: () => proc.kill() }; +} + +export async function wego( + args: string[], + opts: RunOptions, +): Promise { + return spawnWego(args, opts).result; +} + +/** stdout parsed as JSON, or a failure naming what was printed instead. */ +export function json>( + result: CliResult, +): T { + try { + return JSON.parse(result.out) as T; + } catch { + throw new Error( + `stdout is not JSON (exit ${result.code}):\n${result.out}\nstderr:\n${result.err}`, + ); + } +} diff --git a/integration/hotels.test.ts b/integration/hotels.test.ts new file mode 100644 index 0000000..8464ffd --- /dev/null +++ b/integration/hotels.test.ts @@ -0,0 +1,1315 @@ +/** + * `wego hotels …`: the seven sub-commands as a caller sees them, exit code, stdout + * JSON, stderr, and what reaches the wire. The `/rates` settle loop, the `rooms` + * parser and the empty-page note stay in `src/hotels.test.ts`. + * + * `rooms` is the slow one: its settle reads `/rates` 1.5 s apart, four steady + * reads for a page with rates and two for a completed empty one. So only one + * scenario waits out a full page; the rest answer a completed empty page, and + * the budget-exhausted paths (13.5 s for rates, 22.5 s for results) are the unit + * tests' job. + */ + +import { describe, expect, it } from "bun:test"; +import { type Answer, problem } from "./harness/fake"; +import { answer, readFixture, route } from "./harness/fixtures"; +import { useScenario } from "./harness/scenario"; +import { type CliResult, json, signIn, writeSettings } from "./harness/wego"; + +const s = useScenario(); + +/** Values the output must carry are read from the fixture, so editing a + * fixture does not break a scenario that asserts on them. */ +// biome-ignore lint/suspicious/noExplicitAny: a fixture body is untyped JSON +const body = (name: string) => readFixture(name).body as any; + +// biome-ignore lint/suspicious/noExplicitAny: a fixture body is untyped JSON +type Edit = (b: any) => unknown; + +const DATES = ["2099-03-01", "2099-03-05"]; +const SEARCH = ["hotels", "search", "DXB", ...DATES]; +const HOTEL = String(body("hotels-rates").hotelId); +const MINT = ["hotels", "rooms", HOTEL, ...DATES]; +const CITY_SID = body("hotels-search-create").searchId; +const HOTEL_SID = body("hotels-rooms-create").searchId; +const RATE_ID = body("hotels-rates").rates[0].id; + +/** A results page, edited for one scenario. */ +const page = (edit: Edit): Answer => answer("hotels-search-results", edit); + +/** A completed page with no hotels, over `totalCandidates` candidates. */ +const emptyPage = (complete: boolean, totalCandidates: number): Answer => + page((b) => ({ + ...b, + searchComplete: complete, + results: [], + metadata: { ...b.metadata, resultCount: 0, totalCandidates }, + })); + +/** A page whose settle signal (`snapshotCandidateCount`) reads `count`. */ +const counted = (count: number, complete = false): Answer => + page((b) => ({ + ...b, + searchComplete: complete, + metadata: { ...b.metadata, snapshotCandidateCount: count }, + })); + +/** A completed rates page with no rates: two reads settle it, 1.5 s. */ +const noRates = answer("hotels-rates", (b) => ({ + ...b, + searchComplete: true, + rates: [], +})); + +const created = (edit: Edit = (b) => b) => ({ + op: "createHotelSearch", + answers: [answer("hotels-rooms-create", edit)], +}); + +const createBody = (fake: { requests: (op: string) => { body: unknown }[] }) => + fake.requests("createHotelSearch")[0]?.body as Record; + +const query = ( + fake: { requests: (op: string) => { query: URLSearchParams }[] }, + op: string, +) => Object.fromEntries(fake.requests(op)[0]?.query ?? []); + +/** The #1534 rule: the API's request-scoped `*Source` copies inside `metadata` + * are stripped at print time, so the CLI's own top-level label is the ONE + * `*Source` a payload carries per knob. The `locale` echo stays. */ +function expectOneSourcePerKnob( + result: CliResult, + source: string, + fixture: string, +) { + expect(result.out).not.toContain("localeSource"); + expect(result.out).toContain(`"locale": "${body(fixture).metadata.locale}"`); + const printed = json<{ + currencyCodeSource: string; + metadata?: Record; + }>(result); + expect(printed.currencyCodeSource).toBe(source); + expect(result.out.split('"currencyCodeSource"').length - 1).toBe(1); + expect( + Object.keys(printed.metadata ?? {}).filter((k) => k.endsWith("Source")), + ).toEqual([]); +} + +/** A usage error: exit 2, nothing on stdout, the message on stderr, no request. */ +async function expectUsageError( + argv: string[], + says: string | RegExp, + never?: string, +) { + signIn(s.home); + const fake = s.fake(); + const result = await s.run(argv); + expect(result.code).toBe(2); + expect(result.out).toBe(""); + if (typeof says === "string") expect(result.err).toContain(says); + else expect(result.err).toMatch(says); + if (never !== undefined) expect(result.err).not.toContain(never); + expect(fake.seen).toEqual([]); +} + +describe("hotels help", () => { + for (const help of ["-h", "--help", "help"]) { + it(`hotels ${help}: the group usage on stdout, exit 0, empty stderr`, async () => { + const result = await s.run(["hotels", help]); + expect(result.code).toBe(0); + expect(result.out).toMatch(/^Usage: wego hotels/); + expect(result.out).toMatch(/^ {2}booking-link /m); + expect(result.err).toBe(""); + }); + } + + for (const sub of [ + "search", + "results", + "details", + "reviews", + "rooms", + "booking-link", + "share", + ]) { + for (const help of ["help", "-h", "--help"]) { + it(`hotels ${sub} ${help}: that command's own usage on stdout, exit 0`, async () => { + const result = await s.run(["hotels", sub, help]); + expect(result.code).toBe(0); + expect(result.out).toContain(`Usage: wego hotels ${sub}`); + expect(result.err).toBe(""); + }); + } + } + + it("an unknown sub-command prints the group usage on stderr, exit 2", async () => { + await expectUsageError(["hotels", "frobnicate"], "Usage: wego hotels"); + }); + + it("an unknown option on a leaf names it, exit 2", async () => { + await expectUsageError( + ["hotels", "booking-link", "85481", "--bogus"], + /Unknown option: --bogus/, + ); + }); +}); + +describe("hotels usage errors: exit 2, no request", () => { + const cases: [string, string[], string, string?][] = [ + [ + "search: a bad location", + [...SEARCH.slice(0, 2), "not-a-place", ...DATES], + "Invalid location", + ], + [ + "search: --children-ages that disagree with --children", + [...SEARCH, "--children", "1", "--children-ages", "5,11"], + "must equal --children", + ], + [ + "search: --children-ages without --children", + [...SEARCH, "--children-ages", "11"], + "requires --children", + ], + [ + "search: an age over 17", + [...SEARCH, "--children", "1", "--children-ages", "18"], + "0–17", + ], + [ + "search: nine children, over the cap of eight", + [...SEARCH, "--children", "9", "--children-ages", "1,2,3,4,5,6,7,8,9"], + "between 0 and 8", + ], + [ + "search: a non-numeric age", + [...SEARCH, "--children", "1", "--children-ages", "abc"], + "0–17", + ], + [ + "search: a negative age", + [...SEARCH, "--children", "1", "--children-ages", "-1"], + "0–17", + ], + [ + "search: an ages list that is empty once split", + [...SEARCH, "--children-ages", ","], + "at least one age", + ], + [ + "results: --wait=1", + ["hotels", "results", "sid-1", "--wait=1"], + "--wait takes no value", + ], + [ + "results: a non-numeric --page", + ["hotels", "results", "sid-1", "--page", "abc"], + "--page must be a positive integer", + ], + [ + "results: --page-size over the API's 50", + ["hotels", "results", "sid-1", "--page-size", "500"], + "--page-size must be between 1 and 50", + ], + [ + "results: an unknown --sort", + ["hotels", "results", "sid-1", "--sort", "cheapest"], + "--sort must be one of", + ], + [ + "results: the /reviews spelling of --guest-type", + ["hotels", "results", "sid-1", "--guest-type", "family_with_children"], + "--guest-type must be one of", + ], + ...["abc", "-1", "11", "Infinity"].map( + (bad): [string, string[], string] => [ + `results: --min-guest-rating ${bad}`, + [ + "hotels", + "results", + "sid-1", + "--guest-type", + "family", + "--min-guest-rating", + bad, + ], + "--min-guest-rating must be a number between 0 and 10", + ], + ), + [ + "results: --view, gone since the read has one projection", + ["hotels", "results", "sid-1", "--view", "card"], + "Unknown option: --view", + ], + [ + "results: a non-boolean --refundable", + ["hotels", "results", "sid-1", "--refundable", "yes"], + "--refundable must be one of", + ], + [ + "results: an extra positional", + ["hotels", "results", "sid-1", "extra"], + "Unexpected argument: extra", + ], + [ + "details: an unknown --view", + ["hotels", "details", "85481", "--view", "summary"], + "--view must be one of", + ], + [ + "details: a hotelId that is not a number", + ["hotels", "details", "not-a-number"], + "hotelId must be a positive integer", + ], + [ + "details: an extra positional", + ["hotels", "details", "85481", "extra"], + "Unexpected argument: extra", + ], + [ + "details: the extra positional is reported before an invalid id", + ["hotels", "details", "abc", "extra"], + "Unexpected argument: extra", + "hotelId must be a positive integer", + ], + ...[",", " ", ",,"].map((value): [string, string[], string] => [ + `reviews: --topics "${value}"`, + ["hotels", "reviews", "85481", "--topics", value], + "--topics needs at least one", + ]), + ...[ + ["--sort", "newest"], + ["--guest-type", "business"], + ["--view", "full"], + ].map(([flag, value]): [string, string[], string] => [ + `reviews: ${flag} ${value}`, + ["hotels", "reviews", "85481", flag, value], + `${flag} must be one of`, + ]), + [ + "reviews: --page-size is rejected, not clamped", + ["hotels", "reviews", "85481", "--page-size", "500"], + "--page-size must be between 1 and 50", + ], + [ + "reviews: a hotelId that is not a number", + ["hotels", "reviews", "not-a-number"], + "hotelId must be a positive integer", + ], + [ + "rooms: the dates positionally AND as flags", + [...MINT, "--check-in", "2099-04-01", "--check-out", "2099-04-05"], + "not both", + ], + [ + "rooms: one positional date", + ["hotels", "rooms", "85481", "2099-03-01"], + " ", + ], + [ + "rooms: positional dates alongside --search", + [...MINT, "--search", "sid-1"], + "positional dates", + ], + [ + "rooms: neither --search nor dates", + ["hotels", "rooms", "85481"], + " ", + ], + [ + "rooms: --search and the date flags", + [ + "hotels", + "rooms", + "85481", + "--search", + "sid-1", + "--check-in", + "2099-03-01", + "--check-out", + "2099-03-05", + ], + "--check-in, --check-out, not both", + ], + [ + "rooms: names only the conflicting flags actually passed", + ["hotels", "rooms", "85481", "--search", "sid-1", "--adults", "3"], + "--adults, not both", + "--check-in,", + ], + ...["--check-in", "--check-out", "--adults", "--children", "--rooms"].map( + (flag): [string, string[], string] => [ + `rooms: ${flag} alongside --search`, + ["hotels", "rooms", "85481", "--search", "sid-1", flag, "1"], + flag, + ], + ), + [ + "rooms: --children-ages alongside --search, before the --children pairing check", + ["hotels", "rooms", "85481", "--search", "sid-1", "--children-ages", "5"], + "not both", + "requires --children", + ], + [ + "rooms: --site alongside --search, whose search fixed the market", + ["hotels", "rooms", "85481", "--search", "sid-1", "--site", "AE"], + "--site", + ], + [ + "rooms: an extra positional", + [...MINT, "extra"], + "Unexpected argument: extra", + ], + ["booking-link: no --rate", ["hotels", "booking-link", "85481"], "--rate"], + [ + "booking-link: an extra positional", + ["hotels", "booking-link", "85481", "extra", "--rate", "r1"], + "Unexpected argument: extra", + ], + ...[ + ["--adults", "2"], + ["--children-ages", "11"], + ["--guests", "2:11"], + ].map(([flag, value]): [string, string[], string] => [ + `booking-link: the dropped ${flag}`, + ["hotels", "booking-link", "85481", "--rate", "r1", flag, value], + `Unknown option: ${flag}`, + ]), + [ + "booking-link: a malformed --country", + ["hotels", "booking-link", "85481", "--rate", "r1", "--country", "usa"], + "2-letter ISO country code", + ], + [ + "share: more rooms than the default two adults", + ["hotels", "share", "BKK", ...DATES, "--rooms", "3"], + "the default when --adults is omitted", + ], + [ + "share: a non-numeric --rooms", + ["hotels", "share", "BKK", ...DATES, "--rooms", "two"], + "--rooms", + ], + [ + "share: a hotelId, naming the city code as the way through", + ["hotels", "share", "710862", ...DATES], + "city code", + ], + [ + "share: lat,lng", + ["hotels", "share", "13.75,100.5", ...DATES], + "city code", + ], + [ + "share: a bare city name, with the city-code message", + ["hotels", "share", "bangkok", ...DATES], + "city code", + "hotelId", + ], + [ + "share: --children without ages, so no guessed age reaches the link", + ["hotels", "share", "BKK", ...DATES, "--children", "1"], + "--children-ages", + ], + [ + "share: --children-ages that disagree with --children", + [ + "hotels", + "share", + "BKK", + ...DATES, + "--children", + "2", + "--children-ages", + "5", + ], + "1 age(s) but --children is 2", + ], + [ + "share: an extra positional", + ["hotels", "share", "BKK", ...DATES, "extra"], + "Unexpected argument: extra", + ], + ]; + + for (const [name, argv, says, never] of cases) { + it(name, async () => { + await expectUsageError(argv, says, never); + }); + } +}); + +describe("hotels search", () => { + it("creates the search, prints the settled first page, and hints at a currency setting", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [route("hotels-search-create"), route("hotels-search-results")], + }); + const result = await s.run(SEARCH); + + expect(result.code).toBe(0); + expect(createBody(fake)).toMatchObject({ + cityCode: "DXB", + checkIn: DATES[0], + checkOut: DATES[1], + }); + expect(fake.requests("getHotelSearchResults")[0]?.pathParams.searchId).toBe( + CITY_SID, + ); + expect(fake.seen.every((r) => r.token === "access-1")).toBe(true); + const printed = json<{ results: { name: string }[]; settled: string }>( + result, + ); + expect(printed.results[0]?.name).toBe( + body("hotels-search-results").results[0].name, + ); + expect(printed.settled).toBe("converged"); + // The one stderr line on a fresh machine is the currency-setting hint the + // search prints while no currency is stored (issue #1386). + expect(result.err.trim().split("\n")).toEqual([ + expect.stringContaining("config set currency"), + ]); + }); + + for (const [rung, settings, flags, source, sent] of [ + [ + "a flag beats the setting", + { currency: "SAR" }, + ["--currency", "USD"], + "explicit", + "USD", + ], + [ + "the setting decides without a flag", + { currency: "SAR" }, + [], + "setting", + "SAR", + ], + ["neither leaves it to the API's default", {}, [], "default", undefined], + ] as const) { + it(`names the layer the currency came from: ${rung}`, async () => { + // The create and the settle read must carry the SAME resolved currency + // (#1400), so the page is priced in the unit the search was created in. + signIn(s.home); + writeSettings(s.home, settings); + const fake = s.fake({ + routes: [route("hotels-search-create"), route("hotels-search-results")], + }); + const result = await s.run([...SEARCH, ...flags]); + + expect(result.code).toBe(0); + expect( + json<{ currencyCodeSource: string }>(result).currencyCodeSource, + ).toBe(source); + expect(createBody(fake).currency).toBe(sent); + for (const read of fake.requests("getHotelSearchResults")) { + expect(read.query.get("currency") ?? undefined).toBe(sent); + } + }); + } + + it("prints one *Source per knob, top level, in the CLI's vocabulary", async () => { + signIn(s.home); + writeSettings(s.home, { currency: "SAR" }); + s.fake({ + routes: [route("hotels-search-create"), route("hotels-search-results")], + }); + const result = await s.run(SEARCH); + expect(result.code).toBe(0); + expectOneSourcePerKnob(result, "setting", "hotels-search-results"); + }); + + it("forwards an explicit --children 0 rather than refusing it", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [route("hotels-search-create"), route("hotels-search-results")], + }); + const result = await s.run([...SEARCH, "--children", "0"]); + expect(result.code).toBe(0); + expect(createBody(fake).children).toBe(0); + }); + + it("sends --children-ages on the create and prints the priced occupancy it echoes", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [route("hotels-search-create"), route("hotels-search-results")], + }); + const result = await s.run([ + ...SEARCH, + "--children", + "1", + "--children-ages", + "11", + ]); + + expect(result.code).toBe(0); + expect(createBody(fake).childrenAges).toEqual([11]); + expect(json<{ occupancy: unknown }>(result).occupancy).toEqual( + body("hotels-search-create").occupancy, + ); + expect(result.err.trim().split("\n")).toEqual([ + expect.stringContaining("config set currency"), + ]); + }); + + it("accepts exactly eight children's ages, the cap", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [route("hotels-search-create"), route("hotels-search-results")], + }); + const result = await s.run([ + ...SEARCH, + "--children", + "8", + "--children-ages", + "1,2,3,4,5,6,7,8", + ]); + expect(result.code).toBe(0); + expect(createBody(fake).childrenAges).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + }); + + it("re-reads while the page is empty and stops once hotels arrive and the count holds", async () => { + signIn(s.home); + const later = page((b) => ({ ...b, searchComplete: true })); + const fake = s.fake({ + routes: [ + route("hotels-search-create"), + { + op: "getHotelSearchResults", + answers: [ + page((b) => ({ + ...b, + searchComplete: false, + results: [], + metadata: { ...b.metadata, snapshotCandidateCount: 0 }, + })), + later, + ], + }, + ], + }); + const result = await s.run(SEARCH); + + expect(result.code).toBe(0); + // Empty, then the full page twice: the second full read confirms the count. + expect(fake.requests("getHotelSearchResults")).toHaveLength(3); + expect(json<{ results: { name: string }[] }>(result).results[0]?.name).toBe( + body("hotels-search-results").results[0].name, + ); + }); + + it("converges on a steady candidate count while searchComplete stays false (#1084)", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [ + route("hotels-search-create"), + { op: "getHotelSearchResults", answers: [counted(4), counted(7)] }, + ], + }); + const result = await s.run(SEARCH); + + expect(result.code).toBe(0); + // 4, then 7, then 7 again: equal across two reads, so it stops at the third. + expect(fake.requests("getHotelSearchResults")).toHaveLength(3); + const printed = json<{ settled: string; searchComplete: boolean }>(result); + expect(printed.settled).toBe("converged"); + expect(printed.searchComplete).toBe(false); + }); + + it("stops on a completed empty page and prints no still-settling hint", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [ + route("hotels-search-create"), + { op: "getHotelSearchResults", answers: [emptyPage(true, 12)] }, + ], + }); + const result = await s.run(SEARCH); + + expect(result.code).toBe(0); + // Complete, then the count confirmed on the next read: far short of the budget. + expect(fake.requests("getHotelSearchResults")).toHaveLength(2); + expect(result.err).not.toContain("No hotels have settled yet"); + expect(result.err).not.toContain("no hotels match"); + }); + + it("reports a completed zero-candidate search as a no-match, exit 0", async () => { + signIn(s.home); + s.fake({ + routes: [ + route("hotels-search-create"), + { op: "getHotelSearchResults", answers: [emptyPage(true, 0)] }, + ], + }); + const result = await s.run(SEARCH); + + expect(result.code).toBe(0); + expect(json<{ results: unknown[] }>(result).results).toEqual([]); + expect(result.err).toContain("Search complete –"); + expect(result.err).not.toContain("No hotels have settled yet"); + }); + + it("keeps the searchId as a re-run hint when the read after the create fails", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [ + route("hotels-search-create"), + { + op: "getHotelSearchResults", + answers: [ + problem(503, "upstream_unavailable", "try later", { + "retry-after": "0", + }), + ], + }, + ], + }); + const result = await s.run(SEARCH); + + expect(result.code).toBe(5); + expect(result.out).toBe(""); + expect(fake.requests("createHotelSearch")).toHaveLength(1); + expect(result.err).toContain(`re-run: wego hotels results ${CITY_SID}`); + }); +}); + +describe("hotels results", () => { + it("forwards paging, sort and filters under their published names", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("hotels-search-results")] }); + const result = await s.run([ + "hotels", + "results", + CITY_SID, + "--page", + "2", + "--sort", + "price_asc", + "--min-star", + "4", + "--refundable", + "true", + ]); + + expect(result.code).toBe(0); + expect(fake.seen[0]?.path).toBe(`/v1/hotels/searches/${CITY_SID}/results`); + expect(query(fake, "getHotelSearchResults")).toEqual({ + page: "2", + sort: "price_asc", + "min-star": "4", + refundable: "true", + }); + }); + + it("forwards --sort guest_rating_desc with the guest cohort", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("hotels-search-results")] }); + const result = await s.run([ + "hotels", + "results", + CITY_SID, + "--sort", + "guest_rating_desc", + "--guest-type", + "family", + "--min-guest-rating", + "8.5", + ]); + + expect(result.code).toBe(0); + expect(query(fake, "getHotelSearchResults")).toMatchObject({ + sort: "guest_rating_desc", + "guest-type": "family", + "min-guest-rating": "8.5", + }); + }); + + it("without --wait: one read, stamped `unsettled`, and an empty page says how to wait", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [{ op: "getHotelSearchResults", answers: [emptyPage(false, 0)] }], + }); + const result = await s.run(["hotels", "results", CITY_SID]); + + expect(result.code).toBe(0); + expect(fake.requests("getHotelSearchResults")).toHaveLength(1); + expect(json<{ settled: string }>(result).settled).toBe("unsettled"); + expect(result.err).toContain( + `No hotels have settled yet – re-run: wego hotels results ${CITY_SID} --wait`, + ); + }); + + it("--wait re-reads while empty and stops once hotels arrive, stderr quiet", async () => { + signIn(s.home); + writeSettings(s.home, { currency: "USD" }); + const fake = s.fake({ + routes: [ + { + op: "getHotelSearchResults", + answers: [ + page((b) => ({ + ...b, + searchComplete: false, + results: [], + metadata: { ...b.metadata, snapshotCandidateCount: 0 }, + })), + page((b) => ({ ...b, searchComplete: true })), + ], + }, + ], + }); + const result = await s.run(["hotels", "results", CITY_SID, "--wait"]); + + expect(result.code).toBe(0); + expect(fake.requests("getHotelSearchResults")).toHaveLength(3); + expect(json<{ results: { name: string }[] }>(result).results[0]?.name).toBe( + body("hotels-search-results").results[0].name, + ); + expect(result.err).toBe(""); + }); + + it("--wait prints no re-run hint on a completed empty page", async () => { + signIn(s.home); + s.fake({ + routes: [{ op: "getHotelSearchResults", answers: [emptyPage(true, 12)] }], + }); + const result = await s.run(["hotels", "results", CITY_SID, "--wait"]); + + expect(result.code).toBe(0); + expect(json<{ results: unknown[] }>(result).results).toEqual([]); + expect(result.err).not.toContain("No hotels have settled yet"); + }); + + it("--wait: a 401 mid-settle refreshes once and restarts the poll on the new token", async () => { + // The whole settle runs inside one authed call, which retries its callback + // once on a 401: the poll re-walks from the first read on the fresh token. + signIn(s.home); + const fake = s.fake({ + routes: [ + { + op: "getHotelSearchResults", + when: (r) => r.token === "access-1", + answers: [ + counted(0), + counted(2), + problem(401, "invalid_token", "token expired"), + ], + }, + { + op: "getHotelSearchResults", + when: (r) => r.token === "access-2", + answers: [counted(2)], + }, + ], + refresh: [{ access_token: "access-2", refresh_token: "refresh-2" }], + }); + const result = await s.run(["hotels", "results", CITY_SID, "--wait"]); + + expect(result.code).toBe(0); + const printed = json<{ + settled: string; + metadata: { snapshotCandidateCount: number }; + }>(result); + expect(printed.settled).toBe("converged"); + expect(printed.metadata.snapshotCandidateCount).toBe(2); + expect(fake.tokenRequests.map((t) => t.grantType)).toEqual([ + "refresh_token", + ]); + const reads = fake.requests("getHotelSearchResults"); + expect(reads.filter((r) => r.token === "access-1")).toHaveLength(3); + expect(reads.filter((r) => r.token === "access-2")).toHaveLength(2); + }); + + for (const [rung, settings, flags, source, sent] of [ + [ + "the stored currency, not the API's USD", + { currency: "SAR" }, + [], + "setting", + "SAR", + ], + [ + "an explicit flag over the setting", + { currency: "SAR" }, + ["--currency", "USD"], + "explicit", + "USD", + ], + ["the API's default with neither", {}, [], "default", undefined], + ] as const) { + it(`a bare read prices in ${rung}, and names the rung`, async () => { + // A searchId carries no currency, so a bare read applies the stored + // preference even when the search was created with a flag; repeating the + // flag on the read is the documented way to match it (docs/settings.md). + signIn(s.home); + writeSettings(s.home, settings); + const fake = s.fake({ routes: [route("hotels-search-results")] }); + const result = await s.run(["hotels", "results", CITY_SID, ...flags]); + + expect(result.code).toBe(0); + expect( + json<{ currencyCodeSource: string }>(result).currencyCodeSource, + ).toBe(source); + expect( + fake.requests("getHotelSearchResults")[0]?.query.get("currency") ?? + undefined, + ).toBe(sent); + }); + } + + it("prints one *Source per knob, top level, in the CLI's vocabulary", async () => { + signIn(s.home); + writeSettings(s.home, { currency: "SAR" }); + s.fake({ routes: [route("hotels-search-results")] }); + const result = await s.run(["hotels", "results", CITY_SID]); + expect(result.code).toBe(0); + expectOneSourcePerKnob(result, "setting", "hotels-search-results"); + }); +}); + +describe("hotels details and reviews", () => { + it("reviews prints the page and reads the hotel it was given", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("hotels-reviews")] }); + const result = await s.run(["hotels", "reviews", HOTEL]); + + expect(result.code).toBe(0); + expect(fake.seen[0]?.path).toBe(`/v1/hotels/${HOTEL}/reviews`); + expect( + json<{ metadata: { totalCandidates: number } }>(result).metadata + .totalCandidates, + ).toBe(body("hotels-reviews").metadata.totalCandidates); + expect(result.err).toBe(""); + }); + + it("reviews forwards each flag under its published parameter name", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("hotels-reviews")] }); + const result = await s.run([ + "hotels", + "reviews", + HOTEL, + "--topics", + "breakfast,pool", + "--guest-type", + "couple", + "--sort", + "rating_desc", + "--page-size", + "20", + "--view", + "detail", + ]); + + expect(result.code).toBe(0); + // Kebab for the net-new knob, camel for the mirrored one: one request + // legitimately carries both spellings. + expect(query(fake, "getHotelReviews")).toEqual({ + topics: "breakfast,pool", + "guest-type": "couple", + sort: "rating_desc", + pageSize: "20", + view: "detail", + }); + }); + + it("reviews trims the topics it forwards", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("hotels-reviews")] }); + const result = await s.run([ + "hotels", + "reviews", + HOTEL, + "--topics", + " breakfast , ,pool ", + ]); + expect(result.code).toBe(0); + expect(query(fake, "getHotelReviews").topics).toBe("breakfast,pool"); + }); + + it("reviews of an unknown hotel exits 4 with a hint", async () => { + signIn(s.home); + s.fake({ + routes: [{ op: "getHotelReviews", answers: [problem(404, "not_found")] }], + }); + const result = await s.run(["hotels", "reviews", "999999"]); + expect(result.code).toBe(4); + expect(result.out).toBe(""); + expect(result.err).toContain("Unknown hotel id"); + }); + + it("details and reviews inherit the stored locale and are sent no currency", async () => { + signIn(s.home); + writeSettings(s.home, { currency: "SAR", locale: "ar", site: "SA" }); + const fake = s.fake({ + routes: [route("hotels-details"), route("hotels-reviews")], + }); + expect((await s.run(["hotels", "details", HOTEL])).code).toBe(0); + expect((await s.run(["hotels", "reviews", HOTEL])).code).toBe(0); + + expect(query(fake, "getHotel")).toEqual({ locale: "ar" }); + expect(query(fake, "getHotelReviews")).toEqual({ locale: "ar" }); + }); +}); + +describe("hotels rooms", () => { + it("--search: reads a full page to settled, mints nothing, and inherits the currency but never the market", async () => { + // The one scenario that waits out four steady reads (4.5 s). + signIn(s.home); + writeSettings(s.home, { currency: "SAR", site: "SA" }); + const fake = s.fake({ routes: [route("hotels-rates")] }); + const result = await s.run([ + "hotels", + "rooms", + HOTEL, + "--search", + HOTEL_SID, + ]); + + expect(result.code).toBe(0); + expect(fake.requests("createHotelSearch")).toEqual([]); + expect(fake.requests("getHotelRates")).toHaveLength(4); + expect(query(fake, "getHotelRates")).toEqual({ + searchId: HOTEL_SID, + currency: "SAR", + }); + const printed = json<{ + rates: { id: string }[]; + settled: string; + occupancy?: unknown; + siteCode?: string; + }>(result); + expect(printed.rates[0]?.id).toBe(RATE_ID); + expect(printed.settled).toBe("converged"); + // No create, so no occupancy echo and no market to report. + expect(printed.occupancy).toBeUndefined(); + expect(printed.siteCode).toBeUndefined(); + expectOneSourcePerKnob(result, "setting", "hotels-rates"); + expect(result.err).not.toContain("no rooms"); + }); + + it("--search: keeps --currency and --locale legal, since both shape the read itself", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [{ op: "getHotelRates", answers: [noRates] }], + }); + const result = await s.run([ + "hotels", + "rooms", + HOTEL, + "--search", + HOTEL_SID, + "--currency", + "AED", + "--locale", + "ar", + ]); + + expect(result.code).toBe(0); + expect(fake.requests("createHotelSearch")).toEqual([]); + expect(query(fake, "getHotelRates")).toEqual({ + searchId: HOTEL_SID, + currency: "AED", + locale: "ar", + }); + expect( + json<{ currencyCodeSource: string }>(result).currencyCodeSource, + ).toBe("explicit"); + }); + + it("dates: mints a hotel search with the stored settings, reads rates in the same currency, and reports the market", async () => { + signIn(s.home); + writeSettings(s.home, { currency: "SAR", site: "SA", locale: "ar" }); + const echoed = { + siteCode: "SA", + occupancy: { adults: 2, childrenAges: [11], rooms: 1 }, + }; + const fake = s.fake({ + routes: [ + created((b) => ({ ...b, ...echoed })), + { op: "getHotelRates", answers: [noRates] }, + ], + }); + const result = await s.run([ + ...MINT, + "--children", + "1", + "--children-ages", + "11", + ]); + + expect(result.code).toBe(0); + expect(createBody(fake)).toEqual({ + hotelId: Number(HOTEL), + checkIn: DATES[0], + checkOut: DATES[1], + children: 1, + childrenAges: [11], + currency: "SAR", + siteCode: "SA", + locale: "ar", + }); + // ONE currency for the mint and the read it feeds, or the room is priced twice. + expect(query(fake, "getHotelRates")).toEqual({ + searchId: HOTEL_SID, + currency: "SAR", + locale: "ar", + }); + // A completed empty page settles in two reads. + expect(fake.requests("getHotelRates")).toHaveLength(2); + const printed = json<{ + settled: string; + siteCode: string; + siteCodeSource: string; + occupancy: unknown; + }>(result); + expect(printed.settled).toBe("converged"); + expect(printed.siteCode).toBe("SA"); + expect(printed.siteCodeSource).toBe("setting"); + expect(printed.occupancy).toEqual(echoed.occupancy); + expectOneSourcePerKnob(result, "setting", "hotels-rates"); + // A converged zero is this search's answer, never the hotel's. + expect(result.err).toContain("not proof the hotel has no rooms"); + expect(result.err).toContain(`hotels rooms ${HOTEL} `); + expect(result.err).not.toContain("re-run: wego hotels rooms"); + }); + + it("dates: explicit --site, --currency and --locale win the whole operation, and the source says so", async () => { + // A setting must not beat a flag for half of one command: the mint and the + // rates read both carry the flags. + signIn(s.home); + writeSettings(s.home, { currency: "SAR", site: "SA", locale: "ar" }); + const fake = s.fake({ + routes: [ + created((b) => ({ ...b, siteCode: "AE" })), + { op: "getHotelRates", answers: [noRates] }, + ], + }); + const result = await s.run([ + "hotels", + "rooms", + HOTEL, + "--check-in", + DATES[0], + "--check-out", + DATES[1], + "--site", + "AE", + "--currency", + "USD", + "--locale", + "en", + ]); + + expect(result.code).toBe(0); + expect(createBody(fake)).toMatchObject({ + siteCode: "AE", + currency: "USD", + locale: "en", + }); + expect(query(fake, "getHotelRates")).toMatchObject({ + currency: "USD", + locale: "en", + }); + const printed = json<{ + siteCode: string; + siteCodeSource: string; + currencyCodeSource: string; + }>(result); + expect(printed.siteCode).toBe("AE"); + expect(printed.siteCodeSource).toBe("explicit"); + expect(printed.currencyCodeSource).toBe("explicit"); + }); + + it("dates: a failed rates read keeps the minted searchId as a re-run hint", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [ + created(), + { + op: "getHotelRates", + answers: [ + problem(503, "upstream_unavailable", "try later", { + "retry-after": "0", + }), + ], + }, + ], + }); + const result = await s.run(MINT); + + expect(result.code).toBe(5); + expect(result.out).toBe(""); + expect(fake.requests("createHotelSearch")).toHaveLength(1); + expect(result.err).toContain( + `re-run: wego hotels rooms ${HOTEL} --search ${HOTEL_SID}`, + ); + }); + + it("--search: a failed rates read prints no re-run hint, the caller has the id", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [ + { + op: "getHotelRates", + answers: [ + problem(503, "upstream_unavailable", "try later", { + "retry-after": "0", + }), + ], + }, + ], + }); + const result = await s.run([ + "hotels", + "rooms", + HOTEL, + "--search", + HOTEL_SID, + ]); + + expect(result.code).toBe(5); + expect(fake.requests("createHotelSearch")).toEqual([]); + expect(result.err).not.toContain("re-run: wego hotels rooms"); + }); + + it("--search on a city search exits 6 after one read, naming the command that fixes it", async () => { + signIn(s.home); + const fake = s.fake({ + routes: [ + { + op: "getHotelRates", + answers: [ + problem(409, "rates_require_hotel_search", "not hotel-scoped"), + ], + }, + ], + }); + const result = await s.run([ + "hotels", + "rooms", + HOTEL, + "--search", + CITY_SID, + ]); + + expect(result.code).toBe(6); + // The settle never retries a scope the search cannot change. + expect(fake.requests("getHotelRates")).toHaveLength(1); + expect(result.err).toContain("this search is not hotel-scoped"); + expect(result.err).toContain("hotels rooms "); + }); +}); + +describe("hotels booking-link", () => { + it("sends no guests, and no countryCode unless asked", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("hotels-booking-link")] }); + const result = await s.run([ + "hotels", + "booking-link", + HOTEL, + "--rate", + RATE_ID, + ]); + + expect(result.code).toBe(0); + expect(fake.seen[0]?.pathParams).toEqual({ + hotelId: HOTEL, + rateId: RATE_ID, + }); + expect(query(fake, "getHotelRateBookingLink")).toEqual({}); + expect(json<{ bookingUrl: string }>(result).bookingUrl).toBe( + body("hotels-booking-link").bookingUrl, + ); + }); + + it("uppercases --country, like the info commands", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("hotels-booking-link")] }); + const result = await s.run([ + "hotels", + "booking-link", + HOTEL, + "--rate", + RATE_ID, + "--country", + "ae", + ]); + expect(result.code).toBe(0); + expect(query(fake, "getHotelRateBookingLink").countryCode).toBe("AE"); + }); +}); + +describe("hotels share", () => { + const SHARE = ["hotels", "share", "BKK", ...DATES]; + + it("forwards the city, dates and occupancy, and prints the durable link", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("hotels-search-link")] }); + const result = await s.run([...SHARE, "--adults", "2"]); + + expect(result.code).toBe(0); + // No stored site and no id_token market, so no siteCode goes on the wire. + expect(query(fake, "getHotelSearchLink")).toEqual({ + cityCode: "BKK", + checkIn: DATES[0], + checkOut: DATES[1], + adults: "2", + }); + expect(json<{ searchUrl: string }>(result).searchUrl).toBe( + body("hotels-search-link").searchUrl, + ); + }); + + it("sends --rooms, the same occupancy vocabulary as hotels search", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("hotels-search-link")] }); + const result = await s.run([...SHARE, "--adults", "4", "--rooms", "2"]); + expect(result.code).toBe(0); + expect(query(fake, "getHotelSearchLink")).toMatchObject({ + adults: "4", + rooms: "2", + }); + }); + + it("packs --children-ages into a CSV the API parses", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("hotels-search-link")] }); + const result = await s.run([ + ...SHARE, + "--children", + "2", + "--children-ages", + "5,9", + ]); + expect(result.code).toBe(0); + expect(query(fake, "getHotelSearchLink")).toMatchObject({ + children: "2", + childrenAges: "5,9", + }); + }); + + it("inherits the stored currency, locale and site, which the link hands on", async () => { + signIn(s.home); + writeSettings(s.home, { currency: "SAR", locale: "ar", site: "SA" }); + const fake = s.fake({ routes: [route("hotels-search-link")] }); + const result = await s.run(SHARE); + expect(result.code).toBe(0); + expect(query(fake, "getHotelSearchLink")).toMatchObject({ + currency: "SAR", + locale: "ar", + siteCode: "SA", + }); + }); + + it("an explicit --site beats the stored one", async () => { + signIn(s.home); + writeSettings(s.home, { site: "SA" }); + const fake = s.fake({ routes: [route("hotels-search-link")] }); + const result = await s.run([...SHARE, "--site", "AE"]); + expect(result.code).toBe(0); + expect(query(fake, "getHotelSearchLink").siteCode).toBe("AE"); + }); +}); diff --git a/integration/info.test.ts b/integration/info.test.ts new file mode 100644 index 0000000..5a1fc4d --- /dev/null +++ b/integration/info.test.ts @@ -0,0 +1,249 @@ +/** + * `wego info`: the four reference lookups, their help, and what reaches the wire. + * The parsers' edge cases stay in `src/info.test.ts`; here is what a caller sees. + */ + +import { describe, expect, it } from "bun:test"; +import { readFixture, route } from "./harness/fixtures"; +import { useScenario } from "./harness/scenario"; +import { json, signIn, writeSettings } from "./harness/wego"; + +const s = useScenario(); + +/** Values the output must carry are read from the fixture, so editing a + * fixture does not break a scenario that asserts on them. */ +// biome-ignore lint/suspicious/noExplicitAny: a fixture body is untyped JSON +const body = (name: string) => readFixture(name).body as any; + +function query(fake: { seen: { query: URLSearchParams }[] }) { + return Object.fromEntries(fake.seen[0]?.query ?? []); +} + +describe("info help", () => { + for (const help of ["--help", "-h", "help"]) { + it(`info ${help}: usage on stdout, exit 0, empty stderr`, async () => { + const result = await s.run(["info", help]); + expect(result.code).toBe(0); + expect(result.out).toMatch(/^Usage: wego info /); + expect(result.out).toMatch(/^ {2}holidays /m); + expect(result.err).toBe(""); + }); + } + + it("a bare `info` prints usage on stderr with exit 2", async () => { + const result = await s.run(["info"]); + expect(result.code).toBe(2); + expect(result.err).toContain("Usage:"); + expect(result.out).toBe(""); + }); + + it("an unknown sub-command names it, exit 2", async () => { + const result = await s.run(["info", "weather"]); + expect(result.code).toBe(2); + expect(result.err).toContain("Unknown info sub-command: weather"); + expect(result.out).toBe(""); + }); + + for (const [sub, usage] of [ + ["holidays", "info holidays "], + ["visa-free", "info visa-free "], + ["schedules", "info schedules "], + ["airports-near", "info airports-near "], + ] as const) { + it(`info ${sub} --help prints the leaf usage on stdout, exit 0`, async () => { + const result = await s.run(["info", sub, "--help"]); + expect(result.code).toBe(0); + expect(result.out).toContain(usage); + expect(result.err).toBe(""); + }); + } + + it("a usage error costs exit 2 and no request", async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run(["info", "holidays", "ZZZ"]); + expect(result.code).toBe(2); + expect(result.err).toContain("2-letter ISO country code"); + expect(fake.seen).toEqual([]); + }); +}); + +describe("info holidays", () => { + it("prints JSON, sends the token and the window", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("holidays")] }); + const result = await s.run([ + "info", + "holidays", + "sg", + "--from", + "2026-08-01", + "--to", + "2026-12-31", + ]); + + expect(result.code).toBe(0); + expect(result.err).toBe(""); + expect(json<{ metadata: { window: string } }>(result).metadata.window).toBe( + body("holidays").metadata.window, + ); + expect(fake.seen[0]?.path).toBe("/v1/countries/SG/holidays"); + expect(fake.seen[0]?.token).toBe("access-1"); + expect(query(fake)).toEqual({ + fromDate: "2026-08-01", + toDate: "2026-12-31", + }); + }); + + it("sends no date params when the window is left to the API", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("holidays")] }); + expect((await s.run(["info", "holidays", "SG"])).code).toBe(0); + expect(query(fake)).toEqual({}); + }); + + it("inherits the stored locale but never the stored market", async () => { + signIn(s.home); + writeSettings(s.home, { locale: "ar", site: "SA", currency: "SAR" }); + const fake = s.fake({ routes: [route("holidays")] }); + expect((await s.run(["info", "holidays", "SG"])).code).toBe(0); + expect(query(fake)).toEqual({ locale: "ar" }); + }); + + it("exits 3 without a request when logged out", async () => { + const fake = s.fake(); + const result = await s.run(["info", "holidays", "SG"]); + expect(result.code).toBe(3); + expect(result.out).toBe(""); + expect(result.err).not.toBe(""); + expect(fake.seen).toEqual([]); + }); +}); + +describe("info visa-free", () => { + it("prints the list and forwards paging", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("visa-free")] }); + const result = await s.run([ + "info", + "visa-free", + "ph", + "--page-size", + "10", + ]); + + expect(result.code).toBe(0); + const printed = json<{ + results: { countryCode: string }[]; + metadata: { coverage: string }; + }>(result); + expect(printed.results[0]?.countryCode).toBe( + body("visa-free").results[0].countryCode, + ); + expect(printed.metadata.coverage).toBe(body("visa-free").metadata.coverage); + expect(fake.seen[0]?.path).toBe("/v1/countries/PH/visa-free-destinations"); + expect(query(fake)).toEqual({ pageSize: "10" }); + }); +}); + +describe("info schedules", () => { + it("forwards the route and airline, and prints the resolved city", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("schedules")] }); + const result = await s.run([ + "info", + "schedules", + "sin", + "lhr", + "--airline", + "sq", + "--site", + "sg", + ]); + + expect(result.code).toBe(0); + expect(query(fake)).toMatchObject({ + from: "SIN", + to: "LHR", + airline: "SQ", + siteCode: "SG", + }); + expect(json<{ metadata: { to: unknown } }>(result).metadata.to).toEqual( + body("schedules").metadata.to, + ); + }); + + it("inherits the stored locale and market", async () => { + signIn(s.home); + writeSettings(s.home, { locale: "ar", site: "SA" }); + const fake = s.fake({ routes: [route("schedules")] }); + expect((await s.run(["info", "schedules", "SIN", "BKK"])).code).toBe(0); + expect(query(fake)).toMatchObject({ locale: "ar", siteCode: "SA" }); + }); + + it("an explicit --site beats the stored one", async () => { + signIn(s.home); + writeSettings(s.home, { site: "SA" }); + const fake = s.fake({ routes: [route("schedules")] }); + expect( + (await s.run(["info", "schedules", "SIN", "BKK", "--site", "SG"])).code, + ).toBe(0); + expect(query(fake)).toMatchObject({ siteCode: "SG" }); + }); + + it("prints one siteCodeSource, top level, naming the deciding layer", async () => { + signIn(s.home); + writeSettings(s.home, { site: "SA" }); + s.fake({ routes: [route("schedules")] }); + const result = await s.run(["info", "schedules", "SIN", "BKK"]); + + expect(result.code).toBe(0); + const printed = json<{ + siteCodeSource: string; + metadata: Record; + }>(result); + expect(printed.siteCodeSource).toBe("setting"); + expect(printed.metadata.siteCode).toBe(body("schedules").metadata.siteCode); + expect(result.out.split('"siteCodeSource"').length - 1).toBe(1); + expect( + Object.keys(printed.metadata).filter((k) => k.endsWith("Source")), + ).toEqual([]); + }); + + it("reads `default` with no stored site and no flag", async () => { + signIn(s.home); + s.fake({ routes: [route("schedules")] }); + const result = await s.run(["info", "schedules", "SIN", "BKK"]); + expect(json<{ siteCodeSource: string }>(result).siteCodeSource).toBe( + "default", + ); + }); +}); + +describe("info airports-near", () => { + it("sends a place code as `place` and repeats --types", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("airports-near")] }); + const result = await s.run([ + "info", + "airports-near", + "lon", + "--types", + "airport,city", + ]); + + expect(result.code).toBe(0); + expect(fake.seen[0]?.query.get("place")).toBe("LON"); + expect(fake.seen[0]?.query.getAll("types")).toEqual(["airport", "city"]); + expect(json<{ results: { code: string }[] }>(result).results[0]?.code).toBe( + body("airports-near").results[0].code, + ); + }); + + it("sends a coordinate pair as latitude and longitude", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("airports-near")] }); + expect((await s.run(["info", "airports-near", "51.5,-0.12"])).code).toBe(0); + expect(query(fake)).toEqual({ latitude: "51.5", longitude: "-0.12" }); + }); +}); diff --git a/integration/login-more.test.ts b/integration/login-more.test.ts new file mode 100644 index 0000000..73a10e0 --- /dev/null +++ b/integration/login-more.test.ts @@ -0,0 +1,356 @@ +/** + * The session edges `auth.test.ts` leaves out: login's refusals and the hints it + * prints for a remote or non-interactive shell, the endpoint checks every command + * makes, what a refresh keeps and drops, the trace a failed refresh leaves on + * disk, and the analytics session logout ends. + * + * The paste-the-callback path needs a TTY on stdin, which a spawned binary does + * not have, so it stays with `paste-callback.test.ts` and the `parseLoginArgs` + * unit tests. Nothing here lets the binary open a browser. + */ + +import { describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { problem } from "./harness/fake"; +import { route } from "./harness/fixtures"; +import { loginThroughBrowser } from "./harness/login"; +import { useScenario } from "./harness/scenario"; +import { idToken, signIn, spawnWego } from "./harness/wego"; + +const s = useScenario(); + +const stored = () => JSON.parse(readFileSync(s.home.credentialsPath, "utf8")); +const failurePath = () => join(s.home.configDir, "last-auth-failure.json"); + +/** An id_token whose `exp` is `ms` from now (negative: already expired). */ +const idTokenExpiringIn = (ms: number) => + idToken({ exp: Math.floor((Date.now() + ms) / 1000) }); + +/** Start `login`, wait for the authorize URL, and deliver the callback the + * "browser" would, returning the finished run. */ +async function loginWith(args: string[], env: Record) { + const fake = s.fake(); + const running = spawnWego(["login", ...args], { + fake, + home: s.home, + env, + }); + const escaped = fake.authorizeUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const [printed] = await running.waitForErr(new RegExp(`${escaped}\\?\\S+`)); + const authorize = new URL(printed).searchParams; + const redirectUri = authorize.get("redirect_uri") ?? ""; + fake.armCode({ + code: "code-1", + codeChallenge: authorize.get("code_challenge") ?? "", + redirectUri, + tokens: { access_token: "access-9" }, + }); + const state = encodeURIComponent(authorize.get("state") ?? ""); + await fetch(`${redirectUri}?code=code-1&state=${state}`).catch( + () => undefined, + ); + return { fake, result: await running.result }; +} + +/** A stored session whose access token has expired, so the next call refreshes. */ +function signInExpired(extra: { refreshToken?: string; idToken?: string }) { + signIn(s.home, { + accessToken: "access-old", + refreshToken: "refresh-1", + expiresAt: Date.now() - 10_000, + ...extra, + }); +} + +describe("login refusals", () => { + it("exits 7 when the token endpoint cannot be reached", async () => { + const { result } = await loginWith(["--no-browser"], { + // A closed port: the callback arrives, the code exchange cannot. + WEGO_AUTH_TOKEN_URL: "http://127.0.0.1:9/oauth/token", + }); + expect(result.code).toBe(7); + expect(result.err).toContain("Login failed"); + expect(existsSync(s.home.credentialsPath)).toBe(false); + }); + + it("exits 2 when the redirect port is already taken", async () => { + const occupied = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response("busy"), + }); + try { + const fake = s.fake(); + const result = await s.run(["login", "--no-browser"], { + env: { WEGO_CLI_REDIRECT_PORT: String(occupied.port) }, + }); + expect(result.code).toBe(2); + expect(result.err).toContain("Login failed"); + expect(fake.tokenRequests).toEqual([]); + } finally { + occupied.stop(true); + } + }); + + it("refuses a plaintext auth server that is not localhost, exit 2", async () => { + const result = await s.run(["login", "--no-browser"], { + env: { + WEGO_AUTH_AUTHORIZE_URL: "http://auth.evil.com/authorize", + WEGO_AUTH_TOKEN_URL: "http://auth.evil.com/token", + }, + }); + expect(result.code).toBe(2); + expect(result.err).toMatch(/must be HTTPS/); + }); + + it("exits 2 when both --browser and --no-browser are given", async () => { + const fake = s.fake(); + const result = await s.run(["login", "--browser", "--no-browser"]); + expect(result.code).toBe(2); + expect(result.err).toMatch(/not both/); + expect(fake.tokenRequests).toEqual([]); + }); +}); + +describe("login without a local browser", () => { + it("skips the browser inside an SSH session and finishes over the loopback", async () => { + const { result } = await loginWith([], { + SSH_CONNECTION: "10.0.0.1 50000 10.0.0.2 22", + }); + expect(result.code).toBe(0); + expect(result.err).toMatch(/No browser on this machine/); + expect(result.err).not.toMatch(/Opening your browser/); + expect(stored()).toMatchObject({ accessToken: "access-9" }); + }); + + it("promises no paste prompt to a caller without a TTY, only the port-forward hint", async () => { + const fake = s.fake(); + const { result } = await loginThroughBrowser(fake, s.home, { + tokens: { access_token: "access-9" }, + }); + expect(result.code).toBe(0); + expect(result.err).not.toMatch(/paste it below/); + expect(result.err).toMatch(/ssh -L/); + }); +}); + +describe("endpoint checks", () => { + it("whoami ignores a malformed WEGO_CLI_REDIRECT_PORT, a login-only setting", async () => { + signIn(s.home); + s.fake({ routes: [route("user")] }); + const result = await s.run(["whoami"], { + env: { WEGO_CLI_REDIRECT_PORT: "abc" }, + }); + expect(result.code).toBe(0); + }); + + it("refuses a plaintext WEGO_API_URL that is not localhost, exit 2", async () => { + signIn(s.home); + const result = await s.run(["whoami"], { + env: { WEGO_API_URL: "http://api.wego.com" }, + }); + expect(result.code).toBe(2); + expect(result.err).toMatch(/WEGO_API_URL must be HTTPS/); + }); + + it("refuses to refresh over a plaintext token endpoint, exit 3", async () => { + signInExpired({}); + const fake = s.fake(); + const result = await s.run(["whoami"], { + env: { WEGO_AUTH_TOKEN_URL: "http://auth.wego.com/token" }, + }); + expect(result.code).toBe(3); + expect(result.err).toMatch(/WEGO_AUTH_TOKEN_URL must be HTTPS/); + expect(fake.seen).toEqual([]); + }); + + it("names `bun dev` when a local API cannot be reached, exit 7", async () => { + signIn(s.home); + // No fake: WEGO_API_URL is a closed loopback port. + const result = await s.run(["whoami"]); + expect(result.code).toBe(7); + expect(result.err).toMatch( + /Cannot reach the Wego API at http:\/\/127\.0\.0\.1:9/, + ); + expect(result.err).toMatch(/bun dev/); + }); +}); + +describe("what a refresh keeps", () => { + it("stores the id_token a refresh returns", async () => { + const issued = idTokenExpiringIn(3_600_000); + signInExpired({ idToken: idTokenExpiringIn(-60_000) }); + s.fake({ + accept: [], + routes: [route("user")], + refresh: [{ access_token: "access-2", id_token: issued }], + }); + expect((await s.run(["whoami"])).code).toBe(0); + expect(stored()).toMatchObject({ + accessToken: "access-2", + idToken: issued, + }); + }); + + it("keeps the stored id_token when a refresh returns none and it is still accepted", async () => { + const kept = idTokenExpiringIn(-60_000); + signInExpired({ idToken: kept }); + s.fake({ + accept: [], + routes: [route("user")], + refresh: [{ access_token: "access-2" }], + }); + expect((await s.run(["whoami"])).code).toBe(0); + expect(stored()).toMatchObject({ idToken: kept }); + }); + + it("drops a stored id_token the API would no longer accept", async () => { + signInExpired({ idToken: idTokenExpiringIn(-25 * 3_600_000) }); + s.fake({ + accept: [], + routes: [route("user")], + refresh: [{ access_token: "access-2" }], + }); + expect((await s.run(["whoami"])).code).toBe(0); + expect(stored().idToken).toBeUndefined(); + }); + + it("keeps the refresh token when a reactive refresh does not rotate it", async () => { + signIn(s.home, { + accessToken: "access-revoked", + refreshToken: "refresh-1", + }); + s.fake({ + accept: [], + routes: [route("user")], + refresh: [{ access_token: "access-2" }], + }); + expect((await s.run(["whoami"])).code).toBe(0); + expect(stored()).toMatchObject({ + accessToken: "access-2", + refreshToken: "refresh-1", + }); + }); + + it("explains an environment mismatch when a 401 survives a good refresh", async () => { + signIn(s.home, { + accessToken: "access-revoked", + refreshToken: "refresh-1", + }); + // The refreshed token is accepted by the fake, and the API still says 401: + // a token minted for another environment than WEGO_API_URL. + s.fake({ + accept: [], + routes: [ + { + op: "getCurrentUser", + answers: [problem(401, "invalid_token", "Missing or invalid token")], + }, + ], + refresh: [{ access_token: "access-2" }], + }); + const result = await s.run(["whoami"]); + expect(result.code).toBe(3); + expect(result.err).toMatch(/rejected your credentials \(401\)/); + expect(result.err).toMatch(/WEGO_API_URL/); + expect(result.err).toMatch(/wego login/); + }); +}); + +describe("a failed refresh leaves a trace", () => { + it("prints the auth server's OAuth2 error and records it without the refresh token", async () => { + signInExpired({ refreshToken: "refresh-secret" }); + s.fake({ + accept: [], + refresh: [ + { + status: 400, + body: { + error: "invalid_grant", + error_description: "Token is expired", + }, + }, + ], + }); + const result = await s.run(["whoami"]); + + expect(result.code).toBe(3); + expect(result.err).toMatch(/invalid_grant/); + expect(result.err).toMatch(/Token is expired/); + expect(result.err).toMatch(/wego login/); + const text = readFileSync(failurePath(), "utf8"); + expect(JSON.parse(text)).toMatchObject({ + grantType: "refresh_token", + status: 400, + error: "invalid_grant", + errorDescription: "Token is expired", + }); + expect(JSON.parse(text).at).toMatch(/^\d{4}-\d\d-\d\dT/); + expect(text).not.toContain("refresh-secret"); + }); + + it("records the failure on the reactive 401 path too", async () => { + signIn(s.home, { + accessToken: "access-revoked", + refreshToken: "refresh-1", + }); + s.fake({ + accept: [], + refresh: [{ status: 400, body: { error: "invalid_grant" } }], + }); + expect((await s.run(["whoami"])).code).toBe(3); + expect(JSON.parse(readFileSync(failurePath(), "utf8"))).toMatchObject({ + grantType: "refresh_token", + status: 400, + error: "invalid_grant", + }); + }); + + it("redacts the refresh token if the auth server echoes it back", async () => { + const secret = `1${"a".repeat(130)}`; + signInExpired({ refreshToken: secret }); + s.fake({ + accept: [], + refresh: [ + { status: 400, body: `upstream rejected refresh_token=${secret}` }, + ], + }); + expect((await s.run(["whoami"])).code).toBe(3); + const text = readFileSync(failurePath(), "utf8"); + expect(text).not.toContain(secret); + expect(JSON.parse(text).bodySnippet).toContain("[REDACTED]"); + }); + + it("still exits 3 and names login when the record cannot be written", async () => { + signInExpired({}); + // A directory where the record goes: the write fails, the auth failure stands. + mkdirSync(failurePath(), { recursive: true }); + s.fake({ accept: [], refresh: [{ status: 400, body: "nope" }] }); + const result = await s.run(["whoami"]); + expect(result.code).toBe(3); + expect(result.err).toMatch(/wego login/); + }); +}); + +describe("logout and the analytics session", () => { + const sessionPath = () => join(s.home.configDir, "session.json"); + + it("ends the analytics session, so the next user starts a new one", async () => { + signIn(s.home); + writeFileSync(sessionPath(), "{}\n"); + const result = await s.run(["logout"]); + expect(result.code).toBe(0); + expect(existsSync(sessionPath())).toBe(false); + }); + + it("still logs out, loudly, when the session cannot be cleared", async () => { + signIn(s.home); + // A non-empty directory cannot be removed by a plain `rm`. + mkdirSync(join(sessionPath(), "stuck"), { recursive: true }); + const result = await s.run(["logout"]); + expect(result.code).toBe(0); + expect(existsSync(s.home.credentialsPath)).toBe(false); + expect(result.err).toContain("could not clear the analytics session"); + }); +}); diff --git a/integration/places.test.ts b/integration/places.test.ts new file mode 100644 index 0000000..749f137 --- /dev/null +++ b/integration/places.test.ts @@ -0,0 +1,199 @@ +/** + * `wego places`: its help, the flags it forwards, the ones it refuses before any + * request, and the stored preferences it does (and does not) inherit. The shared + * error classes are `errors.test.ts`; `parsePlacesArgs` has no edge case that + * these do not reach from argv. + */ + +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { readFixture, route } from "./harness/fixtures"; +import { useScenario } from "./harness/scenario"; +import { json, signIn, writeSettings } from "./harness/wego"; + +const s = useScenario(); + +// biome-ignore lint/suspicious/noExplicitAny: a fixture body is untyped JSON +const body = (name: string) => readFixture(name).body as any; + +function query(fake: { seen: { query: URLSearchParams }[] }) { + return Object.fromEntries(fake.seen[0]?.query ?? []); +} + +describe("places help", () => { + for (const help of ["--help", "-h", "help"]) { + it(`places ${help}: usage on stdout, exit 0, empty stderr`, async () => { + const result = await s.run(["places", help]); + expect(result.code).toBe(0); + expect(result.out).toContain('Usage: wego places ""'); + expect(result.err).toBe(""); + }); + } +}); + +describe("places", () => { + it("prints the places JSON and sends the token and the query", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("places")] }); + const result = await s.run([ + "places", + "dubai", + "--locale", + "en", + "--page-size", + "5", + ]); + + expect(result.code).toBe(0); + expect( + json<{ metadata: { resultCount: number } }>(result).metadata.resultCount, + ).toBe(body("places").metadata.resultCount); + expect(fake.seen[0]?.token).toBe("access-1"); + expect(query(fake)).toEqual({ + query: "dubai", + locale: "en", + pageSize: "5", + }); + }); + + it("takes --flag value and --flag=value, and --types comma lists and repeats", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("places")] }); + const result = await s.run([ + "places", + "paris", + "--types", + "city,airport", + "--types=hotel", + "--locale=en", + "--page", + "2", + "--page-size=5", + ]); + + expect(result.code).toBe(0); + expect(fake.seen[0]?.query.getAll("types")).toEqual([ + "city", + "airport", + "hotel", + ]); + expect(fake.seen[0]?.query.get("query")).toBe("paris"); + expect(fake.seen[0]?.query.get("locale")).toBe("en"); + expect(fake.seen[0]?.query.get("page")).toBe("2"); + expect(fake.seen[0]?.query.get("pageSize")).toBe("5"); + }); + + it("accepts the values at the caps (page 100, page-size 50)", async () => { + signIn(s.home); + const fake = s.fake({ routes: [route("places")] }); + const result = await s.run([ + "places", + "dubai", + "--page", + "100", + "--page-size", + "50", + ]); + + expect(result.code).toBe(0); + expect(query(fake)).toMatchObject({ page: "100", pageSize: "50" }); + }); + + const refused: [string[], RegExp][] = [ + [[], /Usage: wego places/], + [["dubai", "--page", "x"], /--page must be a positive integer/], + [["dubai", "--page", "0x10"], /--page must be a positive integer/], + [["dubai", "--page", "1e3"], /--page must be a positive integer/], + [["dubai", "--page", "0"], /--page must be a positive integer/], + [["dubai", "--page", "1.5"], /--page must be a positive integer/], + [["dubai", "--page", "-1"], /--page must be a positive integer/], + [["dubai", "--page", " 5"], /--page must be a positive integer/], + [["dubai", "--page", "101"], /--page must be between 1 and 100/], + [["dubai", "--page-size", "100"], /--page-size must be between 1 and 50/], + [["dubai", "--nope"], /Unknown option/], + [["dubai", "--locale"], /--locale requires a value/], + [["dubai", "--locale", "--page", "2"], /--locale requires a value/], + [["dubai", "--locale="], /--locale requires a value/], + [["dubai", "--types="], /--types requires a value/], + ]; + for (const [args, message] of refused) { + it(`places ${JSON.stringify(args)} is a usage error, exit 2, no request`, async () => { + signIn(s.home); + const fake = s.fake(); + const result = await s.run(["places", ...args]); + expect(result.code).toBe(2); + expect(result.err).toMatch(message); + expect(result.out).toBe(""); + expect(fake.seen).toEqual([]); + }); + } + + it("tells a logged-out user to run login, exit 3, no request", async () => { + const fake = s.fake(); + const result = await s.run(["places", "dubai"]); + expect(result.code).toBe(3); + expect(result.err).toMatch(/wego login/); + expect(fake.seen).toEqual([]); + }); + + it("recovers from a 401 by refreshing once and retrying", async () => { + signIn(s.home, { + accessToken: "access-revoked", + refreshToken: "refresh-1", + }); + const fake = s.fake({ + accept: [], + routes: [route("places")], + refresh: [{ access_token: "access-2" }], + }); + const result = await s.run(["places", "dubai"]); + + expect(result.code).toBe(0); + expect(fake.requests("getPlaces").map((r) => r.token)).toEqual([ + "access-revoked", + "access-2", + ]); + }); + + it("refreshes an expired token before the call and stores the rotation", async () => { + signIn(s.home, { + accessToken: "access-old", + refreshToken: "refresh-1", + expiresAt: Date.now() - 10_000, + }); + const fake = s.fake({ + accept: [], + routes: [route("places")], + refresh: [{ access_token: "access-2", refresh_token: "refresh-2" }], + }); + const result = await s.run(["places", "dubai"]); + + expect(result.code).toBe(0); + expect(fake.requests("getPlaces").map((r) => r.token)).toEqual([ + "access-2", + ]); + expect( + JSON.parse(readFileSync(s.home.credentialsPath, "utf8")), + ).toMatchObject({ accessToken: "access-2", refreshToken: "refresh-2" }); + }); +}); + +describe("places stored preferences", () => { + it("inherits the stored locale and never the stored market or currency", async () => { + // Place resolution is market-neutral on purpose: the API pins the upstream + // site to the wildcard, so a stored site must not narrow every lookup. + signIn(s.home); + writeSettings(s.home, { locale: "ar", site: "SA", currency: "SAR" }); + const fake = s.fake({ routes: [route("places")] }); + expect((await s.run(["places", "dubai"])).code).toBe(0); + expect(query(fake)).toEqual({ query: "dubai", locale: "ar" }); + }); + + it("an explicit --locale beats the stored one", async () => { + signIn(s.home); + writeSettings(s.home, { locale: "ar" }); + const fake = s.fake({ routes: [route("places")] }); + expect((await s.run(["places", "dubai", "--locale", "en"])).code).toBe(0); + expect(query(fake)).toEqual({ query: "dubai", locale: "en" }); + }); +}); diff --git a/integration/skill-matches-cli.test.ts b/integration/skill-matches-cli.test.ts new file mode 100644 index 0000000..3ee887e --- /dev/null +++ b/integration/skill-matches-cli.test.ts @@ -0,0 +1,133 @@ +/** + * The embedded skill only tells the agent to run what this binary has. + * + * `skills/wego/SKILL.md` is compiled into the binary and followed by the user's + * coding agent. When a command or flag is renamed and the skill is not, the agent + * is told to run something that fails, and nothing else here notices: the + * scenarios test the CLI, the skill evals in wego-ai run per release and cost + * money. This checks, for every `wego …` command the skill shows, that the + * command answers `--help` and that each `--flag` it uses is in that help. + * + * It cannot tell whether the skill covers a new command; that is what the evals + * measure. + */ + +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { makeHome, wego } from "./harness/wego"; + +const SKILL = readFileSync( + fileURLToPath(new URL("../skills/wego/SKILL.md", import.meta.url)), + "utf8", +); + +/** Every `wego …` command line the skill shows: continuation lines joined, a + * trailing `# comment` dropped, runs of spaces collapsed. Inline code ends at + * its backtick; a `|` inside `[--sort a|b]` does not end anything. */ +export function skillCommands(markdown: string): string[] { + const joined = markdown.replace(/\\\n\s*/g, " "); + const found = new Set(); + for (const m of joined.matchAll(/(?:^|[\s`(])(wego(?: [^`\n]+)?)/gm)) { + const command = (m[1] ?? "") + .replace(/\s#.*$/, "") + .replace(/\s+/g, " ") + .trim(); + if (command !== "wego") found.add(command); + } + return [...found].sort(); +} + +/** The subcommand words at the front of a command: lowercase words before the + * first argument, placeholder, quote or flag. */ +export function commandPath(command: string): string[] { + const words = command.split(/\s+/).slice(1); + const path: string[] = []; + for (const word of words) { + if (!/^[a-z][a-z-]*$/.test(word) || path.length === 2) break; + path.push(word); + } + return path; +} + +export function longFlags(command: string): string[] { + return [...new Set(command.match(/(?(); + +/** `wego --help`, falling back to the parent when a word is an argument + * rather than a subcommand (`config set currency`, `places London`). */ +async function helpFor( + path: string[], +): Promise<{ path: string[]; out: string } | undefined> { + for (let n = path.length; n >= 0; n -= 1) { + const candidate = path.slice(0, n); + const key = candidate.join(" "); + let result = helpCache.get(key); + if (!result) { + const r = await wego([...candidate, "--help"], { home }); + result = { code: r.code, out: r.out }; + helpCache.set(key, result); + } + if (result.code === 0 && result.out.includes("Usage:")) { + return { path: candidate, out: result.out }; + } + } + return undefined; +} + +describe("the skill matches the CLI", () => { + const commands = skillCommands(SKILL); + + it("finds the commands the skill shows", () => { + expect(commands.length).toBeGreaterThan(20); + }); + + for (const command of commands) { + it(command, async () => { + const help = await helpFor(commandPath(command)); + expect(help, `no \`--help\` answers for: ${command}`).toBeDefined(); + const missing = longFlags(command).filter( + (flag) => flag !== "--help" && !help?.out.includes(flag), + ); + expect( + missing, + `skills/wego/SKILL.md tells the agent to run \`${command}\`, but \`wego ${help?.path.join(" ")} --help\` has no ${missing.join(", ")}`, + ).toEqual([]); + }); + } +}); + +describe("reading the skill", () => { + it("joins continuation lines and stops at the end of inline code", () => { + expect( + skillCommands( + "Run `wego places London --page 2` or:\n\n wego flights search SIN BKK \\\n --adults 2\n", + ), + ).toEqual([ + "wego flights search SIN BKK --adults 2", + "wego places London --page 2", + ]); + }); + + it("takes the subcommand words, not the arguments", () => { + expect(commandPath('wego places "Heathrow" --page 2')).toEqual(["places"]); + expect(commandPath("wego info holidays [--from X]")).toEqual([ + "info", + "holidays", + ]); + expect(commandPath("wego config set currency SAR")).toEqual([ + "config", + "set", + ]); + }); + + it("collects long flags only", () => { + expect(longFlags("wego update -y --check [--locale en] --check")).toEqual([ + "--check", + "--locale", + ]); + }); +}); diff --git a/integration/skill.test.ts b/integration/skill.test.ts new file mode 100644 index 0000000..baeb8af --- /dev/null +++ b/integration/skill.test.ts @@ -0,0 +1,44 @@ +/** + * `wego skill`: the agent skill embedded in the binary, installed into a throwaway + * home. The installer's branches (ownership markers, remote bodies, agent paths) + * are `src/skill.test.ts`; here is proof the compiled binary carries the skill this + * checkout ships, byte for byte. + */ + +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { useScenario } from "./harness/scenario"; +import { json } from "./harness/wego"; + +const s = useScenario(); + +const SHIPPED = fileURLToPath( + new URL("../skills/wego/SKILL.md", import.meta.url), +); + +describe("skill", () => { + it("lists the embedded skill as JSON", async () => { + const result = await s.run(["skill", "list", "--json"]); + expect(result.code).toBe(0); + expect(json<{ id: string }[]>(result).map((e) => e.id)).toEqual(["wego"]); + }); + + it("installs the embedded skill, identical to skills/wego/SKILL.md", async () => { + const result = await s.run(["skill", "install", "-y", "--embedded"]); + expect(result.code).toBe(0); + expect( + readFileSync( + join(s.home.dir, ".claude", "skills", "wego", "SKILL.md"), + "utf8", + ), + ).toBe(readFileSync(SHIPPED, "utf8")); + }); + + it("rejects an unknown skill id and names `wego skill list`", async () => { + const result = await s.run(["skill", "install", "ghost", "-y"]); + expect(result.code).not.toBe(0); + expect(result.err).toMatch(/wego skill list/); + }); +}); diff --git a/integration/target.test.ts b/integration/target.test.ts new file mode 100644 index 0000000..28e9be3 --- /dev/null +++ b/integration/target.test.ts @@ -0,0 +1,83 @@ +/** + * `wego info target`: a retargeted run says so, on stderr for a person and as one + * JSON object on stdout for an agent. It reads nothing and calls nothing. + */ + +import { describe, expect, it } from "bun:test"; +import { join } from "node:path"; +import { useScenario } from "./harness/scenario"; +import { json } from "./harness/wego"; + +const s = useScenario(); + +const STAGING_AUTH_HOST = "auth.wegostaging.com"; +const STAGING_API_URL = "https://api.wegostaging.com"; + +describe("info target", () => { + it("names the target, its origin and the endpoints on stderr", async () => { + const fake = s.fake(); + const result = await s.run(["--target", "staging", "info", "target"]); + + expect(result.code).toBe(0); + expect(result.err).toContain("staging"); + expect(result.err).toContain("--target"); + expect(result.err).toContain(STAGING_API_URL); + expect(result.err).toContain(STAGING_AUTH_HOST); + expect(result.err).toContain("suppressed"); + expect(json<{ target: string }>(result).target).toBe("staging"); + expect(fake.seen).toEqual([]); + }); + + it("prints the same stdout with --json, and nothing on stderr", async () => { + const plain = await s.run(["--target", "staging", "info", "target"]); + const quiet = await s.run([ + "--target", + "staging", + "info", + "target", + "--json", + ]); + + expect(quiet.code).toBe(0); + expect(quiet.out).toBe(plain.out); + expect(quiet.err).toBe(""); + }); + + it("reports a target set by WEGO_TARGET, with a store keyed by its auth host", async () => { + const result = await s.run(["info", "target", "--json"], { + env: { WEGO_TARGET: "staging" }, + }); + + expect(result.code).toBe(0); + expect(json(result)).toEqual({ + target: "staging", + source: "env", + apiUrl: STAGING_API_URL, + authorizeUrl: `https://${STAGING_AUTH_HOST}/user-auth/v2/users/oauth/authorize`, + tokenUrl: `https://${STAGING_AUTH_HOST}/user-auth/v2/users/oauth/token`, + credentialsPath: join( + s.home.configDir, + STAGING_AUTH_HOST, + "credentials.json", + ), + telemetrySuppressed: true, + }); + }); + + it("prints its usage on --help and rejects an unknown argument", async () => { + const help = await s.run(["info", "target", "--help"]); + expect(help.code).toBe(0); + expect(help.out).toContain("info target"); + + const bad = await s.run(["info", "target", "--jsn"]); + expect(bad.code).toBe(2); + expect(bad.err).toContain("--jsn"); + }); + + it("refuses a mistyped target instead of falling back to prod", async () => { + const result = await s.run(["--target", "stagng", "info", "target"]); + expect(result.code).toBe(2); + expect(result.out).toBe(""); + expect(result.err).toContain("stagng"); + }); +}); diff --git a/integration/telemetry.test.ts b/integration/telemetry.test.ts new file mode 100644 index 0000000..864c18a --- /dev/null +++ b/integration/telemetry.test.ts @@ -0,0 +1,131 @@ +/** + * `wego telemetry` and the one control over usage events. Nothing here can reach + * an analytics service: the binary under test is pointed at nothing, and `log` + * mode prints the event instead of sending it. + */ + +import { describe, expect, it } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { useScenario } from "./harness/scenario"; +import { json } from "./harness/wego"; + +const s = useScenario(); + +const statePath = () => join(s.home.configDir, "telemetry.json"); +const state = () => JSON.parse(readFileSync(statePath(), "utf8")); +function writeState(value: Record) { + mkdirSync(s.home.configDir, { recursive: true }); + writeFileSync(statePath(), JSON.stringify(value)); +} +/** The harness turns telemetry off; these scenarios say what they want. */ +const env = (mode = "") => ({ env: { WEGO_CLI_TELEMETRY: mode } }); + +describe("telemetry status", () => { + it("reports the stored setting and where it lives", async () => { + writeState({ deviceId: "dev-1", enabled: true }); + const result = await s.run(["telemetry", "status"], env()); + expect(result.code).toBe(0); + expect(json(result)).toEqual({ + enabled: true, + source: "default", + mode: null, + setting: true, + path: statePath(), + }); + }); + + it("defaults to status with no subcommand", async () => { + const result = await s.run(["telemetry"], env()); + expect(json(result).source).toBe("default"); + }); + + it("attributes the state to the setting when it was turned off", async () => { + writeState({ enabled: false }); + const result = await s.run(["telemetry", "status"], env()); + expect(json(result)).toMatchObject({ enabled: false, source: "setting" }); + }); + + it("attributes the state to the environment, which wins", async () => { + writeState({ enabled: true }); + const result = await s.run(["telemetry", "status"], env("0")); + expect(json(result)).toMatchObject({ + enabled: false, + source: "environment", + mode: "off", + setting: true, + }); + }); + + it("reports log mode as not sending", async () => { + const result = await s.run(["telemetry", "status"], env("log")); + expect(json(result)).toMatchObject({ enabled: false, mode: "log" }); + }); +}); + +describe("telemetry enable / disable", () => { + it("persists a disable, keeps the machine id, and echoes the new state", async () => { + writeState({ deviceId: "dev-1", enabled: true }); + const result = await s.run(["telemetry", "disable"], env()); + expect(result.code).toBe(0); + expect(json(result)).toEqual({ enabled: false, path: statePath() }); + expect(state()).toMatchObject({ deviceId: "dev-1", enabled: false }); + }); + + it("persists an enable", async () => { + writeState({ deviceId: "dev-1", enabled: false }); + const result = await s.run(["telemetry", "enable"], env()); + expect(json(result)).toEqual({ enabled: true, path: statePath() }); + expect(state()).toMatchObject({ deviceId: "dev-1", enabled: true }); + }); + + it("warns on stderr when the environment will override what was just stored", async () => { + const result = await s.run(["telemetry", "enable"], env("0")); + expect(result.code).toBe(0); + expect(result.err).toMatch(/WEGO_CLI_TELEMETRY=0 overrides/); + expect(state().enabled).toBe(true); + }); + + it("stays quiet when the environment agrees with the stored choice", async () => { + const result = await s.run(["telemetry", "disable"], env("0")); + expect(result.code).toBe(0); + expect(result.err).toBe(""); + }); +}); + +describe("telemetry usage errors", () => { + for (const [args, message] of [ + [["nuke"], /Unknown subcommand: nuke/], + [["--force"], /Unknown option: --force/], + [["disable", "unexpected"], /Unexpected argument: unexpected/], + [["enable", "--force"], /Unknown option: --force/], + ] as const) { + it(`rejects \`telemetry ${args.join(" ")}\` with exit 2`, async () => { + writeState({ enabled: true }); + const result = await s.run(["telemetry", ...args], env()); + expect(result.code).toBe(2); + expect(result.out).toBe(""); + expect(result.err).toMatch(message); + expect(state().enabled).toBe(true); + }); + } + + it("prints usage on --help, naming the single control", async () => { + const result = await s.run(["telemetry", "--help"], env()); + expect(result.code).toBe(0); + expect(result.out).toMatch(/WEGO_CLI_TELEMETRY/); + expect(result.out).toMatch(//); + }); +}); + +describe("the usage event", () => { + it("in log mode, prints the event on stderr and never the command's arguments", async () => { + const result = await s.run(["places", "Secret Street 42"], env("log")); + + // Logged out, so the command itself fails; the event is still built. + expect(result.code).toBe(3); + expect(result.out).toBe(""); + expect(result.err).toContain("cli_command_ran"); + expect(result.err).not.toContain("Secret Street"); + }); +}); diff --git a/package.json b/package.json index c347cef..083b1d6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "format": "biome check --write", "typecheck": "bun run api-types:generate && bunx --package @typescript/native tsc --noEmit", "test": "bun --env-file=.env.local.example test ./src ./scripts", + "test:integration": "bun test --timeout 30000 --preload ./integration/harness/preload.ts ./integration", "check": "bun run lint && bun run typecheck && bun run test", "prepare": "husky" }, diff --git a/scripts/next-report.test.ts b/scripts/next-report.test.ts new file mode 100644 index 0000000..b349c21 --- /dev/null +++ b/scripts/next-report.test.ts @@ -0,0 +1,560 @@ +/** + * The next report, rendered from the SHARED payloads and read through a fake + * GitHub API on a fake clock. + * + * The payloads in `scripts/next-report/payloads/` are the cross-repository + * interface: wego-ai tests its writer against the same files, byte for byte. + * So a rendering test here is also a statement that the two sides agree on what + * each verdict looks like, and the files are read, never inlined. + * + * The polling cases matter as much as the rendering ones. The release lane waits + * up to 45 minutes; a wait that ended on the wrong state (reporting "did not + * start" while a check was running, or trusting a check from another App) would + * put the wrong sentence in front of a person deciding a promote. + */ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { + banner, + type CheckRun, + type Clock, + commandMessage, + EVALS_CHECK_NAME, + evalsLine, + type Limits, + parseReport, + pickCheck, + pollLine, + REPORT_APP_ID, + renderCompleted, + run, +} from "./next-report"; + +const SHA = "0123456789abcdef0123456789abcdef01234567"; +const TAG = "v1.5.0"; +const DETAILS = "https://github.com/wego/wego-ai/actions/runs/1"; + +const payload = (name: string): string => + readFileSync(`scripts/next-report/payloads/${name}.json`, "utf8"); + +/** A payload as wego-ai delivers it: prose, then the one ```json fence. */ +const check = (overrides: Partial & { body?: string } = {}) => { + const { body, ...rest } = overrides; + return { + id: 1, + name: "cli-next-smoke", + status: "completed", + conclusion: "success", + started_at: "2026-09-23T10:00:00Z", + details_url: DETAILS, + app: { id: REPORT_APP_ID }, + output: { + title: "v1.5.0 · ready", + summary: "smoke of cli/next", + text: `The smoke ran against staging.\n\n\`\`\`json\n${body ?? payload("ready")}\n\`\`\`\n`, + }, + ...rest, + } satisfies CheckRun; +}; + +const completed = (name: string) => + renderCompleted(TAG, SHA, check({ body: payload(name) }), { table: true }); + +describe("a completed report, per shared payload", () => { + it.each([ + ["ready", "✓ Ready: nothing new is wrong in v1.5.0", "::notice::"], + [ + "look-first", + "⚠ Look first: startup is 28% slower than v1.4.2", + "::warning::", + ], + [ + "staging-problem", + "✗ Staging problem: whoami failed and staging's health check was failing", + "::warning::", + ], + [ + "binary-problem", + "✗ Binary problem: the built-in skill does not match the tag", + "::warning::", + ], + ])("%s: banner, link, annotation", (name, line, kind) => { + const out = completed(name); + const lines = out.summary.split("\n"); + expect(lines[0]).toBe("### next report · v1.5.0"); + expect(lines).toContain(line); + expect(out.summary).toContain( + `Details: [private wego-ai run (org members)](${DETAILS})`, + ); + expect(out.annotations).toHaveLength(1); + expect(out.annotations[0]?.startsWith(kind)).toBe(true); + expect(out.annotations[0]).toContain( + "See the private report before promoting.", + ); + }); + + it("renders every part with its label, previous beside current", () => { + const table = completed("ready").summary; + expect(table).toContain("| Part | v1.4.2 | v1.5.0 |"); + expect(table).toContain( + "| Binary is the tag | – | ✓ version · commit · skill |", + ); + expect(table).toContain("| Stable commands | 6/6 | ✓ 6/6 |"); + expect(table).toContain("| Error responses | 3/3 | ✓ 3/3 |"); + expect(table).toContain( + "| Search round trips | – | ✓ flights ✓ hotels ✓ |", + ); + expect(table).toContain("| Startup | 39 ms | 41 ms |"); + // The evals are their own check now, never a row of the smoke's table. + expect(table).not.toContain("Skill evals"); + }); + + it("marks a failed step", () => { + expect(completed("staging-problem").summary).toContain( + "| Stable commands | 6/6 | ✗ 5/6 |", + ); + expect(completed("binary-problem").summary).toContain( + "| Binary is the tag | – | ✗ version · commit · skill |", + ); + expect(completed("look-first").summary).toContain( + "| Startup | 39 ms | 50 ms |", + ); + }); + + it("reads a first release, which has no previous version", () => { + const first = JSON.parse(payload("ready")); + delete first.previous; + const out = renderCompleted( + TAG, + SHA, + check({ body: JSON.stringify(first) }), + { table: true }, + ); + expect(out.summary).toContain("| Part | previous | v1.5.0 |"); + }); +}); + +describe("a completed check whose report cannot be read", () => { + it.each([ + ["malformed", "not valid JSON"], + ["unknown-schema", "cli-next-smoke/v9"], + ])("%s: the title, the link, and a warning", (name, reason) => { + const out = completed(name); + expect(out.summary).toContain("● Report unreadable: v1.5.0 · ready"); + expect(out.summary).toContain(DETAILS); + expect(out.summary).not.toContain("| Part |"); + expect(out.annotations).toHaveLength(1); + expect(out.annotations[0]).toStartWith("::warning::"); + expect(out.annotations[0]).toContain("could not be read"); + expect(out.annotations[0]).toContain(reason); + }); + + it("reads no fence, or two fences, as no report", () => { + expect(parseReport("no json here")).toHaveProperty("error"); + const two = `\`\`\`json\n${payload("ready")}\n\`\`\`\n\`\`\`json\n${payload("ready")}\n\`\`\``; + expect(parseReport(two)).toHaveProperty("error"); + expect(parseReport(null)).toHaveProperty("error"); + }); + + it("refuses a report about a different commit", () => { + const out = renderCompleted( + TAG, + "f".repeat(40), + check({ body: payload("ready") }), + { + table: true, + }, + ); + expect(out.summary).toContain("● Report unreadable"); + expect(out.annotations[0]).toContain("is for commit"); + }); +}); + +describe("annotations carry another repository's text safely", () => { + it("escapes the characters that end or corrupt a workflow command", () => { + expect(commandMessage("50%\nnext::warning::x\r")).toBe( + "50%25%0Anext::warning::x%0D", + ); + }); + + it("keeps a pipe in a headline from splitting the table row", () => { + const report = JSON.parse(payload("ready")); + report.parts[3].value = "flights | hotels"; + const out = renderCompleted( + TAG, + SHA, + check({ body: JSON.stringify(report) }), + { table: true }, + ); + expect(out.summary).toContain("✓ flights \\| hotels"); + }); +}); + +describe("pickCheck: wego-ai's App, and only that App", () => { + it("ignores a same-named check from any other App", () => { + expect(pickCheck([check({ app: { id: 1 } })])).toBeUndefined(); + expect(pickCheck([check({ app: null })])).toBeUndefined(); + }); + + it("takes the newest of several", () => { + const old = check({ id: 1, started_at: "2026-09-23T09:00:00Z" }); + const fresh = check({ id: 2, started_at: "2026-09-23T10:00:00Z" }); + expect(pickCheck([old, fresh])?.id).toBe(2); + expect(pickCheck([fresh, old])?.id).toBe(2); + }); +}); + +/** A clock that only moves when the code under test sleeps. */ +const fakeClock = (start = Date.parse("2026-09-23T10:00:00Z")) => { + let t = start; + const clock: Clock & { slept: number } = { + slept: 0, + now: () => t, + sleep: async (ms) => { + t += ms; + clock.slept += ms; + }, + }; + return clock; +}; + +const LIMITS: Limits = { + intervalMs: 30_000, + startMs: 600_000, + totalMs: 2_700_000, +}; + +const ENV = { + GITHUB_REPOSITORY: "wego/cli", + GITHUB_TOKEN: "t", + TAG, + SHA, +}; + +/** + * A GitHub API that answers the check-runs endpoint with whatever `answer` + * returns for the n-th look, and counts the looks. + */ +const fakeApi = (answer: (n: number) => CheckRun[] | Response) => { + const calls: string[] = []; + const fetcher = async (url: string) => { + calls.push(url); + if (url.includes("/check-runs")) { + const a = answer(calls.filter((c) => c.includes("/check-runs")).length); + return a instanceof Response + ? a + : Response.json({ total_count: a.length, check_runs: a }); + } + if (url.endsWith(`/commits/${TAG}`)) return Response.json({ sha: SHA }); + return new Response("not found", { status: 404 }); + }; + return { calls, fetcher }; +}; + +describe("the release lane's wait", () => { + it("logs one line per look, with the wego-ai run linked once", async () => { + const api = fakeApi((n) => + n === 1 + ? [] + : n < 4 + ? [check({ status: "in_progress", conclusion: null })] + : [check()], + ); + const lines: string[] = []; + const clock = { ...fakeClock(), log: (l: string) => lines.push(l) }; + await run([], { ...ENV, NOTIFY_STATUS: "202" }, api.fetcher, clock, LIMITS); + expect(lines).toEqual([ + `waiting for cli-next-smoke on ${TAG} (${SHA.slice(0, 7)}), up to 45 min, looking every 30 s`, + "0 min: not started yet", + `0.5 min: in progress: wego-ai run ${DETAILS}`, + "1 min: in progress", + "1.5 min: completed: v1.5.0 · ready", + ]); + }); + + it("logs a failed look as such, and keeps looking", () => { + expect(pollLine({ error: "HTTP 502" }, 90_000, false)).toBe( + "1.5 min: could not read the check runs (HTTP 502)", + ); + }); + + it("does not look when the receiver is switched off (404)", async () => { + const api = fakeApi(() => []); + const clock = fakeClock(); + const out = await run( + [], + { ...ENV, NOTIFY_STATUS: "404" }, + api.fetcher, + clock, + LIMITS, + ); + expect(out.summary).toContain("● No report: the receiver is switched off"); + expect(api.calls).toEqual([]); + expect(clock.slept).toBe(0); + }); + + it.each([ + "400", + "401", + "403", + "500", + "error", + "", + ])("does not look when the request was refused (%p)", async (status) => { + const api = fakeApi(() => []); + const out = await run( + [], + { ...ENV, NOTIFY_STATUS: status }, + api.fetcher, + fakeClock(), + LIMITS, + ); + expect(out.summary).toContain( + "● No report: the request to wego-ai was refused", + ); + expect(api.calls).toEqual([]); + }); + + it("gives up after 10 min when no check appears, with a warning", async () => { + const api = fakeApi(() => []); + const clock = fakeClock(); + const out = await run( + [], + { ...ENV, NOTIFY_STATUS: "202" }, + api.fetcher, + clock, + LIMITS, + ); + expect(out.summary).toContain("● No report: wego-ai did not start"); + expect(out.annotations[0]).toStartWith("::warning::"); + expect(clock.slept).toBe(600_000); + }); + + it("treats another App's check as no check at all", async () => { + const api = fakeApi(() => [check({ app: { id: 42 } })]); + const out = await run( + [], + { ...ENV, NOTIFY_STATUS: "202" }, + api.fetcher, + fakeClock(), + LIMITS, + ); + expect(out.summary).toContain("● No report: wego-ai did not start"); + expect(out.summary).not.toContain("Ready"); + }); + + it("stops at 45 min when the check is still running", async () => { + const api = fakeApi(() => [check({ status: "in_progress" })]); + const clock = fakeClock(); + const out = await run( + [], + { ...ENV, NOTIFY_STATUS: "409" }, + api.fetcher, + clock, + LIMITS, + ); + expect(out.summary).toContain( + "● No report yet: still running after 45 min", + ); + expect(clock.slept).toBe(2_700_000); + }); + + it("renders the report once the check completes", async () => { + const api = fakeApi((n) => + n < 4 + ? [check({ status: "in_progress" })] + : [check({ body: payload("look-first") })], + ); + const clock = fakeClock(); + const out = await run( + [], + { ...ENV, NOTIFY_STATUS: "202" }, + api.fetcher, + clock, + LIMITS, + ); + expect(out.summary).toContain("⚠ Look first:"); + expect(out.summary).toContain("| Part |"); + expect(clock.slept).toBe(90_000); + expect(api.calls[0]).toContain( + `/repos/wego/cli/commits/${SHA}/check-runs?check_name=cli-next-smoke`, + ); + }); + + it("keeps looking through an API error, and says so if it never clears", async () => { + const api = fakeApi(() => new Response("no", { status: 403 })); + const out = await run( + [], + { ...ENV, NOTIFY_STATUS: "202" }, + api.fetcher, + fakeClock(), + LIMITS, + ); + expect(out.summary).toContain( + "● No report: the check runs could not be read", + ); + expect(out.annotations[0]).toContain("HTTP 403"); + }); + + it("refuses a tag that is not a release tag without calling out", async () => { + const api = fakeApi(() => []); + const out = await run( + [], + { ...ENV, TAG: "main; rm -rf /", NOTIFY_STATUS: "202" }, + api.fetcher, + fakeClock(), + LIMITS, + ); + expect(out.summary).toContain("is not a vX.Y.Z release tag"); + expect(api.calls).toEqual([]); + }); +}); + +describe("the promote banner: one look, one line", () => { + it("shows the verdict and the link, without the table", async () => { + const api = fakeApi(() => [check({ body: payload("ready") })]); + const clock = fakeClock(); + const out = await run(["--banner"], ENV, api.fetcher, clock, LIMITS); + expect(out.summary).toContain("✓ Ready: nothing new is wrong in v1.5.0"); + expect(out.summary).toContain(DETAILS); + expect(out.summary).not.toContain("| Part |"); + expect(clock.slept).toBe(0); + // One look at the tag's commit, then one at each check. + expect(api.calls).toHaveLength(3); + }); + + it("says how long ago a running check started", () => { + const out = banner( + TAG, + SHA, + { check: check({ status: "in_progress" }) }, + Date.parse("2026-09-23T10:12:30Z"), + ); + expect(out.summary).toContain("● No report yet, started 12 min ago"); + }); + + it("says when there is no report at all, without polling", async () => { + const api = fakeApi(() => [check({ app: { id: 42 } })]); + const clock = fakeClock(); + const out = await run(["--banner"], ENV, api.fetcher, clock, LIMITS); + expect(out.summary).toContain("● No report:"); + expect(clock.slept).toBe(0); + }); + + it("reports a tag that resolves to no commit", async () => { + const out = await run( + ["--banner"], + { ...ENV, TAG: "v9.9.9" }, + fakeApi(() => []).fetcher, + fakeClock(), + LIMITS, + ); + expect(out.summary).toContain("the tag's commit could not be read"); + }); +}); + +/** An evals check as wego-ai writes it. */ +const evalsCheck = (overrides: Partial & { body?: string } = {}) => + check({ + name: EVALS_CHECK_NAME, + output: { + title: "v1.5.0 · evals", + summary: "evals of cli/next", + text: `\`\`\`json\n${overrides.body ?? payload("evals-ready")}\n\`\`\`\n`, + }, + ...overrides, + }); + +/** A GitHub API that answers each check name from its own list. */ +const fakeChecks = (smoke: CheckRun[], evals: CheckRun[]) => + fakeApi(() => [...smoke, ...evals]); + +describe("the evals: their own check, one line", () => { + it.each([ + [ + "evals-ready", + "Skill evals: ✓ Ready: skill answers held against v1.4.2", + undefined, + ], + [ + "evals-look-first", + "Skill evals: ⚠ Look first: skill answers scored lower than v1.4.2", + "::warning::", + ], + [ + "evals-partial", + "Skill evals: ✓ Ready: frozen set held; persona subset skipped, no skill or command change since v1.4.2", + undefined, + ], + [ + "evals-skipped", + "Skill evals: ● Skipped: nothing in skills/ or src/ changed since v1.4.2", + undefined, + ], + ])("%s", (name, line, kind) => { + const out = evalsLine( + SHA, + { check: evalsCheck({ body: payload(name) }) }, + 0, + ); + expect(out.line).toBe(line); + expect(out.annotation?.slice(0, 11)).toBe(kind); + }); + + it("is a state, not an error, while it runs or before it starts", () => { + expect(evalsLine(SHA, { check: undefined }, 0).line).toBe( + "● Skill evals: not started yet", + ); + expect( + evalsLine( + SHA, + { check: evalsCheck({ status: "in_progress" }) }, + Date.parse("2026-09-23T11:30:00Z"), + ).line, + ).toBe("● Skill evals: running, started 90 min ago"); + }); + + it("shows an unreadable report as such, with a warning", () => { + for (const body of [payload("malformed"), payload("ready")]) { + const out = evalsLine(SHA, { check: evalsCheck({ body }) }, 0); + expect(out.line).toBe("● Skill evals: report unreadable: v1.5.0 · evals"); + expect(out.annotation?.startsWith("::warning::")).toBe(true); + } + }); + + it("ignores an evals check from another App", () => { + expect( + pickCheck([evalsCheck({ app: { id: 42 } })], EVALS_CHECK_NAME), + ).toBeUndefined(); + }); + + it("the release run waits for the smoke only, then looks once at the evals", async () => { + const api = fakeChecks([check()], [evalsCheck({ status: "in_progress" })]); + const clock = fakeClock(); + const out = await run( + [], + { ...ENV, NOTIFY_STATUS: "202" }, + api.fetcher, + clock, + LIMITS, + ); + expect(out.summary).toContain("✓ Ready: nothing new is wrong in v1.5.0"); + expect(out.summary).toContain( + "● Skill evals: running, started 0 min ago; the promote banner shows the result", + ); + expect(clock.slept).toBe(0); + }); + + it("the promote banner shows both", async () => { + const api = fakeChecks( + [check()], + [evalsCheck({ body: payload("evals-look-first") })], + ); + const out = await run(["--banner"], ENV, api.fetcher, fakeClock(), LIMITS); + expect(out.summary).toContain("✓ Ready: nothing new is wrong in v1.5.0"); + expect(out.summary).toContain( + "Skill evals: ⚠ Look first: skill answers scored lower than v1.4.2", + ); + expect(out.annotations.some((a) => a.includes("skill evals"))).toBe(true); + }); +}); diff --git a/scripts/next-report.ts b/scripts/next-report.ts new file mode 100644 index 0000000..03612a2 --- /dev/null +++ b/scripts/next-report.ts @@ -0,0 +1,833 @@ +/** + * Read the reports wego-ai writes onto a release commit, `cli-next-smoke` and + * `cli-next-evals`, and show them to whoever is about to promote. + * + * bun run scripts/next-report.ts # release-cli.yml, after notify-verify + * bun run scripts/next-report.ts --banner # promote-cli.yml, one look, one line + * + * WHAT IS BEING READ. Once `cli/next` moves, `notify-verify` asks a receiver in + * wego-ai to smoke the new build against staging, then evaluate its skill. + * wego-ai writes each answer back as a check run on this repository, at the + * tag's commit, as its GitHub App (id 4987365): `cli-next-smoke` in minutes, + * `cli-next-evals` in up to hours. Each check's `output.text` carries ONE fenced + * ```json block, in `cli-next-smoke/v1` or `cli-next-evals/v1`; the payloads in + * `scripts/next-report/payloads/` are the shared fixtures for that interface and + * are byte-identical to the ones wego-ai tests its writer against. + * + * ONLY THAT APP'S CHECK COUNTS. Any App with `checks: write` on this repository + * can create a check run with either name, and a report that said "ready" + * would be read by a person deciding whether to move `cli/stable`. So the name is + * a filter and the App id is the proof: a check from any other App is ignored as + * if it did not exist. + * + * IT NEVER FAILS A RUN. The report is advice for a human, and both lanes that run + * this have already done their real work (the release published, the promote + * gates run on their own). Every state, including "could not read anything", + * becomes a line in the step summary and exit 0. The workflow steps wrap this + * script again so that a crash is a summary line too. + * + * Everything that decides what the reader sees is a pure function below; `run` + * wires them to `fetch` and a clock, both injectable, and the `import.meta.main` + * block is only process I/O. + */ + +/** The check run's name, fixed by the cross-repository interface. */ +export const CHECK_NAME = "cli-next-smoke"; + +/** wego-ai's GitHub App. A check run from any other App is not the report. */ +export const REPORT_APP_ID = 4987365; + +/** The one schema this reader understands. Anything else is "could not read". */ +export const SCHEMA = "cli-next-smoke/v1"; + +/** + * The evals are their own check run: the smoke answers "does the binary work" + * in minutes, the evals answer "how well does its skill do" in up to hours. The + * release run waits for the smoke only; the evals are read where the promote + * decision is made. + */ +export const EVALS_CHECK_NAME = "cli-next-evals"; +export const EVALS_SCHEMA = "cli-next-evals/v1"; + +const EVALS_VERDICTS = ["ready", "look_first", "not_run"] as const; +export type EvalsVerdict = (typeof EVALS_VERDICTS)[number]; + +/** One eval set: the frozen regression set or the persona subset. */ +export interface EvalSet { + id: string; + result: string; + /** Why a set was not run, e.g. no change to the skill since the baseline. */ + reason?: string; + /** The version this set's scores are compared with. */ + baseline?: string; +} + +export interface EvalsReport { + schema: typeof EVALS_SCHEMA; + version: string; + sha: string; + previous?: string; + verdict: EvalsVerdict; + headline: string; + sets: EvalSet[]; + run_url?: string; +} + +const EVALS_BANNERS: Record = { + ready: "✓ Ready", + look_first: "⚠ Look first", + not_run: "● Skipped", +}; + +const VERDICTS = [ + "ready", + "look_first", + "staging_problem", + "binary_problem", +] as const; +export type Verdict = (typeof VERDICTS)[number]; + +export interface Part { + id: string; + result: string; + value?: string | number; + detail?: string; + previous?: string | number; +} + +export interface Report { + schema: typeof SCHEMA; + version: string; + sha: string; + previous?: string; + verdict: Verdict; + headline: string; + parts: Part[]; + run_url?: string; +} + +/** The fields of a GitHub check run this script reads. */ +export interface CheckRun { + id: number; + name: string; + status: string; + conclusion?: string | null; + started_at?: string | null; + details_url?: string | null; + html_url?: string | null; + app?: { id?: number } | null; + output?: { + title?: string | null; + summary?: string | null; + text?: string | null; + } | null; +} + +/** What the reader sees: markdown for the step summary, workflow commands for the log. */ +export interface Outcome { + summary: string; + annotations: string[]; +} + +/** The polling budget. Injectable so the tests do not wait 45 minutes. */ +export interface Limits { + /** Between two looks. */ + intervalMs: number; + /** No matching check by then: wego-ai did not start. */ + startMs: number; + /** A check exists but is not completed by then: stop waiting. */ + totalMs: number; +} + +export const DEFAULT_LIMITS: Limits = { + intervalMs: 30_000, + startMs: 10 * 60_000, + totalMs: 45 * 60_000, +}; + +const BANNERS: Record = { + ready: "✓ Ready", + look_first: "⚠ Look first", + staging_problem: "✗ Staging problem", + binary_problem: "✗ Binary problem", +}; + +const LABELS: Record = { + binary: "Binary is the tag", + stable: "Stable commands", + errors: "Error responses", + search: "Search round trips", + startup_ms: "Startup", + evals: "Skill evals", +}; + +const EVALS: Record = { + ok: "ok", + below: "below", + not_run: "not run", + not_configured: "not configured", +}; + +const RELEASE_TAG = /^v\d+\.\d+\.\d+$/; +const FULL_SHA = /^[0-9a-f]{40}$/; +const FENCE = /```json[^\S\n]*\n([\s\S]*?)```/g; + +/** + * A workflow command's message, escaped the way the runner unescapes it. The + * headline is another repository's text; without this a newline in it would end + * the annotation early and start whatever followed as a new line of the log. + */ +export function commandMessage(text: string): string { + return text.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); +} + +/** A table cell: one line, and no `|` to split the row. */ +function cell(text: string): string { + return text.replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim(); +} + +/** + * The report inside a check run's `output.text`, or the reason there is none. + * + * Exactly one ```json fence. Zero means the writer did not attach one; two means + * this reader would be guessing which is the report, and a guess is exactly what + * a person deciding a promote should not be shown. + */ +export function parseReport( + text: string | null | undefined, +): { report: Report } | { error: string } { + const fences = [...(text ?? "").matchAll(FENCE)]; + if (fences.length !== 1) { + return { + error: + fences.length === 0 + ? "the check carries no ```json block" + : `the check carries ${fences.length} \`\`\`json blocks, not one`, + }; + } + let data: unknown; + try { + data = JSON.parse(fences[0]?.[1] ?? ""); + } catch { + return { error: "the ```json block is not valid JSON" }; + } + if (typeof data !== "object" || data === null || Array.isArray(data)) { + return { error: "the ```json block is not an object" }; + } + const r = data as Record; + if (r.schema !== SCHEMA) { + return { + error: `the report's schema is ${JSON.stringify(r.schema ?? null)}, not ${SCHEMA}`, + }; + } + if (!VERDICTS.includes(r.verdict as Verdict)) { + return { + error: `the report's verdict ${JSON.stringify(r.verdict ?? null)} is not one this reader knows`, + }; + } + if (typeof r.headline !== "string" || typeof r.version !== "string") { + return { error: "the report has no headline or no version" }; + } + if (typeof r.sha !== "string") { + return { error: "the report names no commit" }; + } + if (r.previous !== undefined && typeof r.previous !== "string") { + return { error: "the report's previous version is not a string" }; + } + const parts = r.parts; + if ( + !Array.isArray(parts) || + !parts.every( + (p) => + typeof p === "object" && + p !== null && + typeof (p as Part).id === "string" && + typeof (p as Part).result === "string", + ) + ) { + return { error: "the report's parts are not a list of {id, result}" }; + } + return { report: r as unknown as Report }; +} + +/** + * The report among a commit's check runs: named `cli-next-smoke` AND written by + * wego-ai's App. The newest wins, because a re-run of wego-ai's lane writes a + * fresh check run rather than editing the old one. + */ +export function pickCheck( + runs: CheckRun[], + name: string = CHECK_NAME, +): CheckRun | undefined { + return runs + .filter((r) => r.name === name && r.app?.id === REPORT_APP_ID) + .sort( + (a, b) => + Date.parse(b.started_at ?? "") - Date.parse(a.started_at ?? "") || + b.id - a.id, + )[0]; +} + +function heading(tag: string): string { + return `### next report · ${tag}`; +} + +function detailsLine(check: CheckRun, report?: Report): string { + const url = check.details_url || check.html_url || report?.run_url; + return url + ? `Details: [private wego-ai run (org members)](${url})` + : "Details: the check carries no link to its run"; +} + +/** A step's value in one column: the part's own value or detail, or the previous one. */ +function stepCell(part: Part, which: "current" | "previous"): string { + if (which === "previous") { + return part.previous === undefined ? "–" : cell(String(part.previous)); + } + const shown = part.value ?? part.detail; + const text = shown === undefined ? "" : ` ${cell(String(shown))}`; + switch (part.result) { + case "pass": + return `✓${text}`; + case "fail": + return `✗${text}`; + case "noted": + return `noted${text}`; + default: + return cell(`${part.result}${text}`); + } +} + +function partCells(part: Part): [string, string] { + if (part.id === "startup_ms") { + const ms = (v: string | number | undefined) => + v === undefined ? "–" : cell(`${v} ms`); + return [ms(part.previous), ms(part.value)]; + } + if (part.id === "evals") { + return [ + part.previous === undefined + ? "–" + : cell(EVALS[String(part.previous)] ?? String(part.previous)), + cell(EVALS[part.result] ?? part.result), + ]; + } + return [stepCell(part, "previous"), stepCell(part, "current")]; +} + +/** The table: one row per part, the previous release beside this one. */ +export function renderTable(report: Report): string { + const prev = report.previous ? `v${cell(report.previous)}` : "previous"; + const rows = report.parts.map((part) => { + const [before, now] = partCells(part); + return `| ${cell(LABELS[part.id] ?? part.id)} | ${before} | ${now} |`; + }); + return [ + `| Part | ${prev} | v${cell(report.version)} |`, + "| --- | --- | --- |", + ...rows, + ].join("\n"); +} + +/** The verdict's one line: the banner a promoter reads first. */ +export function bannerLine(report: Report): string { + return `${BANNERS[report.verdict]}: ${cell(report.headline)}`; +} + +/** + * A completed check, rendered. `table: false` is the promote banner: the same + * verdict and link, without the rows the release run already showed. + */ +export function renderCompleted( + tag: string, + sha: string, + check: CheckRun, + opts: { table: boolean }, +): Outcome { + const parsed = parseReport(check.output?.text); + let reason = "error" in parsed ? parsed.error : undefined; + if ("report" in parsed && parsed.report.sha !== sha) { + // A report about another commit is not a report about this one, however + // well-formed. The check run sits on `sha`, so this is the writer + // disagreeing with itself, and the reader should not pick a side. + reason = `the report is for commit ${parsed.report.sha}, not ${sha}`; + } + if (reason !== undefined || !("report" in parsed)) { + const title = check.output?.title?.trim() || "(the check has no title)"; + return { + summary: [ + heading(tag), + "", + `● Report unreadable: ${cell(title)}`, + "", + detailsLine(check), + "", + ].join("\n"), + annotations: [ + `::warning::${commandMessage(`next-report: the ${CHECK_NAME} report for ${tag} could not be read (${reason}). See the private report before promoting.`)}`, + ], + }; + } + const report = parsed.report; + const lines = [ + heading(tag), + "", + bannerLine(report), + "", + detailsLine(check, report), + "", + ]; + if (opts.table) lines.push(renderTable(report), ""); + const message = `next-report: ${report.headline}. See the private report before promoting.`; + return { + summary: lines.join("\n"), + annotations: [ + `${report.verdict === "ready" ? "::notice::" : "::warning::"}${commandMessage(message)}`, + ], + }; +} + +/** The evals report inside its check run's `output.text`, or why there is none. */ +export function parseEvals( + text: string | null | undefined, +): { report: EvalsReport } | { error: string } { + const fences = [...(text ?? "").matchAll(FENCE)]; + if (fences.length !== 1) { + return { + error: `the check carries ${fences.length} \`\`\`json blocks, not one`, + }; + } + let data: unknown; + try { + data = JSON.parse(fences[0]?.[1] ?? ""); + } catch { + return { error: "the ```json block is not valid JSON" }; + } + const r = (data ?? {}) as Record; + if (r.schema !== EVALS_SCHEMA) { + return { + error: `the report's schema is ${JSON.stringify(r.schema ?? null)}, not ${EVALS_SCHEMA}`, + }; + } + if (!EVALS_VERDICTS.includes(r.verdict as EvalsVerdict)) { + return { + error: `the report's verdict ${JSON.stringify(r.verdict ?? null)} is not one this reader knows`, + }; + } + if ( + typeof r.headline !== "string" || + typeof r.version !== "string" || + typeof r.sha !== "string" || + !Array.isArray(r.sets) + ) { + return { error: "the report has no headline, version, commit or sets" }; + } + return { report: r as unknown as EvalsReport }; +} + +/** + * The evals in one line, for the release summary and the promote banner, plus + * the annotation a "look first" deserves. A missing or running evals check is a + * state, never an error: the evals may take hours, and the smoke already said + * whether the binary works. + */ +export function evalsLine( + sha: string, + found: Lookup, + now: number, +): { line: string; annotation?: string; pending?: boolean } { + if ("error" in found) { + return { line: `● Skill evals: could not be read (${cell(found.error)})` }; + } + const check = found.check; + if (!check) return { line: "● Skill evals: not started yet", pending: true }; + if (check.status !== "completed") { + const started = Date.parse(check.started_at ?? ""); + const ago = Number.isNaN(started) + ? "an unknown time" + : `${Math.max(0, Math.floor((now - started) / 60_000))} min`; + return { + line: `● Skill evals: running, started ${ago} ago`, + pending: true, + }; + } + const parsed = parseEvals(check.output?.text); + const reason = + "error" in parsed + ? parsed.error + : parsed.report.sha !== sha + ? `the report is for commit ${parsed.report.sha}, not ${sha}` + : undefined; + if (reason !== undefined || !("report" in parsed)) { + const title = check.output?.title?.trim() || "(the check has no title)"; + return { + line: `● Skill evals: report unreadable: ${cell(title)}`, + annotation: `::warning::${commandMessage(`next-report: the ${EVALS_CHECK_NAME} report could not be read (${reason}).`)}`, + }; + } + const report = parsed.report; + return { + line: `Skill evals: ${EVALS_BANNERS[report.verdict]}: ${cell(report.headline)}`, + annotation: + report.verdict === "look_first" + ? `::warning::${commandMessage(`next-report: skill evals: ${report.headline}. See the private report before promoting.`)}` + : undefined, + }; +} + +/** Append the evals line (and its annotation) to an outcome. */ +function withEvals( + outcome: Outcome, + evals: { line: string; annotation?: string }, +): Outcome { + return { + summary: `${outcome.summary.trimEnd()}\n\n${evals.line}\n`, + annotations: + evals.annotation === undefined + ? outcome.annotations + : [...outcome.annotations, evals.annotation], + }; +} + +/** A state with no report to show: one line, and the annotation that goes with it. */ +export function renderNoReport( + tag: string, + line: string, + annotation?: string, +): Outcome { + return { + summary: [heading(tag), "", line, ""].join("\n"), + annotations: annotation === undefined ? [] : [annotation], + }; +} + +/** The result of one look at the commit's check runs. */ +export type Lookup = { check: CheckRun | undefined } | { error: string }; + +export interface Clock { + now: () => number; + sleep: (ms: number) => Promise; + /** One progress line per look, for the job log. Silent when absent. */ + log?: (line: string) => void; +} + +function minutes(ms: number): string { + return `${Math.round(ms / 6_000) / 10} min`; +} + +/** What one look found, as the job log shows it while the wait goes on: the + * step summary only says anything once the wait is over. */ +export function pollLine( + found: Lookup, + elapsedMs: number, + firstSeen: boolean, +): string { + const at = minutes(elapsedMs); + if ("error" in found) + return `${at}: could not read the check runs (${found.error})`; + const check = found.check; + if (!check) return `${at}: not started yet`; + if (check.status === "completed") { + return `${at}: completed: ${check.output?.title ?? check.conclusion ?? "no title"}`; + } + const link = + firstSeen && check.details_url ? `: wego-ai run ${check.details_url}` : ""; + return `${at}: in progress${link}`; +} + +/** + * The release lane's wait, as a function of what `notify-verify` was answered. + * + * 404 and a refusal are known before any look: nothing was dispatched, so a + * 45-minute wait for a check that cannot come would only cost runner time. 202 + * and 409 both mean wego-ai is running (409 is a replayed request whose first + * attempt was accepted), so those wait. + */ +export async function watch( + tag: string, + sha: string, + notifyStatus: string, + lookup: () => Promise, + clock: Clock, + limits: Limits = DEFAULT_LIMITS, + lookupEvals?: () => Promise, +): Promise { + if (notifyStatus === "404") { + return renderNoReport(tag, "● No report: the receiver is switched off"); + } + if (notifyStatus !== "202" && notifyStatus !== "409") { + return renderNoReport( + tag, + "● No report: the request to wego-ai was refused", + ); + } + const start = clock.now(); + let seen = false; + let lastError: string | undefined; + clock.log?.( + `waiting for ${CHECK_NAME} on ${tag} (${sha.slice(0, 7)}), up to ${minutes(limits.totalMs)}, looking every ${Math.round(limits.intervalMs / 1000)} s`, + ); + for (;;) { + const found = await lookup(); + clock.log?.( + pollLine( + found, + clock.now() - start, + !seen && "check" in found && found.check !== undefined, + ), + ); + if ("error" in found) { + lastError = found.error; + } else { + lastError = undefined; + if (found.check) { + seen = true; + if (found.check.status === "completed") { + const smoke = renderCompleted(tag, sha, found.check, { table: true }); + if (!lookupEvals) return smoke; + // One look, never a wait: the evals can take hours, and the promote + // banner reads them again when the decision is made. + const evals = evalsLine(sha, await lookupEvals(), clock.now()); + return withEvals( + smoke, + evals.pending + ? { line: `${evals.line}; the promote banner shows the result` } + : evals, + ); + } + } + } + const elapsed = clock.now() - start; + if (!seen && elapsed >= limits.startMs) { + if (lastError !== undefined) { + return renderNoReport( + tag, + "● No report: the check runs could not be read", + `::warning::${commandMessage(`next-report: the check runs for ${tag} could not be read (${lastError}).`)}`, + ); + } + const minutes = Math.round(limits.startMs / 60_000); + return renderNoReport( + tag, + "● No report: wego-ai did not start", + `::warning::${commandMessage(`next-report: wego-ai wrote no ${CHECK_NAME} check for ${tag} within ${minutes} min, though the receiver accepted the request.`)}`, + ); + } + if (elapsed >= limits.totalMs) { + const minutes = Math.round(limits.totalMs / 60_000); + return renderNoReport( + tag, + `● No report yet: still running after ${minutes} min`, + `::notice::${commandMessage(`next-report: the ${CHECK_NAME} check for ${tag} is still running. promote-cli.yml shows its verdict when it lands.`)}`, + ); + } + await clock.sleep(limits.intervalMs); + } +} + +/** + * The promote lane's single look. No waiting: a promoter who dispatches while + * the smoke is still running should see that, not sit behind it. + */ +export function banner( + tag: string, + sha: string, + found: Lookup, + now: number, + evalsFound?: Lookup, +): Outcome { + const smoke = smokeBanner(tag, sha, found, now); + return evalsFound === undefined + ? smoke + : withEvals(smoke, evalsLine(sha, evalsFound, now)); +} + +function smokeBanner( + tag: string, + sha: string, + found: Lookup, + now: number, +): Outcome { + if ("error" in found) { + return renderNoReport( + tag, + "● No report: the check runs could not be read", + `::warning::${commandMessage(`next-report: the check runs for ${tag} could not be read (${found.error}).`)}`, + ); + } + const check = found.check; + if (!check) { + return renderNoReport( + tag, + `● No report: wego-ai wrote no ${CHECK_NAME} check for this commit`, + ); + } + if (check.status !== "completed") { + const started = Date.parse(check.started_at ?? ""); + const ago = Number.isNaN(started) + ? "an unknown time" + : `${Math.max(0, Math.floor((now - started) / 60_000))} min`; + return { + summary: [ + heading(tag), + "", + `● No report yet, started ${ago} ago`, + "", + detailsLine(check), + "", + ].join("\n"), + annotations: [], + }; + } + return renderCompleted(tag, sha, check, { table: false }); +} + +export interface Env { + GITHUB_REPOSITORY?: string; + GITHUB_TOKEN?: string; + GITHUB_API_URL?: string; + TAG?: string; + SHA?: string; + NOTIFY_STATUS?: string; +} + +type Fetch = (url: string, init?: RequestInit) => Promise; + +function github(env: Env, fetcher: Fetch) { + const base = (env.GITHUB_API_URL || "https://api.github.com").replace( + /\/+$/, + "", + ); + const headers = { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${env.GITHUB_TOKEN ?? ""}`, + "User-Agent": "wego-cli-next-report", + "X-GitHub-Api-Version": "2022-11-28", + }; + const get = async (path: string): Promise => { + const res = await fetcher(`${base}${path}`, { headers }); + if (!res.ok) throw new Error(`GET ${path} answered HTTP ${res.status}`); + return res.json(); + }; + const repo = env.GITHUB_REPOSITORY ?? ""; + return { + /** The commit a tag names, through the API rather than a full-history checkout. */ + async commitOf(ref: string): Promise { + const data = (await get( + `/repos/${repo}/commits/${encodeURIComponent(ref)}`, + )) as { sha?: unknown }; + if (typeof data.sha !== "string" || !FULL_SHA.test(data.sha)) { + throw new Error(`${ref} resolved to no commit`); + } + return data.sha; + }, + /** + * One look. `app_id` narrows the page on the server; `pickCheck` is the + * filter this relies on. + */ + async lookup(sha: string, name: string = CHECK_NAME): Promise { + try { + const data = (await get( + `/repos/${repo}/commits/${sha}/check-runs?check_name=${name}&app_id=${REPORT_APP_ID}&per_page=100`, + )) as { check_runs?: CheckRun[] }; + return { check: pickCheck(data.check_runs ?? [], name) }; + } catch (err) { + return { error: (err as Error).message }; + } + }, + }; +} + +/** + * Both modes, end to end, over an injected `fetch` and clock. Throws only on a + * bug; every expected state is an Outcome. + */ +export async function run( + argv: string[], + env: Env, + fetcher: Fetch, + clock: Clock, + limits: Limits = DEFAULT_LIMITS, +): Promise { + const tag = env.TAG ?? ""; + const shown = RELEASE_TAG.test(tag) ? tag : "(no tag)"; + if (!RELEASE_TAG.test(tag)) { + return renderNoReport( + shown, + `● No report: ${JSON.stringify(tag)} is not a vX.Y.Z release tag`, + ); + } + if (!/^[\w.-]+\/[\w.-]+$/.test(env.GITHUB_REPOSITORY ?? "")) { + return renderNoReport( + tag, + "● No report: GITHUB_REPOSITORY does not name a repository", + ); + } + const api = github(env, fetcher); + + if (argv.includes("--banner")) { + let sha: string; + try { + sha = await api.commitOf(tag); + } catch (err) { + return renderNoReport( + tag, + "● No report: the tag's commit could not be read", + `::warning::${commandMessage(`next-report: ${(err as Error).message}.`)}`, + ); + } + return banner( + tag, + sha, + await api.lookup(sha), + clock.now(), + await api.lookup(sha, EVALS_CHECK_NAME), + ); + } + + const sha = env.SHA ?? ""; + if (!FULL_SHA.test(sha)) { + return renderNoReport(tag, "● No report: no commit to look up"); + } + return watch( + tag, + sha, + env.NOTIFY_STATUS ?? "", + () => api.lookup(sha), + clock, + limits, + () => api.lookup(sha, EVALS_CHECK_NAME), + ); +} + +if (import.meta.main) { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + const emit = async (outcome: Outcome) => { + for (const line of outcome.annotations) console.log(line); + console.log(outcome.summary); + if (summaryPath) { + const { appendFileSync } = await import("node:fs"); + appendFileSync(summaryPath, `${outcome.summary}\n`); + } + }; + try { + await emit( + await run(process.argv.slice(2), process.env as Env, fetch, { + now: () => Date.now(), + sleep: (ms) => Bun.sleep(ms), + log: (line) => console.log(line), + }), + ); + } catch (err) { + // A bug here must not become a red run: the report is advice, and the lane + // around it has already done its work. + await emit( + renderNoReport( + process.env.TAG || "(no tag)", + "● No report: next-report crashed", + `::warning::${commandMessage(`next-report crashed: ${(err as Error)?.message ?? String(err)}`)}`, + ), + ); + } + process.exit(0); +} diff --git a/scripts/next-report/payloads/binary-problem.json b/scripts/next-report/payloads/binary-problem.json new file mode 100644 index 0000000..2421d35 --- /dev/null +++ b/scripts/next-report/payloads/binary-problem.json @@ -0,0 +1,39 @@ +{ + "schema": "cli-next-smoke/v1", + "version": "1.5.0", + "sha": "0123456789abcdef0123456789abcdef01234567", + "previous": "1.4.2", + "verdict": "binary_problem", + "headline": "the built-in skill does not match the tag", + "parts": [ + { + "id": "binary", + "result": "fail", + "detail": "version · commit · skill" + }, + { + "id": "stable", + "result": "pass", + "value": "6/6", + "previous": "6/6" + }, + { + "id": "errors", + "result": "pass", + "value": "3/3", + "previous": "3/3" + }, + { + "id": "search", + "result": "pass", + "value": "flights ✓ hotels ✓" + }, + { + "id": "startup_ms", + "result": "report", + "value": 41, + "previous": 39 + } + ], + "run_url": "https://github.com/wego/wego-ai/actions/runs/1" +} diff --git a/scripts/next-report/payloads/evals-look-first.json b/scripts/next-report/payloads/evals-look-first.json new file mode 100644 index 0000000..aa8de2b --- /dev/null +++ b/scripts/next-report/payloads/evals-look-first.json @@ -0,0 +1,21 @@ +{ + "schema": "cli-next-evals/v1", + "version": "1.5.0", + "sha": "0123456789abcdef0123456789abcdef01234567", + "previous": "1.4.2", + "verdict": "look_first", + "headline": "skill answers scored lower than v1.4.2", + "sets": [ + { + "id": "frozen", + "result": "below", + "baseline": "1.4.2" + }, + { + "id": "persona", + "result": "ok", + "baseline": "1.4.2" + } + ], + "run_url": "https://github.com/wego/wego-ai/actions/runs/1" +} diff --git a/scripts/next-report/payloads/evals-partial.json b/scripts/next-report/payloads/evals-partial.json new file mode 100644 index 0000000..d79fe53 --- /dev/null +++ b/scripts/next-report/payloads/evals-partial.json @@ -0,0 +1,22 @@ +{ + "schema": "cli-next-evals/v1", + "version": "1.5.0", + "sha": "0123456789abcdef0123456789abcdef01234567", + "previous": "1.4.2", + "verdict": "ready", + "headline": "frozen set held; persona subset skipped, no skill or command change since v1.4.2", + "sets": [ + { + "id": "frozen", + "result": "ok", + "baseline": "1.4.2" + }, + { + "id": "persona", + "result": "not_run", + "baseline": "1.3.0", + "reason": "no change to the skill or any --help since v1.4.2" + } + ], + "run_url": "https://github.com/wego/wego-ai/actions/runs/1" +} diff --git a/scripts/next-report/payloads/evals-ready.json b/scripts/next-report/payloads/evals-ready.json new file mode 100644 index 0000000..76094e8 --- /dev/null +++ b/scripts/next-report/payloads/evals-ready.json @@ -0,0 +1,21 @@ +{ + "schema": "cli-next-evals/v1", + "version": "1.5.0", + "sha": "0123456789abcdef0123456789abcdef01234567", + "previous": "1.4.2", + "verdict": "ready", + "headline": "skill answers held against v1.4.2", + "sets": [ + { + "id": "frozen", + "result": "ok", + "baseline": "1.4.2" + }, + { + "id": "persona", + "result": "ok", + "baseline": "1.4.2" + } + ], + "run_url": "https://github.com/wego/wego-ai/actions/runs/1" +} diff --git a/scripts/next-report/payloads/evals-skipped.json b/scripts/next-report/payloads/evals-skipped.json new file mode 100644 index 0000000..940f811 --- /dev/null +++ b/scripts/next-report/payloads/evals-skipped.json @@ -0,0 +1,23 @@ +{ + "schema": "cli-next-evals/v1", + "version": "1.5.0", + "sha": "0123456789abcdef0123456789abcdef01234567", + "previous": "1.4.2", + "verdict": "not_run", + "headline": "nothing in skills/ or src/ changed since v1.4.2", + "sets": [ + { + "id": "frozen", + "result": "not_run", + "baseline": "1.4.2", + "reason": "no change to the skill or src/ since v1.4.2" + }, + { + "id": "persona", + "result": "not_run", + "baseline": "1.4.2", + "reason": "no change to the skill or any --help since v1.4.2" + } + ], + "run_url": "https://github.com/wego/wego-ai/actions/runs/1" +} diff --git a/scripts/next-report/payloads/look-first.json b/scripts/next-report/payloads/look-first.json new file mode 100644 index 0000000..627a8f5 --- /dev/null +++ b/scripts/next-report/payloads/look-first.json @@ -0,0 +1,39 @@ +{ + "schema": "cli-next-smoke/v1", + "version": "1.5.0", + "sha": "0123456789abcdef0123456789abcdef01234567", + "previous": "1.4.2", + "verdict": "look_first", + "headline": "startup is 28% slower than v1.4.2", + "parts": [ + { + "id": "binary", + "result": "pass", + "detail": "version · commit · skill" + }, + { + "id": "stable", + "result": "pass", + "value": "6/6", + "previous": "6/6" + }, + { + "id": "errors", + "result": "pass", + "value": "3/3", + "previous": "3/3" + }, + { + "id": "search", + "result": "pass", + "value": "flights ✓ hotels ✓" + }, + { + "id": "startup_ms", + "result": "report", + "value": 50, + "previous": 39 + } + ], + "run_url": "https://github.com/wego/wego-ai/actions/runs/1" +} diff --git a/scripts/next-report/payloads/malformed.json b/scripts/next-report/payloads/malformed.json new file mode 100644 index 0000000..8ae3003 --- /dev/null +++ b/scripts/next-report/payloads/malformed.json @@ -0,0 +1 @@ +{"schema":"cli-next-smoke/v1","verdict": diff --git a/scripts/next-report/payloads/ready.json b/scripts/next-report/payloads/ready.json new file mode 100644 index 0000000..8311723 --- /dev/null +++ b/scripts/next-report/payloads/ready.json @@ -0,0 +1,39 @@ +{ + "schema": "cli-next-smoke/v1", + "version": "1.5.0", + "sha": "0123456789abcdef0123456789abcdef01234567", + "previous": "1.4.2", + "verdict": "ready", + "headline": "nothing new is wrong in v1.5.0", + "parts": [ + { + "id": "binary", + "result": "pass", + "detail": "version · commit · skill" + }, + { + "id": "stable", + "result": "pass", + "value": "6/6", + "previous": "6/6" + }, + { + "id": "errors", + "result": "pass", + "value": "3/3", + "previous": "3/3" + }, + { + "id": "search", + "result": "pass", + "value": "flights ✓ hotels ✓" + }, + { + "id": "startup_ms", + "result": "report", + "value": 41, + "previous": 39 + } + ], + "run_url": "https://github.com/wego/wego-ai/actions/runs/1" +} diff --git a/scripts/next-report/payloads/staging-problem.json b/scripts/next-report/payloads/staging-problem.json new file mode 100644 index 0000000..99e4692 --- /dev/null +++ b/scripts/next-report/payloads/staging-problem.json @@ -0,0 +1,39 @@ +{ + "schema": "cli-next-smoke/v1", + "version": "1.5.0", + "sha": "0123456789abcdef0123456789abcdef01234567", + "previous": "1.4.2", + "verdict": "staging_problem", + "headline": "whoami failed and staging's health check was failing", + "parts": [ + { + "id": "binary", + "result": "pass", + "detail": "version · commit · skill" + }, + { + "id": "stable", + "result": "fail", + "value": "5/6", + "previous": "6/6" + }, + { + "id": "errors", + "result": "pass", + "value": "3/3", + "previous": "3/3" + }, + { + "id": "search", + "result": "pass", + "value": "flights ✓ hotels ✓" + }, + { + "id": "startup_ms", + "result": "report", + "value": 41, + "previous": 39 + } + ], + "run_url": "https://github.com/wego/wego-ai/actions/runs/1" +} diff --git a/scripts/next-report/payloads/unknown-schema.json b/scripts/next-report/payloads/unknown-schema.json new file mode 100644 index 0000000..b45cdba --- /dev/null +++ b/scripts/next-report/payloads/unknown-schema.json @@ -0,0 +1,39 @@ +{ + "schema": "cli-next-smoke/v9", + "version": "1.5.0", + "sha": "0123456789abcdef0123456789abcdef01234567", + "previous": "1.4.2", + "verdict": "ready", + "headline": "nothing new is wrong in v1.5.0", + "parts": [ + { + "id": "binary", + "result": "pass", + "detail": "version · commit · skill" + }, + { + "id": "stable", + "result": "pass", + "value": "6/6", + "previous": "6/6" + }, + { + "id": "errors", + "result": "pass", + "value": "3/3", + "previous": "3/3" + }, + { + "id": "search", + "result": "pass", + "value": "flights ✓ hotels ✓" + }, + { + "id": "startup_ms", + "result": "report", + "value": 41, + "previous": 39 + } + ], + "run_url": "https://github.com/wego/wego-ai/actions/runs/1" +} diff --git a/scripts/plugin-git.ts b/scripts/plugin-git.ts index b2e9653..9b88cdd 100644 --- a/scripts/plugin-git.ts +++ b/scripts/plugin-git.ts @@ -19,7 +19,7 @@ import { redactRemote } from "./plugin-publish"; // call time (Sonar typescript:S4036 - a writable directory earlier on PATH could // shadow the binary, and these processes hold a publish credential). `NOSONAR` // does not suppress hotspots, so the fix is the resolution, not a comment. Same -// idiom as `src/testing/cli-runner.ts` and `apps/docs/scripts/diff-board.ts`. +// idiom as `integration/harness/binary.ts` and `apps/docs/scripts/diff-board.ts`. // // Resolved LAZILY, on the first git call. Both scripts have paths that return // before any git runs - `--print-plan` and the graceful token skip - and both diff --git a/scripts/unit-tier-guard.test.ts b/scripts/unit-tier-guard.test.ts new file mode 100644 index 0000000..442621a --- /dev/null +++ b/scripts/unit-tier-guard.test.ts @@ -0,0 +1,129 @@ +/** + * A unit test never asserts on a command's stdout, stderr or exit code: those + * belong to the integration tier, which runs the compiled binary as its own + * process against a contract-checked fake (`integration/`). + * + * Enforced by what a unit test may import. A command's output only exists once + * the command runs, so a unit test that cannot reach a command entry point cannot + * assert on its output. What stays reachable is what unit tests are for: the + * parsers that turn argv into API-call arguments, the HTTP client, and pure logic. + * + * `skill`, `update` and `uninstall` are left out on purpose. They act on the + * installed binary and the user's file system, which the release's artifact checks + * exercise on real installs (`install-smoke.sh`, `update-smoke.sh`, + * `upgrade-path.sh`), so their handlers keep in-process tests with injected file + * systems. + */ + +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = fileURLToPath(new URL("../", import.meta.url)); +/** This file names every way in, as strings, to prove it catches them. */ +const SELF = fileURLToPath(import.meta.url); + +/** module (relative to src/) → the command entry points it exports. */ +const ENTRY_POINTS: Record = { + index: ["run"], + commands: [ + "login", + "whoami", + "places", + "info", + "feedback", + "flights", + "hotels", + "logout", + ], + "config-command": ["config"], + "telemetry-command": ["telemetry"], +}; + +function unitTestFiles(): string[] { + const out: string[] = []; + for (const dir of ["src", "scripts"]) { + const walk = (d: string) => { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const path = join(d, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.name.endsWith(".test.ts") && path !== SELF) { + out.push(path); + } + } + }; + walk(join(ROOT, dir)); + } + return out.sort(); +} + +/** `import { a, type B, c as d } from ""` → the imported names. */ +export function importedNames(source: string, spec: RegExp): string[] { + const names: string[] = []; + const re = /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+"([^"]+)"/g; + for (const m of source.matchAll(re)) { + if (!spec.test(m[2] ?? "")) continue; + for (const raw of (m[1] ?? "").split(",")) { + const name = raw.trim(); + if (!name || name.startsWith("type ")) continue; + names.push(name.split(/\s+as\s+/)[0]?.trim() ?? name); + } + } + return names; +} + +export function violations(path: string, source: string): string[] { + const found: string[] = []; + for (const [module, entries] of Object.entries(ENTRY_POINTS)) { + const spec = new RegExp(`^(\\./|\\.\\./src/)${module}$`); + for (const name of importedNames(source, spec)) { + if (entries.includes(name)) + found.push(`${path} imports ${name} from ${module}`); + } + } + if (/testing\/cli-runner/.test(source)) { + found.push(`${path} imports the in-process CLI runner`); + } + if (/spawn\([^)]*src\/index\.ts/s.test(source)) { + found.push(`${path} spawns src/index.ts`); + } + return found; +} + +describe("unit tests stay below the command boundary", () => { + it("no unit test reaches a command entry point", () => { + const found = unitTestFiles().flatMap((file) => + violations(relative(ROOT, file), readFileSync(file, "utf8")), + ); + expect(found).toEqual([]); + }); + + it("catches each way in", () => { + expect( + violations( + "x.test.ts", + [ + 'import { run } from "./index";', + 'import { parseLoginArgs, login as doLogin } from "./commands";', + 'import { config } from "./config-command";', + 'import { runCli } from "./testing/cli-runner";', + ].join("\n"), + ), + ).toEqual([ + "x.test.ts imports run from index", + "x.test.ts imports login from commands", + "x.test.ts imports config from config-command", + "x.test.ts imports the in-process CLI runner", + ]); + }); + + it("lets a parser and a type through", () => { + expect( + violations( + "x.test.ts", + 'import { type FlightsDeps, parseFlightSearchArgs } from "./commands";', + ), + ).toEqual([]); + }); +}); diff --git a/scripts/workflow-lanes.test.ts b/scripts/workflow-lanes.test.ts index 977dd8a..a373b8e 100644 --- a/scripts/workflow-lanes.test.ts +++ b/scripts/workflow-lanes.test.ts @@ -487,3 +487,152 @@ describe("every store-writing lane with a manual trigger gates on both actors", }); } }); + +/** + * THE PROMOTE BANNER IS A READING, NOT A GATE. + * + * `next-report` at the top of `promote-cli.yml` shows wego-ai's verdict for the + * tag. It is deliberately outside the gate chain: a promote is a human decision, + * and the banner exists so the verdict was in front of that human, not to make + * the decision for them. Two ways it could quietly become a gate: + * + * - a job `needs:` it, directly or through another job, so a red or slow + * banner holds `approve` or `promote` back; + * - it grows a `needs:` of its own, so it waits behind the gate it is meant to + * sit beside, and the promoter reads the verdict after the pointer moved. + * + * Its capabilities (read-only, no secret, never red) are asserted with the + * release lane's reader in `workflow-shape.test.ts`. + */ +describe("promote-cli.yml: the next report banner gates nothing", () => { + const wf = workflow("promote-cli.yml") as { + jobs: Record< + string, + Job & { + "continue-on-error"?: unknown; + steps?: { run?: string; "continue-on-error"?: unknown }[]; + } + >; + }; + const banner = Object.entries(wf.jobs).find(([, job]) => + (job.steps ?? []).some((s) => + /scripts\/next-report\.ts\s+--banner/.test(s.run ?? ""), + ), + ); + + it("exists, in one job", () => { + expect(banner).toBeDefined(); + }); + + it("is in no other job's needs chain", () => { + const name = banner?.[0] as string; + for (const other of Object.keys(wf.jobs)) { + expect( + ancestryOf(other, wf.jobs), + `promote-cli.yml: '${other}' waits for the banner`, + ).not.toContain(name); + } + }); + + it("waits for nothing itself", () => { + expect(needsOf(banner?.[1])).toEqual([]); + }); + + it("cannot fail the run, at the job or at the step", () => { + const job = banner?.[1]; + expect(job?.["continue-on-error"]).toBe(true); + const step = (job?.steps ?? []).find((s) => + (s.run ?? "").includes("next-report.ts"), + ); + expect( + (step as Record | undefined)?.["continue-on-error"], + ).toBe(true); + }); +}); + +/** + * THE INTEGRATION MATRIX COVERS WHAT THE RELEASE BUILDS, AND HOLDS PUBLICATION. + * + * `integration` runs the compiled binary against a fake API on each target's own + * runner. Its matrix is a literal and `scripts/build-release.ts`'s target list is + * another, and nothing makes them move together: a sixth target added to the + * build would ship with no job ever having executed it, green. So the set is + * derived from the build script, not restated here. + * + * And a job `release` does not need is decoration. The whole point is that a + * target that cannot run its own commands never reaches `cli/next`. + */ +describe("release-cli.yml: integration runs every built target before publication", () => { + const wf = workflow("release-cli.yml") as { + jobs: Record< + string, + Job & { + name?: string; + strategy?: { + "fail-fast"?: boolean; + matrix?: { + include?: { target: string; runner: string; asset: string }[]; + }; + }; + } + >; + }; + const job = wf.jobs.integration; + const include = job?.strategy?.matrix?.include ?? []; + + /** The asset names `build-release.ts` writes: `wego-`, per target. */ + const built = [ + ...readFileSync("scripts/build-release.ts", "utf8").matchAll( + /\{\s*target:\s*"bun-[^"]+",\s*suffix:\s*"([^"]+)"\s*\}/g, + ), + ].map((m) => `wego-${m[1]}`); + + it("reads a target list out of the build script", () => { + // A reshaped TARGETS table would otherwise parse as "builds nothing" and the + // comparison below would pass against an empty matrix. + expect(built.length).toBeGreaterThanOrEqual(5); + }); + + it("covers exactly the assets the build produces", () => { + expect(include.map((e) => e.asset).sort()).toEqual([...built].sort()); + }); + + it("runs each target on a runner that can execute it", () => { + const families: Record = { + linux: "ubuntu-", + darwin: "macos-", + windows: "windows-", + }; + for (const { target, runner, asset } of include) { + expect(asset, `${target} runs ${asset}`).toContain(target); + const [os = "", arch = ""] = target.split("-"); + expect( + runner.startsWith(families[os] ?? "?"), + `${target} on ${runner}`, + ).toBe(true); + // `macos-latest` is Apple silicon and `ubuntu-latest` is x64, so the other + // architecture needs a label that names it. + if (os === "linux" && arch === "arm64") expect(runner).toMatch(/-arm$/); + if (os === "darwin" && arch === "x64") expect(runner).toMatch(/intel/); + } + }); + + it("names each leg by its target, and lets every leg finish", () => { + expect(job?.name).toBe(`integration (\${{ matrix.target }})`); + expect(job?.strategy?.["fail-fast"]).toBe(false); + }); + + it("drives the built artifact, not a binary of its own", () => { + const step = (job?.steps ?? []).find((s) => + (s.run ?? "").includes("bun run test:integration"), + ); + expect(String(step?.env?.WEGO_INTEGRATION_BINARY)).toContain( + `\${{ matrix.asset }}`, + ); + }); + + it("holds publication: release needs it, and it does not need release", () => { + expect(needsOf(wf.jobs.release)).toContain("integration"); + expect(ancestryOf("integration", wf.jobs)).not.toContain("release"); + }); +}); diff --git a/scripts/workflow-shape.test.ts b/scripts/workflow-shape.test.ts index 741d3c2..13708a6 100644 --- a/scripts/workflow-shape.test.ts +++ b/scripts/workflow-shape.test.ts @@ -35,18 +35,36 @@ * signing calls that drifted apart, killing an edge run and then the first real * release (see `.github/actions/sign-manifest`). Comments were present throughout. * - * ASSERTED BY PROPERTY, NOT BY NAME. Nothing here pins the string "sign": the - * claim is "exactly one job may sign, it is the one that runs the signing action, - * and it can do nothing else". Rename the job freely; violate the shape and this - * fails. + * ASSERTED BY PROPERTY WHERE THERE IS A PROPERTY. Nothing here pins the string + * "sign": the claim is "the job that may sign is the one that runs the signing + * action, and it can do nothing else". Rename the job freely; violate the shape + * and this fails. + * + * `ID_TOKEN_HOLDERS` below is the one deliberate exception, and it is a LIST + * because there is no longer a property that separates the holders. Two jobs now + * want an OIDC token for unrelated reasons: `sign` exchanges it for a Fulcio + * certificate, and the verification job presents it as an identity to a + * receiver in wego-ai that writes a check run back. "The job that runs cosign" + * does not describe the second kind, and "any job that needs an identity" + * describes every job anyone will ever want to add. So the set is enumerated, and + * widening it is a diff a release signer reviews rather than a property that + * quietly admits one more. * * The mutations this suite kills: - * - `id-token: write` added at workflow level, or to a second job -> "exactly one". + * - `id-token: write` added at workflow level, or to a job outside the named + * set -> "exactly these jobs". * - the signing job given `setup-bun`, `bun install` or any `bun run` -> "installs nothing". - * - the signing job given an `environment:` or the store token -> "cannot reach the store". + * - the signing job or a verification job given an `environment:` or the store + * token -> "cannot reach the store". * - a store-writing job given `id-token: write` -> "cannot sign". * - a lane given a `workflow_call` trigger, or a job delegating to a reusable * workflow -> "the signing call stays in the lane file". + * - a verification job given a guard or a setting, or its body grown a third + * field -> "always asks, and sends only what the receiver needs". + * - the verification job given a checkout or any grant beside `id-token` -> + * "an identity leaves, and nothing else does". + * - a report reader given a write grant, an OIDC token or a secret, or made + * able to go red -> "reads the report, and nothing else". */ import { describe, expect, it } from "bun:test"; import { existsSync, readdirSync, readFileSync } from "node:fs"; @@ -62,6 +80,33 @@ const SIGNING_LANES: string[] = ["edge-cli.yml", "release-cli.yml"]; /** Every lane that can write a ring, signing or not. */ const PUBLISHING_LANES: string[] = [...SIGNING_LANES, "promote-cli.yml"]; +/** + * EVERY JOB IN THE REPOSITORY THAT MAY HOLD `id-token: write`, by file and name. + * + * Three, in two files, and they are two kinds of thing: + * + * edge-cli.yml sign cosign, for the edge ring's signed record + * release-cli.yml sign cosign, for the release manifest + * release-cli.yml notify-verify the release's identity, to the verify receiver + * + * The receiver in wego-ai reads the token's claims - `repository`, `event_name`, + * `ref`, `job_workflow_ref`, `sha` - and answers 403 to anything else, so the + * value of `id-token: write` in the verification job is precisely that it cannot + * be minted anywhere else and still match. A fourth job quietly granted the + * permission is a fourth place a token naming this repository can be produced, and + * `workflow-lanes.test.ts` cannot see it because none of these jobs touches a ring. + */ +const ID_TOKEN_HOLDERS: Record = { + "edge-cli.yml": ["sign"], + "release-cli.yml": ["sign", "notify-verify"], +}; + +/** The job that presents an identity rather than signs with one. */ +const VERIFY_JOBS: [string, string][] = [["release-cli.yml", "notify-verify"]]; + +/** The one receiver, spelled out in the workflow rather than read from a setting. */ +const RECEIVER_URL = "https://api.wego.com/.well-known/internal/cli-verify"; + const STORE_TOKEN = "BLOB_READ_WRITE_TOKEN"; /** The composite action both lanes sign with; a local path, never a package. */ @@ -85,6 +130,7 @@ const isLocalUses = (uses: string): boolean => uses.startsWith("./") || uses.startsWith("$/"); interface Step { + name?: string; uses?: string; run?: string; env?: Record; @@ -94,6 +140,8 @@ interface Job { environment?: unknown; env?: Record; steps?: Step[]; + needs?: string | string[]; + if?: string; /** A job-level `uses:` is how a reusable workflow is called. */ uses?: string; } @@ -135,24 +183,61 @@ function signingJobs(wf: Workflow): string[] { .map(([name]) => name); } -describe.each(SIGNING_LANES)("%s: the signing capability", (file) => { - const wf = lane(file); +/** + * 1 - the whole security claim, half one, and now stated over the WHOLE directory + * rather than per signing lane. + * + * Scoping it to the two signing lanes was right while `sign` was the only holder + * anywhere; it is not right now, because a third file can grant the permission and + * a suite that only reads two files would never look. `readdirSync` is what makes + * "no other job, in no other workflow" an assertion rather than an intention. + */ +describe("id-token: write is granted to exactly the named jobs", () => { + const files = readdirSync(".github/workflows") + .filter((f) => /\.ya?ml$/.test(f)) + .sort(); - // 1 — the whole security claim, half one. - it("grants id-token to exactly one job, and never at workflow level", () => { - const holders = signers(wf); - expect(holders).not.toContain(""); - expect(holders).toHaveLength(1); + it("finds the workflows the named set refers to", () => { + // A renamed file would otherwise drop out of the scan AND out of the + // comparison, leaving this suite green having asserted nothing about it. + for (const file of Object.keys(ID_TOKEN_HOLDERS)) { + expect( + files, + `${file} is named in ID_TOKEN_HOLDERS but does not exist`, + ).toContain(file); + } + }); + + it.each(files)("%s grants it to exactly the jobs named for it", (file) => { + const holders = signers(lane(file)); + // A workflow-level grant reaches every job in the file, including ones added + // later by someone who never read this test. + expect( + holders, + `${file} grants id-token at workflow level, which reaches every job in it`, + ).not.toContain(""); + expect( + holders.sort(), + `${file}'s id-token holders are not the named set. Widening it is a deliberate edit to ID_TOKEN_HOLDERS, reviewed by a release signer.`, + ).toEqual([...(ID_TOKEN_HOLDERS[file] ?? [])].sort()); }); +}); + +describe.each(SIGNING_LANES)("%s: the signing capability", (file) => { + const wf = lane(file); it("gives it to the job that actually signs, and to no other", () => { - expect(signingJobs(wf)).toEqual(signers(wf)); + // Still a property: whatever else holds an OIDC token in this file, exactly + // one job runs the signing action, and it is one of the named holders. + const signing = signingJobs(wf); + expect(signing).toHaveLength(1); + expect(signers(wf)).toContain(signing[0] as string); }); // 2 — the signing job runs no repository code, so there is nothing in it to // abuse the token it holds. `sign-manifest` needs cosign and a dist/ only. it("keeps the signing job free of anything that installs or runs dependencies", () => { - const [name] = signers(wf); + const [name] = signingJobs(wf); const steps = wf.jobs[name as string]?.steps ?? []; for (const step of steps) { expect(step.uses ?? "").not.toContain("setup-bun"); @@ -162,13 +247,281 @@ describe.each(SIGNING_LANES)("%s: the signing capability", (file) => { // 3 — and cannot reach the store even if something in it did run. it("keeps the store token and its environment out of the signing job", () => { - const [name] = signers(wf); + const [name] = signingJobs(wf); const job = wf.jobs[name as string] as Job; expect(job.environment).toBeUndefined(); expect(JSON.stringify(job)).not.toContain(STORE_TOKEN); }); }); +/** + * THE VERIFICATION JOBS: an identity leaves, and nothing else does. + * + * It holds `id-token: write` so a receiver in wego-ai can read a token GitHub + * signed and decide, from its claims alone, whether this repository is asking. The + * whole arrangement rests on this repository holding NO credential for it, which is + * three separate properties and not one: + * + * - no `environment:`, so the job cannot be handed the store token the way + * `release` and the promote lanes are; + * - no `secrets.` reference, so nothing is handed to it directly either; + * - the request body carries the tag and the sha and nothing more, so a future + * field cannot become a place to put something that matters. + * + * And no switch: the job always asks, and a receiver that is switched off answers + * 404, which the job reports as "not verified" rather than as a red release. + */ +describe.each( + VERIFY_JOBS, +)("%s %s: presents an identity, holds no secret", (file, name) => { + const wf = lane(file); + const job = wf.jobs[name] as Job | undefined; + const text = JSON.stringify(job ?? {}); + + it("exists", () => { + expect(job, `${file} has no job '${name}'`).toBeDefined(); + }); + + it("declares no environment, so it can never be handed the store token", () => { + expect(job?.environment).toBeUndefined(); + expect(text).not.toContain(STORE_TOKEN); + }); + + it("reads no secret at all", () => { + expect(text).not.toContain("secrets."); + }); + + it("always runs, and asks the one receiver", () => { + // A guard or a variable here would be a second switch beside the receiver's. + expect(job?.if, `${file} ${name} must not be guarded`).toBeUndefined(); + expect(text).not.toContain("vars."); + const post = (job?.steps ?? []).find((s) => s.env?.RECEIVER !== undefined); + expect(post?.env?.RECEIVER).toBe(RECEIVER_URL); + }); + + it("reads a switched-off receiver as not verified, not as a failure", () => { + const post = (job?.steps ?? []).find((s) => s.env?.RECEIVER !== undefined); + const arm = /\n\s*404\)([^;]*);;/.exec(post?.run ?? ""); + expect(arm, `${file} ${name} has no 404) arm`).not.toBeNull(); + expect(arm?.[1]).toContain("::notice::"); + expect(arm?.[1]).not.toContain("exit 1"); + }); + + it("sends the tag and the sha, and nothing else", () => { + // The receiver also accepts `suites`, `platforms` and `reason`. None is this + // repository's decision, and an unused field is one more thing two + // repositories have to keep agreeing about. + const post = (job?.steps ?? []).find((s) => + (s.run ?? "").includes("jq -cn"), + ); + expect(post?.run, `${file} ${name} builds no request body`).toBeDefined(); + const body = /jq -cn([^']*)'([^']*)'/.exec(post?.run ?? ""); + expect(body?.[2]).toBe("{tag:$tag, sha:$sha}"); + // `--arg`, so the two values are jq data rather than jq program text. + expect(post?.run).toContain('--arg tag "$TAG"'); + expect(post?.run).toContain('--arg sha "$SHA"'); + }); + + it("interpolates no expression into a run body", () => { + // Everything arrives through `env:`. A `${{ }}` inside `run:` is substituted by + // the runner before bash sees it, which is how an input becomes code. + for (const step of job?.steps ?? []) { + expect( + step.run ?? "", + `${file} ${name}: a run: body interpolates a \${{ }} expression`, + ).not.toContain("${{"); + } + }); +}); + +/** + * The one `needs:` edge worth asserting, and why it is not simply "no edge". + * + * `notify-verify` waits for the job that advances `cli/next`, because a + * verification of bytes the ring is not yet serving proves nothing. That job is + * also the one holding the store token, so the edge exists on purpose and cannot be + * removed. What must stay true is that it is the ONLY such edge: `needs:` passes a + * job's outputs, never its secrets, but each additional edge to a token-bearing job + * is another place a future output could carry something it should not. + */ +describe("release-cli.yml: notify-verify waits for the pointer, and nothing else privileged", () => { + const wf = lane("release-cli.yml"); + + /** The job whose steps move `cli/next`. Found by the step, not by its name. */ + const advancer = Object.entries(wf.jobs).find(([, job]) => + (job.steps ?? []).some((s) => s.name === "Advance cli/next"), + )?.[0]; + + it("finds the job that advances cli/next", () => { + expect(advancer).toBeDefined(); + }); + + it("needs no store-writing job but that one", () => { + const job = wf.jobs["notify-verify"] as Job | undefined; + const needs = job?.needs === undefined ? [] : [job.needs].flat(); + const privileged = needs.filter((n) => storeWriters(wf).includes(n)); + expect(privileged).toEqual([advancer as string]); + }); +}); + +/** + * What `notify-verify` hands on, and what it holds while doing it. + * + * `next-report` reads the receiver's answer from a job output, so the output is + * part of the interface: rename it on one side and the reader sees an empty + * status, which it reads as "refused", and every report says so. And the job + * holds exactly one grant. It checks nothing out and reads no tree, so + * `contents: read` would be a second capability with no step to use it. + */ +describe("release-cli.yml: notify-verify hands on the answer, and holds only the token", () => { + const wf = lane("release-cli.yml"); + const job = wf.jobs["notify-verify"] as + | (Job & { outputs?: Record }) + | undefined; + const post = (job?.steps ?? []).find((s) => s.env?.RECEIVER !== undefined) as + | (Step & { id?: string }) + | undefined; + + it("holds id-token: write and no other grant", () => { + expect(job?.permissions).toEqual({ "id-token": "write" }); + }); + + it("checks nothing out and installs nothing", () => { + for (const step of job?.steps ?? []) { + expect(step.uses ?? "").not.toContain("actions/checkout"); + expect(step.uses ?? "").not.toContain("setup-bun"); + expect(step.run ?? "").not.toMatch(/\bbun (install|run|x)\b/); + } + }); + + it("asks for a token with the audience the receiver checks", () => { + expect(post?.run).toContain("audience=wego-cli-verify"); + }); + + it("exposes the status output next-report reads, from the step that asks", () => { + expect(post?.id).toBeDefined(); + expect(job?.outputs?.status?.replace(/\s+/g, "")).toBe( + `\${{steps.${post?.id}.outputs.status}}`, + ); + // Written before the request, so a step that dies before an answer still + // leaves `error` to read, and again after it, with the code. + const run = post?.run ?? ""; + expect(run.indexOf("status=error")).toBeGreaterThanOrEqual(0); + expect(run.indexOf("status=error")).toBeLessThan(run.indexOf("curl")); + expect(run).toContain('echo "status=$CODE" >> "$GITHUB_OUTPUT"'); + }); + + it("fails loudly on any answer it does not name", () => { + const arm = /\n\s*\*\)([^;]*);;/.exec(post?.run ?? ""); + expect(arm?.[1]).toContain("::error::"); + expect(arm?.[1]).toContain("exit 1"); + }); +}); + +/** + * THE REPORT READERS: they read a check run, and can do nothing else. + * + * Two jobs run `scripts/next-report.ts`: `release-cli.yml`'s `next-report`, which + * waits for wego-ai's verdict after a release, and the banner at the top of + * `promote-cli.yml`. Both read another repository's text into a public summary, + * and both are advice rather than gates, which is two separate properties: + * + * - READ-ONLY. `checks: read` for the API, `contents: read` for the checkout, + * nothing else. No environment, no `id-token`, and no secret but the run's + * own `github.token`: a reader that could write, sign or reach the store is a + * privileged job whose input is text from outside this repository. + * - NEVER RED. `continue-on-error` on the job, and a step that ends in + * `exit 0`. A red reader would read as a failed release, or on the promote + * lane as a refused gate, over a report that is only ever advice. + */ +const READERS: [string, string][] = [ + ["release-cli.yml", "next-report"], + ["promote-cli.yml", "next-report"], +]; + +describe.each( + READERS, +)("%s %s: reads the report, and nothing else", (file, name) => { + const wf = lane(file); + const job = wf.jobs[name] as + | (Job & { "continue-on-error"?: unknown }) + | undefined; + const text = JSON.stringify(job ?? {}); + const step = (job?.steps ?? []).find((s) => + (s.run ?? "").includes("scripts/next-report.ts"), + ); + + it("exists, and runs the report script", () => { + expect(job, `${file} has no job '${name}'`).toBeDefined(); + expect(step).toBeDefined(); + }); + + it("holds checks: read, at most contents: read beside it, and nothing else", () => { + const grants = job?.permissions ?? {}; + expect(grants.checks).toBe("read"); + for (const [scope, level] of Object.entries(grants)) { + expect( + ["checks", "contents"], + `${file} ${name} grants ${scope}`, + ).toContain(scope); + expect(level, `${file} ${name} grants ${scope}: ${level}`).toBe("read"); + } + }); + + it("declares no environment and reads no secret but the run's own token", () => { + expect(job?.environment).toBeUndefined(); + expect(text).not.toContain(STORE_TOKEN); + expect(text).not.toContain("secrets."); + expect(text).not.toContain("vars."); + const tokens = [...text.matchAll(/\$\{\{\s*([^}]*?)\s*\}\}/g)] + .map((m) => m[1] ?? "") + .filter((expr) => /token/i.test(expr)); + expect(tokens).toEqual(["github.token"]); + }); + + it("can never go red", () => { + expect(job?.["continue-on-error"]).toBe(true); + expect(step?.run?.trimEnd().endsWith("exit 0")).toBe(true); + }); + + it("is needed by no other job", () => { + for (const [other, j] of Object.entries(wf.jobs)) { + const needs = j.needs === undefined ? [] : [j.needs].flat(); + expect(needs, `${file}: '${other}' needs '${name}'`).not.toContain(name); + } + }); + + it("interpolates no expression into a run body", () => { + for (const s of job?.steps ?? []) { + expect(s.run ?? "").not.toContain("${{"); + } + }); +}); + +describe("release-cli.yml: next-report runs whenever notify-verify ran", () => { + const job = lane("release-cli.yml").jobs["next-report"] as Job | undefined; + + it("waits for notify-verify, red included, and only when it ran", () => { + // Without `always()` a refused request (a red notify-verify) would skip the + // one job that says why there is no report. Without the result check it + // would also run on a release that never published, and report on nothing. + expect(job?.needs).toContain("notify-verify"); + const cond = (job?.if ?? "").replace(/\s+/g, " "); + expect(cond).toContain("always()"); + expect(cond).toContain("needs.notify-verify.result == 'failure'"); + expect(cond).toContain("needs.notify-verify.result == 'success'"); + }); + + it("hands the script the receiver's answer through env", () => { + const step = (job?.steps ?? []).find((s) => + (s.run ?? "").includes("scripts/next-report.ts"), + ); + expect(String(step?.env?.NOTIFY_STATUS).replace(/\s+/g, "")).toBe( + `\${{needs.notify-verify.outputs.status}}`, + ); + }); +}); + describe.each(PUBLISHING_LANES)("%s: the store capability", (file) => { const wf = lane(file); diff --git a/src/api.test.ts b/src/api.test.ts index bf0a401..914e9f6 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -1031,10 +1031,8 @@ describe("fetchFlightTrip", () => { it("puts `--view detail` on the wire and parses the detail variant", async () => { // The `?view=` half of the flag, at the layer that owns the query string. - // `commands.test.ts` owns the parse/validate half, and `flights-e2e` proves the - // default read still works through the real subprocess — the detail body itself - // is unreachable offline, since no capture records its v6-trip + amenities - // fan-out (`apps/api` `NOT_YET_REPLAYABLE_VARIANTS`). + // `parseFlightTripArgs` owns the parse/validate half, and + // `integration/flights.test.ts` drives `--view` through the compiled binary. let seen: URL | undefined; // The DETAIL shape, which is not the default trip: `legs[]` instead of // `outbound`/`return`, and a `provider` OBJECT instead of a flat @@ -1480,8 +1478,8 @@ describe("fetchHotelResults response tolerance (moved here in #1341)", () => { // The settle counter is `.int().nonnegative().optional().catch(undefined)`, so a // negative, fractional, non-numeric or null value degrades to undefined and the // caller falls back to item-presence. Asserted here because it is the RESPONSE - // SCHEMA's behaviour: `hotels.test.ts` drives the settle through injected deps, - // which never reach this parse. + // SCHEMA's behaviour: `integration/hotels.test.ts` drives the settle through + // the binary, against contract-valid answers that never carry a malformed count. for (const bad of [-1, 2.5, "not-a-number", null] as unknown[]) { const http = (() => Promise.resolve( @@ -1558,7 +1556,7 @@ describe("fetchFlightResults response tolerance (moved here in #1341)", () => { // `.int().nonnegative().optional().catch(undefined)`: a negative, fractional, // non-numeric or null count degrades to undefined so the settle falls back to // item-presence instead of converging on a bogus number or throwing. The - // consequence is asserted in `commands.test.ts`; the parse is asserted here. + // fallback itself is `settle`'s, in `search-engine.test.ts`; the parse is asserted here. for (const bad of [-1, 2.5, "not-a-number", null] as unknown[]) { const page = await read(body(bad)); expect(page.metadata?.snapshotFareCount).toBeUndefined(); @@ -1591,8 +1589,8 @@ describe("fetchHotelReviews builds the wire query", () => { } it("puts the hotel in the path and every flag under its published name", async () => { - // `hotels.test.ts` asserts the flag → parameter MAPPING against injected deps, - // which never reach this serialization. One request legitimately carries both + // `integration/hotels.test.ts` asserts what reaches the wire from argv; this + // pins the serialization at the layer that writes it. One request legitimately carries both // spellings: kebab for the net-new knob, camel for the mirrored one. const { seen, http } = urlFor(); await fetchHotelReviews( @@ -1651,9 +1649,8 @@ describe("fetchSearchLink builds the wire query", () => { } it("puts the whole search context on the wire under its published names", async () => { - // `commands.test.ts` asserts the argv → params MAPPING against injected deps, - // which never reach this serialization — so the KEY names are asserted here, at - // the layer that writes them. `applyFlightLinkQuery` is shared with + // `integration/flights.test.ts` asserts what reaches the wire from argv; the + // KEY names are asserted here too, at the layer that writes them. `applyFlightLinkQuery` is shared with // `booking-link`, so a rename would silently move both. const { seen, http } = urlFor(); await fetchSearchLink( diff --git a/src/commands.test.ts b/src/commands.test.ts index 18ce575..3dcc869 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -1,3486 +1,40 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { describe, expect, it } from "bun:test"; +import { fetchFlightResults, type HttpFetch } from "./api"; import { - ApiHttpError, - ApiUnreachableError, - type BookingLinkParams, - type CreateFlightSearchBody, - createFlightSearch, - type FeedbackBody, - type FlightResultsQuery, - fetchBookingLink, - fetchFareOptions, - fetchFlightResults, - fetchFlightTrip, - fetchHolidays, - fetchNearbyPlaces, - fetchPlaces, - fetchSchedules, - fetchSearchLink, - fetchTripExperience, - fetchVisaFree, - fetchWhoami, - type HttpFetch, - NotFoundError, - type PlacesQuery, - type SearchLinkParams, - sendFeedback, - UnauthorizedError, -} from "./api"; -import type { AuthFailureRecord } from "./auth-failure"; -import { - type FlightsDeps, - feedback, - flights, - info, - login, - logout, - parseFeedbackArgs, - parseFlightResultsArgs, - parseLoginArgs, - places, - resolveCliSite, - whoami, -} from "./commands"; -import type { CliConfig } from "./config"; -import { type RunDeps, run } from "./index"; -import { startLoopback } from "./loopback"; -import { exchangeCode, refreshTokens } from "./oauth"; -import type { UserSettings } from "./settings"; -import { clearCredentials, loadCredentials, saveCredentials } from "./storage"; -import { loadTestCliConfig } from "./test-config"; - -/** - * Behavioral tests for the three commands. They run the REAL collaborators — - * real loopback server, real PKCE, real token exchange, real on-disk credential - * storage — and stub only at true boundaries: the auth server and the API are - * local HTTP servers (network boundary), and the browser launch is captured - * (OS boundary). Assertions are on what a user observes: exit code, printed - * output, and the credentials actually written to disk. No first-party module - * is mocked, and nothing asserts on internal call order. - */ - -// --- local servers (network boundary) ------------------------------------ -type Stub = { url: string; stop: () => void }; -const running: Stub[] = []; - -function serve(handler: (req: Request) => Response | Promise): Stub { - const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler }); - const stub = { url: `http://127.0.0.1:${s.port}`, stop: () => s.stop(true) }; - running.push(stub); - return stub; -} - -/** A fake authorization server: only the `/token` endpoint the CLI calls. */ -function authServer( - token: (grant: string) => Response | Promise, -): Stub { - return serve(async (req) => { - const url = new URL(req.url); - if (url.pathname === "/token" && req.method === "POST") { - const body = new URLSearchParams(await req.text()); - return token(body.get("grant_type") ?? ""); - } - return new Response("not found", { status: 404 }); - }); -} - -/** A fake resource API: `GET /v1/user` accepts the given bearer token(s). */ -/** - * The api calls, injected (#1341). - * - * These describes used to drive hand-written `Bun.serve` stand-ins for `apps/api` - * routes — a fake that can only fail when it disagrees with itself, which is the - * defect #1328 exists to remove. The commands already take each api call as a dep, - * so the deps are stubbed instead: no socket, and the token the CLI sent is visible - * to an assertion rather than buried in a header the fake threw away. - * - * `accepted` keeps the fakes' most load-bearing behaviour: a token that is not - * accepted raises `UnauthorizedError`, which is what `api.ts` raises on a 401, so the - * reactive-refresh path still runs end to end against a real authorization server. - */ -const API = "https://api.wego.test"; - -/** - * Api deps that refuse to be called. - * - * The default for every `runDeps` that takes stubs. Leaving the REAL api functions wired - * as the default is a trap: a test that forgets to pass its stub reaches the network, - * DNS-fails against the placeholder host, and exits 7 — which reads as a bug in the - * command under test. Measured twice while writing these conversions. This makes the - * mistake name itself instead. - */ -function refusingApi(): Record never> { - const names = [ - "createFlightSearch", - "fetchFlightResults", - "fetchFlightTrip", - "fetchFareOptions", - "fetchBookingLink", - "fetchWhoami", - "fetchPlaces", - "sendFeedback", - ]; - return Object.fromEntries( - names.map((fn) => [ - fn, - () => { - throw new Error( - `${fn} was called with no stub: pass one as runDeps' third argument`, - ); - }, - ]), - ); -} - -/** Raise what `api.ts` raises on a 401, unless the token is accepted. */ -function guard(accepted: string[], token: string): void { - if (!accepted.includes(token)) throw new UnauthorizedError(); -} - -function whoamiApi(accepted: string[], identity: unknown) { - return async (_base: string, token: string) => { - guard(accepted, token); - return Promise.resolve(identity as Awaited>); - }; -} - -/** `GET /v1/places`, recording the token and the query the CLI passed. */ -function placesApi( - accepted: string[], - capture?: (call: { token: string; params: PlacesQuery }) => void, -) { - return async (_base: string, token: string, params: PlacesQuery) => { - capture?.({ token, params }); - guard(accepted, token); - return Promise.resolve({ - results: [{ id: 1, name: "Dubai", type: "city" }], - metadata: { - resultCount: 1, - totalCandidates: 1, - hasMore: false, - hasAmbiguity: false, - }, - } as Awaited>); - }; -} - -/** `POST /v1/feedback`, recording the body the CLI built. */ -function feedbackApi( - accepted: string[], - capture?: (body: Record) => void, -) { - return async (_base: string, token: string, body: FeedbackBody) => { - guard(accepted, token); - capture?.(body as unknown as Record); - return Promise.resolve({ status: "received" as const }); - }; -} - -// --- per-test scratch dir + io -------------------------------------------- -let dir: string; -let credPath: string; - -beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), "wego-cmd-")); - credPath = join(dir, "credentials.json"); -}); -afterEach(async () => { - while (running.length) running.pop()?.stop(); - await rm(dir, { recursive: true, force: true }); -}); - -const sink = () => { - const out: string[] = []; - const err: string[] = []; - return { - out, - err, - log: (m: string) => out.push(m), - error: (m: string) => err.push(m), - }; -}; - -function config(over: { as?: string; api?: string } = {}): CliConfig { - const as = over.as ?? "http://127.0.0.1:1"; - return loadTestCliConfig({ - WEGO_CLI_CLIENT_ID: "cli-abc", - WEGO_AUTH_AUTHORIZE_URL: `${as}/authorize`, - WEGO_AUTH_TOKEN_URL: `${as}/token`, - WEGO_API_URL: over.api ?? "http://127.0.0.1:1", - WEGO_CREDENTIALS_PATH: credPath, - }); -} - -const readStored = async () => - JSON.parse(await readFile(credPath, "utf8")) as Record; - -// A captured browser launch that completes the login: it parses the authorize -// URL the CLI would open and plays the AS's role — redirecting the browser to -// the loopback with a code + the matching state. -function browserThatRedirects(code = "auth-code-xyz") { - return (authorizeUrl: string) => { - const u = new URL(authorizeUrl); - const redirectUri = u.searchParams.get("redirect_uri"); - const state = u.searchParams.get("state"); - void fetch(`${redirectUri}?code=${code}&state=${state}`).catch(() => {}); - }; -} - -describe("parseLoginArgs", () => { - it("skips the browser only when asked, or when the shell is remote", () => { - expect(parseLoginArgs([], false)).toEqual({ skipBrowser: false }); - expect(parseLoginArgs([], true)).toEqual({ skipBrowser: true }); - expect(parseLoginArgs(["--no-browser"], false)).toEqual({ - skipBrowser: true, - }); - // --browser overrules the SSH detection (the X11-forwarding case). - expect(parseLoginArgs(["--browser"], true)).toEqual({ skipBrowser: false }); - }); - - it("returns a usage message for an unknown or contradictory flag", () => { - expect(parseLoginArgs(["--nope"], false)).toEqual({ - usage: expect.stringContaining("Unknown option: --nope"), - }); - expect(parseLoginArgs(["--browser", "--no-browser"], false)).toEqual({ - usage: expect.stringContaining("not both"), - }); - }); -}); - -describe("login", () => { - it("logs in over real loopback PKCE and writes the issued tokens to disk", async () => { - const as = authServer(() => - Response.json({ - access_token: "access-1", - refresh_token: "refresh-1", - expires_in: 3600, - }), - ); - const io = sink(); - - const code = await login(config({ as: as.url }), { - ...io, - startLoopback, // real loopback server - openBrowser: browserThatRedirects(), // captured (OS boundary) - exchangeCode, // real form POST to the fake AS - saveCredentials, // real write to the temp file - }); - - expect(code).toBe(0); - expect(await readStored()).toMatchObject({ - accessToken: "access-1", - refreshToken: "refresh-1", - }); - expect(io.err.join("")).toMatch(/Login successful/); - }); - - it("exits 2 (usage) with a message when the token exchange is rejected", async () => { - const as = authServer( - () => new Response("bad", { status: 400, statusText: "Bad Request" }), - ); - const io = sink(); - - const code = await login(config({ as: as.url }), { - ...io, - startLoopback, - openBrowser: browserThatRedirects(), - exchangeCode, - saveCredentials, - }); - - expect(code).toBe(2); // usage/config error (rejected token exchange) - expect(io.err.join("")).toMatch(/Login failed/); - }); - - it("classifies a network failure from the token exchange as exit 7 (timeout/network), not usage", async () => { - const io = sink(); - - const code = await login(config(), { - ...io, - startLoopback, // real loopback delivers the code - openBrowser: browserThatRedirects(), - // The token POST never reaches the AS: Bun's fetch rejects with a - // TypeError on a DNS/connect failure (a DOMException on the deadline). - // The login catch must route this through the taxonomy so it reports the - // network class, not masquerade as a usage error. - exchangeCode: () => Promise.reject(new TypeError("Unable to connect")), - saveCredentials, - }); - - expect(code).toBe(7); // EXIT.TIMEOUT – network/timeout, not usage(2) - expect(io.err.join("")).toMatch(/Login failed/); - }); - - it("reports a failure (exit 2 usage) when the loopback port is already in use", async () => { - const occupied = serve(() => new Response("busy")); // holds an ephemeral port - const port = new URL(occupied.url).port; - const io = sink(); - - const cfg = loadTestCliConfig({ - WEGO_CLI_CLIENT_ID: "cli-abc", - WEGO_CREDENTIALS_PATH: credPath, - WEGO_CLI_REDIRECT_PORT: port, // force startLoopback to bind a taken port - }); - const code = await login(cfg, { - ...io, - startLoopback, // real → throws EADDRINUSE binding the taken port - openBrowser: browserThatRedirects(), - exchangeCode, - saveCredentials, - }); - - expect(code).toBe(2); // usage/config error (occupied redirect port) - expect(io.err.join("")).toMatch(/Login failed/); - }); - - it("refuses a plaintext (non-localhost) token endpoint", async () => { - const io = sink(); - const cfg = loadTestCliConfig({ - WEGO_CLI_CLIENT_ID: "cli-abc", - WEGO_CREDENTIALS_PATH: credPath, - WEGO_AUTH_AUTHORIZE_URL: "http://auth.evil.com/authorize", - WEGO_AUTH_TOKEN_URL: "http://auth.evil.com/token", - }); - const code = await login(cfg, { - ...io, - startLoopback, - openBrowser: browserThatRedirects(), - exchangeCode, - saveCredentials, - }); - expect(code).toBe(2); // usage/config error (insecure AS endpoint) - expect(io.err.join("")).toMatch(/must be HTTPS/); - }); - - // The SSH case: the loopback binds 127.0.0.1 on the remote box, but the - // browser runs on the laptop, so the redirect never reaches this process. - // The user pastes the callback URL back instead. - it("completes from a pasted callback URL and opens no browser with --no-browser", async () => { - const as = authServer(() => - Response.json({ access_token: "access-ssh", expires_in: 3600 }), - ); - const io = sink(); - let opened = 0; - let cancelled = 0; - let pastedState: string | undefined; - - const code = await login( - config({ as: as.url }), - { - ...io, - startLoopback, - openBrowser: () => { - opened += 1; - }, - exchangeCode, - saveCredentials, - waitForPastedCallback: (state) => { - pastedState = state; - return { - armed: true, - promise: Promise.resolve("pasted-code"), - cancel: () => { - cancelled += 1; - }, - }; - }, - }, - ["--no-browser"], - ); - - expect(code).toBe(0); - expect(opened).toBe(0); // no browser on this machine - expect(cancelled).toBe(1); // the waiter is torn down - expect(await readStored()).toMatchObject({ accessToken: "access-ssh" }); - // The paste waiter is armed with the same CSRF state the authorize URL - // carries — a foreign redirect cannot finish someone else's login. - expect(pastedState).toMatch(/.+/); - expect(io.err.join("")).toMatch(/paste it below/); - }); - - it("skips the browser automatically inside an SSH session", async () => { - const as = authServer(() => Response.json({ access_token: "access-ssh2" })); - const io = sink(); - let opened = 0; - - const code = await login(config({ as: as.url }), { - ...io, - startLoopback, - openBrowser: () => { - opened += 1; - }, - exchangeCode, - saveCredentials, - isRemoteShell: () => true, - waitForPastedCallback: () => ({ - armed: true, - promise: Promise.resolve("pasted-code"), - cancel: () => {}, - }), - }); - - expect(code).toBe(0); - expect(opened).toBe(0); - expect(io.err.join("")).toMatch(/No browser on this machine/); - }); - - it("still finishes over the loopback when the paste waiter is armed but idle", async () => { - const as = authServer(() => Response.json({ access_token: "access-lb" })); - const io = sink(); - let cancelled = 0; - - const code = await login(config({ as: as.url }), { - ...io, - startLoopback, - openBrowser: browserThatRedirects(), - exchangeCode, - saveCredentials, - // Nobody pastes anything — the loopback must win the race unaided. - waitForPastedCallback: () => ({ - armed: true, - promise: new Promise(() => {}), - cancel: () => { - cancelled += 1; - }, - }), - }); - - expect(code).toBe(0); - expect(cancelled).toBe(1); - expect(await readStored()).toMatchObject({ accessToken: "access-lb" }); - }); - - it("promises no paste prompt to a non-TTY caller (an agent shelling out)", async () => { - const as = authServer(() => Response.json({ access_token: "access-tty" })); - const io = sink(); - // Nothing opens a browser here, so play the forwarded-port case: the user - // opens the printed URL elsewhere and the redirect reaches the loopback. - const redirect = browserThatRedirects(); - const error = (message: string) => { - io.error(message); - const url = message.match(/\bhttps?:\/\/\S*authorize\S*/)?.[0]; - if (url) redirect(url); - }; - - const code = await login( - config({ as: as.url }), - { - ...io, - error, - openBrowser: () => { - throw new Error("--no-browser must not open a browser"); - }, - startLoopback, - exchangeCode, - saveCredentials, - // What `waitForPastedCallback` returns without a TTY: nobody to ask. - waitForPastedCallback: () => ({ - armed: false, - promise: new Promise(() => {}), - cancel: () => {}, - }), - }, - ["--no-browser"], - ); - - expect(code).toBe(0); // the loopback still completed it - const out = io.err.join(""); - expect(out).not.toMatch(/paste it below/); - expect(out).toMatch(/ssh -L/); - }); - - it("opens the browser anyway with --browser inside an SSH session (X11)", async () => { - const as = authServer(() => Response.json({ access_token: "access-x11" })); - const io = sink(); - let opened = 0; - const redirect = browserThatRedirects(); - - const code = await login( - config({ as: as.url }), - { - ...io, - startLoopback, - openBrowser: (url) => { - opened += 1; - redirect(url); - }, - exchangeCode, - saveCredentials, - isRemoteShell: () => true, // detection says remote; the flag overrules it - }, - ["--browser"], - ); - - expect(code).toBe(0); - expect(opened).toBe(1); - expect(io.err.join("")).toMatch(/Opening your browser/); - }); - - it("exits 2 (usage) when both browser flags are given", async () => { - const io = sink(); - const code = await login( - config(), - { - ...io, - startLoopback, - openBrowser: browserThatRedirects(), - exchangeCode, - saveCredentials, - }, - ["--browser", "--no-browser"], - ); - - expect(code).toBe(2); - expect(io.err.join("")).toMatch(/not both/); - }); - - // The race's loser keeps running after the winner settles. `login` must not - // care what it eventually does — the same bug class the loopback deadline fix - // covers one layer down, asserted here at the command level. - it("ignores a race loser that settles late, whichever side lost", async () => { - const as = authServer(() => Response.json({ access_token: "access-race" })); - - // 1. The paste wins; the loopback's waiter rejects afterwards. - const io1 = sink(); - let lateReject: ((e: Error) => void) | undefined; - const code1 = await login( - config({ as: as.url }), - { - ...io1, - startLoopback: (path, port) => { - const real = startLoopback(path, port); - return { - ...real, - waitForCode: () => - new Promise((_, reject) => { - lateReject = reject; - }), - }; - }, - openBrowser: () => {}, - exchangeCode, - saveCredentials, - waitForPastedCallback: () => ({ - armed: true, - promise: Promise.resolve("pasted-wins"), - cancel: () => {}, - }), - }, - ["--no-browser"], - ); - expect(code1).toBe(0); - // The loser rejecting now must not surface as an unhandled rejection nor - // change the already-returned exit code. - lateReject?.(new Error("login timed out")); - await Bun.sleep(10); - expect(code1).toBe(0); - expect(await readStored()).toMatchObject({ accessToken: "access-race" }); - - // 2. The loopback wins; the paste reader rejects afterwards. - const io2 = sink(); - let latePasteReject: ((e: Error) => void) | undefined; - const code2 = await login(config({ as: as.url }), { - ...io2, - startLoopback, - openBrowser: browserThatRedirects("loopback-wins"), - exchangeCode, - saveCredentials, - waitForPastedCallback: () => ({ - armed: true, - promise: new Promise((_, reject) => { - latePasteReject = reject; - }), - cancel: () => {}, - }), - }); - expect(code2).toBe(0); - latePasteReject?.(new Error("paste failed after the fact")); - await Bun.sleep(10); - expect(io2.err.join("")).toMatch(/Login successful/); - }); - - it("exits 2 (usage) on an unknown login option", async () => { - const io = sink(); - const code = await login( - config(), - { - ...io, - startLoopback, - openBrowser: browserThatRedirects(), - exchangeCode, - saveCredentials, - }, - ["--nobrowser"], - ); - - expect(code).toBe(2); - expect(io.err.join("")).toMatch(/Unknown option: --nobrowser/); - }); - - it("fails fast with a B1-referencing message when no client_id is set", () => { - expect(() => - loadTestCliConfig({ - WEGO_CLI_CLIENT_ID: "", - WEGO_CREDENTIALS_PATH: credPath, - }), - ).toThrow(/WEGO_CLI_CLIENT_ID/); - }); -}); - -describe("whoami", () => { - const deps = ( - io: ReturnType, - api: ReturnType, - ) => ({ - ...io, - loadCredentials, - saveCredentials, - refreshTokens, - loadSettings: async () => ({}), - recordAuthFailure: async () => {}, - fetchWhoami: api, - }); - - it("prints the caller's identity for a valid session", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const api = whoamiApi(["tok-1"], { sub: "user-1", email: "a@wego.com" }); - const io = sink(); - - const code = await whoami(config({ api: API }), deps(io, api)); - - expect(code).toBe(0); - expect(io.out.join("")).toContain('"sub": "user-1"'); - }); - - it("works despite a malformed WEGO_CLI_REDIRECT_PORT (a login-only setting)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const api = whoamiApi(["tok-1"], { sub: "user-1" }); - const cfg = loadTestCliConfig({ - WEGO_CLI_CLIENT_ID: "cli-abc", - WEGO_CREDENTIALS_PATH: credPath, - WEGO_API_URL: API, - WEGO_CLI_REDIRECT_PORT: "abc", // malformed, but whoami never touches the loopback - }); - const io = sink(); - - const code = await whoami(cfg, deps(io, api)); - - expect(code).toBe(0); - expect(io.out.join("")).toContain('"sub": "user-1"'); - }); - - it("refuses a plaintext (non-localhost) WEGO_API_URL", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const cfg = loadTestCliConfig({ - WEGO_CLI_CLIENT_ID: "cli-abc", - WEGO_CREDENTIALS_PATH: credPath, - WEGO_API_URL: "http://api.wego.com", // plaintext to a remote host - }); - const io = sink(); - const code = await whoami(cfg, deps(io, whoamiApi([], {}))); - expect(code).toBe(2); // usage/config error (insecure API URL) - expect(io.err.join("")).toMatch(/WEGO_API_URL must be HTTPS/); - }); - - it("refuses to refresh an expired token over a plaintext token endpoint", async () => { - await saveCredentials(credPath, { - accessToken: "old", - refreshToken: "rt", - expiresAt: Date.now() - 10_000, // expired → triggers the refresh path - }); - const api = whoamiApi(["new"], { sub: "user-1" }); - const cfg = loadTestCliConfig({ - WEGO_CLI_CLIENT_ID: "cli-abc", - WEGO_CREDENTIALS_PATH: credPath, - WEGO_API_URL: API, // local API is fine - WEGO_AUTH_TOKEN_URL: "http://auth.wego.com/token", // plaintext → refresh refused - }); - const io = sink(); - const code = await whoami(cfg, deps(io, api)); - expect(code).toBe(3); // auth failure (couldn't refresh the expired token) - expect(io.err.join("")).toMatch(/WEGO_AUTH_TOKEN_URL must be HTTPS/); - }); - - it("tells an unauthenticated user to log in", async () => { - const io = sink(); - // No credentials file, so the api must never be reached. - const code = await whoami(config(), deps(io, whoamiApi([], {}))); - expect(code).toBe(3); // auth: not logged in - expect(io.err.join("")).toMatch(/wego login/); - }); - - it("transparently refreshes an expired token, then prints identity and persists the rotation", async () => { - await saveCredentials(credPath, { - accessToken: "old", - refreshToken: "rt", - expiresAt: Date.now() - 10_000, // already expired - }); - const as = authServer(() => - Response.json({ access_token: "new", refresh_token: "rt2" }), - ); - const api = whoamiApi(["new"], { sub: "user-1" }); - const io = sink(); - - const code = await whoami(config({ as: as.url, api: API }), deps(io, api)); - - expect(code).toBe(0); - expect(io.out.join("")).toContain('"sub": "user-1"'); - expect(await readStored()).toMatchObject({ - accessToken: "new", - refreshToken: "rt2", - }); - }); - - it("persists the id_token a refresh returns", async () => { - await saveCredentials(credPath, { - accessToken: "old", - refreshToken: "rt", - expiresAt: Date.now() - 10_000, - idToken: "old-id", - }); - const as = authServer(() => - Response.json({ access_token: "new", id_token: "new-id" }), - ); - const io = sink(); - - const code = await whoami( - config({ as: as.url, api: API }), - deps(io, whoamiApi(["new"], { sub: "user-1" })), - ); - - expect(code).toBe(0); - expect(await readStored()).toMatchObject({ idToken: "new-id" }); - }); - - it("keeps the stored id_token when a refresh returns none, since the hashes outlive it", async () => { - const kept = idTokenExpiring(Date.now() - 60_000); // expired, still accepted - await saveCredentials(credPath, { - accessToken: "old", - refreshToken: "rt", - expiresAt: Date.now() - 10_000, - idToken: kept, - }); - const as = authServer(() => Response.json({ access_token: "new" })); - const io = sink(); - - const code = await whoami( - config({ as: as.url, api: API }), - deps(io, whoamiApi(["new"], { sub: "user-1" })), - ); - - expect(code).toBe(0); - expect(await readStored()).toMatchObject({ idToken: kept }); - }); - - it("drops a stored id_token the API would no longer accept", async () => { - await saveCredentials(credPath, { - accessToken: "old", - refreshToken: "rt", - expiresAt: Date.now() - 10_000, - idToken: idTokenExpiring(Date.now() - 25 * 60 * 60 * 1000), - }); - const as = authServer(() => Response.json({ access_token: "new" })); - const io = sink(); - - const code = await whoami( - config({ as: as.url, api: API }), - deps(io, whoamiApi(["new"], { sub: "user-1" })), - ); - - expect(code).toBe(0); - expect((await readStored())?.idToken).toBeUndefined(); - }); - - it("recovers from a 401 by refreshing once and retrying", async () => { - await saveCredentials(credPath, { - accessToken: "stale", - refreshToken: "rt", - }); // no expiresAt → not refreshed proactively; the 401 drives it - const as = authServer(() => Response.json({ access_token: "fresh" })); - const api = whoamiApi(["fresh"], { sub: "user-1" }); // "stale" → 401 - const io = sink(); - - const code = await whoami(config({ as: as.url, api: API }), deps(io, api)); - - expect(code).toBe(0); - expect(io.out.join("")).toContain('"sub": "user-1"'); - // The AS omitted refresh_token on the reactive refresh, so the on-disk - // refresh token must be PRESERVED (the `?? refreshToken` branch) while the - // access token rotates — dropping it would silently lose the credential. - // Assert the persisted file, not just the printed identity. - expect(await readStored()).toMatchObject({ - accessToken: "fresh", - refreshToken: "rt", - }); - }); - - it("exits 3 (auth) and points to login when the refresh token is rejected", async () => { - await saveCredentials(credPath, { - accessToken: "old", - refreshToken: "rt", - expiresAt: Date.now() - 10_000, - }); - const as = authServer( - () => new Response("bad", { status: 400, statusText: "Bad Request" }), - ); - const api = whoamiApi([], {}); - const io = sink(); - - const code = await whoami(config({ as: as.url, api: API }), deps(io, api)); - - expect(code).toBe(3); // auth: refresh token rejected → re-login - expect(io.err.join("")).toMatch(/wego login/); - }); - - it("surfaces the auth server's OAuth2 error and records the failure locally (issue #1367)", async () => { - await saveCredentials(credPath, { - accessToken: "old", - refreshToken: "rt", - expiresAt: Date.now() - 10_000, // expired → proactive refresh path - }); - const as = authServer(() => - Response.json( - { error: "invalid_grant", error_description: "Token is expired" }, - { status: 400, statusText: "Bad Request" }, - ), - ); - const io = sink(); - let recorded: AuthFailureRecord | undefined; - const code = await whoami(config({ as: as.url, api: API }), { - ...deps(io, whoamiApi([], {})), - recordAuthFailure: async (r) => { - recorded = r; - }, - }); - - expect(code).toBe(3); // still an auth failure, fail-closed - // The OAuth2 error now rides the stderr line, not just a bare status. - const msg = io.err.join(""); - expect(msg).toMatch(/invalid_grant/); - expect(msg).toMatch(/Token is expired/); - expect(msg).toMatch(/wego login/); - // …and a trace of WHY is left on disk (the whole point of #1360's fix). - expect(recorded).toMatchObject({ - grantType: "refresh_token", - status: 400, - error: "invalid_grant", - errorDescription: "Token is expired", - }); - expect(recorded?.at).toMatch(/^\d{4}-\d\d-\d\dT/); // ISO-8601 instant - // The refresh token must NEVER be captured in the diagnostics record. - expect(JSON.stringify(recorded)).not.toContain("rt"); - }); - - it("still exits 3 and prints when the failure record write itself fails", async () => { - await saveCredentials(credPath, { - accessToken: "old", - refreshToken: "rt", - expiresAt: Date.now() - 10_000, - }); - const as = authServer( - () => new Response("nope", { status: 400, statusText: "Bad Request" }), - ); - const io = sink(); - const code = await whoami(config({ as: as.url, api: API }), { - ...deps(io, whoamiApi([], {})), - // A diagnostics write that throws must not mask the auth failure. - recordAuthFailure: async () => { - throw new Error("disk full"); - }, - }); - expect(code).toBe(3); - expect(io.err.join("")).toMatch(/wego login/); - }); - - it("records the failure on the reactive-401 path too, not only proactive (issue #1367)", async () => { - // No expiresAt → the token is not refreshed proactively; the first API call - // 401s WITH a refresh token in hand, driving reactiveRefreshRetry, and THEN - // the refresh itself fails. This is the second call site reportRefreshFailure - // fires from — untested until now. - await saveCredentials(credPath, { - accessToken: "stale", - refreshToken: "rt", - }); - const as = authServer(() => - Response.json( - { error: "invalid_grant" }, - { status: 400, statusText: "Bad Request" }, - ), - ); - const api = whoamiApi([], {}); // "stale" → 401 → reactive refresh → fails - const io = sink(); - let recorded: AuthFailureRecord | undefined; - const code = await whoami(config({ as: as.url, api: API }), { - ...deps(io, api), - recordAuthFailure: async (r) => { - recorded = r; - }, - }); - expect(code).toBe(3); - expect(recorded).toMatchObject({ - grantType: "refresh_token", - status: 400, - error: "invalid_grant", - }); - }); - - it("redacts the sent refresh token from the record if the AS echoes it back (issue #1367)", async () => { - // Defense in depth: if the auth server ever reflects the request body into a - // non-OAuth2 error page, the 365-day refresh token must not land on disk. - const rt = `1${"a".repeat(130)}`; // realistic opaque token length - await saveCredentials(credPath, { - accessToken: "old", - refreshToken: rt, - expiresAt: Date.now() - 10_000, - }); - const as = authServer( - () => - new Response(`upstream rejected refresh_token=${rt}`, { - status: 400, - statusText: "Bad Request", - }), - ); - const io = sink(); - let recorded: AuthFailureRecord | undefined; - const code = await whoami(config({ as: as.url, api: API }), { - ...deps(io, whoamiApi([], {})), - recordAuthFailure: async (r) => { - recorded = r; - }, - }); - expect(code).toBe(3); - expect(JSON.stringify(recorded)).not.toContain(rt); - expect(recorded?.bodySnippet).toContain("[REDACTED]"); - }); - - it("exits 3 (auth) on a persistent 401 with no refresh token", async () => { - await saveCredentials(credPath, { accessToken: "stale" }); - const api = whoamiApi([], {}); // always 401 - const io = sink(); - - const code = await whoami(config({ api: API }), deps(io, api)); - - expect(code).toBe(3); // auth: persistent 401, no refresh token - expect(io.err.join("")).toBeTruthy(); - }); - - it("explains an env mismatch when a 401 survives a successful refresh (not a bare 'run login')", async () => { - await saveCredentials(credPath, { - accessToken: "stale", - refreshToken: "rt", - }); - // The refresh succeeds (AS mints a fresh token)… - const as = authServer(() => Response.json({ access_token: "fresh" })); - // …but the API rejects EVERY token (e.g. token minted for a different - // environment than WEGO_API_URL) — so the retried call still 401s. - const api = whoamiApi([], {}); - const io = sink(); - - const code = await whoami(config({ as: as.url, api: API }), deps(io, api)); - - expect(code).toBe(3); // auth: a persistent 401 is the AUTH class - const msg = io.err.join(""); - expect(msg).toMatch(/rejected your credentials \(401\)/); - expect(msg).toMatch(/WEGO_API_URL/); - expect(msg).toMatch(/wego login/); // still offers re-login as the fallback - }); - - it("gives a 'bun dev' hint when a local api target is unreachable", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const io = sink(); - - // Valid credentials, and the api call fails the way `api.ts` reports an - // unreachable host: a typed `ApiUnreachableError`, which is what the command - // layer classifies. Reaching a dead port to produce it would test Bun's socket - // timeouts, not the CLI. - const unreachable = (() => { - throw new ApiUnreachableError("http://127.0.0.1:1", new Error("refused")); - }) as ReturnType; - const code = await whoami( - config({ api: "http://127.0.0.1:1" }), - deps(io, unreachable), - ); - - expect(code).toBe(7); // timeout/network: unreachable host is the network class - const msg = io.err.join(""); - expect(msg).toMatch(/Cannot reach the Wego API at http:\/\/127\.0\.0\.1:1/); - expect(msg).toMatch(/bun dev/); - }); -}); - -// The `places` command is exercised through the REAL CLI entry point `run(argv, -// deps)`, stubbing only external seams — the network (local auth + API servers) -// and credential storage (a temp file via config). Every assertion is on what a -// user observes: exit code, printed output, the request the API received, and -// the credentials on disk — never on the arg parser or a command's dependency -// shape. That keeps these tests valid across an internals migration (e.g. moving -// the hand-rolled parser to commander.js): only `runDeps` below knows the wiring. -describe("places (through run – the argv entry point)", () => { - const runDeps = ( - io: ReturnType, - cfg: CliConfig, - api: ReturnType = placesApi([]), - ): RunDeps => { - const cmdIo = { log: io.log, error: io.error }; - return { - loadConfig: () => cfg, - io: cmdIo, - login: () => { - throw new Error("login is not exercised by the places tests"); - }, - info: () => { - throw new Error("info is not exercised by the places tests"); - }, - whoami: (c) => - whoami(c, { - ...cmdIo, - loadCredentials, - saveCredentials, - refreshTokens, - loadSettings: async () => ({}), - recordAuthFailure: async () => {}, - fetchWhoami, - }), - places: (c, args) => - places(c, args, { - ...cmdIo, - loadCredentials, - saveCredentials, - refreshTokens, - loadSettings: async () => ({}), - recordAuthFailure: async () => {}, - fetchPlaces: api, - }), - flights: () => { - throw new Error("flights is not exercised by the places tests"); - }, - hotels: () => { - throw new Error("hotels is not exercised by the places tests"); - }, - feedback: () => { - throw new Error("feedback is not exercised by the places tests"); - }, - skill: () => { - throw new Error("skill is not exercised by the places tests"); - }, - update: () => { - throw new Error("update is not exercised by the places tests"); - }, - uninstall: () => { - throw new Error("uninstall is not exercised by the places tests"); - }, - config: () => { - throw new Error("config is not exercised by the places tests"); - }, - telemetry: () => { - throw new Error("telemetry is not exercised by the places tests"); - }, - sendTelemetry: () => { - throw new Error("sendTelemetry is not exercised by the places tests"); - }, - logout: (c) => - logout(c, { ...cmdIo, clearCredentials, clearSession: async () => {} }), - }; - }; - - // `run` reads argv[2] as the command and argv.slice(3) as its args. - const wego = (...args: string[]) => ["bun", "wego", ...args]; - - // CLI-3: `wego places --help` used to error with "Unknown option: --help" - // (exit 2). It now prints the scoped places usage on stdout with exit 0, like - // the flights/hotels leaves — no network call, no credentials read. - for (const help of ["--help", "-h", "help"]) { - it(`places ${help}: prints usage to stdout, exit 0, empty stderr`, async () => { - const io = sink(); - const code = await run( - wego("places", help), - runDeps(io, config({ api: "http://127.0.0.1:1" })), - ); - expect(code).toBe(0); - expect(io.out.join("\n")).toContain('Usage: wego places ""'); - expect(io.err.length).toBe(0); - }); - } - - it("prints places JSON and sends the bearer token + query params", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: { token: string; params: PlacesQuery } | undefined; - const api = placesApi(["tok-1"], (call) => { - seen = call; - }); - const io = sink(); - - const code = await run( - wego("places", "dubai", "--locale", "en", "--page-size", "5"), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(0); - expect(io.out.join("")).toContain('"resultCount": 1'); - expect(seen?.token).toBe("tok-1"); - expect(seen?.params.query).toBe("dubai"); - expect(seen?.params.locale).toBe("en"); - expect(seen?.params.pageSize).toBe(5); - }); - - it("forwards flags in --flag value and --flag=value forms, including comma-split + repeated --types", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: { token: string; params: PlacesQuery } | undefined; - const api = placesApi(["tok-1"], (call) => { - seen = call; - }); - const io = sink(); - - const code = await run( - wego( - "places", - "paris", - "--types", - "city,airport", - "--types=hotel", - "--locale=en", - "--page", - "2", - "--page-size=5", - ), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(0); - // Comma-split and repeated --types both accumulate; the API client emits - // repeated `types` query params. - expect(seen?.params.types).toEqual(["city", "airport", "hotel"]); - expect(seen?.params.query).toBe("paris"); - expect(seen?.params.locale).toBe("en"); - expect(seen?.params.page).toBe(2); - expect(seen?.params.pageSize).toBe(5); - }); - - it("accepts values at the cap boundary (page=100, page-size=50)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: { token: string; params: PlacesQuery } | undefined; - const api = placesApi(["tok-1"], (call) => { - seen = call; - }); - const io = sink(); - - const code = await run( - wego("places", "dubai", "--page", "100", "--page-size", "50"), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(0); - expect(seen?.params.page).toBe(100); - expect(seen?.params.pageSize).toBe(50); - }); - - it("rejects malformed / out-of-range / missing-value flags with exit 2 (usage) and a helpful message, before any network", async () => { - // Bad input is rejected before credentials or the network are touched - // (config points nowhere reachable). Asserted at the observable layer — - // exit code + stderr — not on a parser's return value. - const cases: Array<[string[], RegExp]> = [ - [["places"], /Usage: wego places/], - [["places", "dubai", "--page", "x"], /--page must be a positive integer/], - [ - ["places", "dubai", "--page", "0x10"], - /--page must be a positive integer/, - ], - [ - ["places", "dubai", "--page", "1e3"], - /--page must be a positive integer/, - ], - [["places", "dubai", "--page", "0"], /--page must be a positive integer/], - [ - ["places", "dubai", "--page", "1.5"], - /--page must be a positive integer/, - ], - [ - ["places", "dubai", "--page", "-1"], - /--page must be a positive integer/, - ], - [ - ["places", "dubai", "--page", " 5"], - /--page must be a positive integer/, - ], - [ - ["places", "dubai", "--page", "101"], - /--page must be between 1 and 100/, - ], - [ - ["places", "dubai", "--page-size", "100"], - /--page-size must be between 1 and 50/, - ], - [["places", "dubai", "--nope"], /Unknown option/], - [["places", "dubai", "--locale"], /--locale requires a value/], - [ - ["places", "dubai", "--locale", "--page", "2"], - /--locale requires a value/, - ], - [["places", "dubai", "--locale="], /--locale requires a value/], - [["places", "dubai", "--types="], /--types requires a value/], - ]; - for (const [args, msg] of cases) { - const io = sink(); - const code = await run(wego(...args), runDeps(io, config())); - expect(code).toBe(2); // usage error - expect(io.err.join("")).toMatch(msg); - } - }); - - it("tells an unauthenticated user to log in", async () => { - const io = sink(); - const code = await run(wego("places", "dubai"), runDeps(io, config())); // no creds - expect(code).toBe(3); // auth: not logged in - expect(io.err.join("")).toMatch(/wego login/); - }); - - it("recovers from a 401 by refreshing once and retrying", async () => { - await saveCredentials(credPath, { - accessToken: "stale", - refreshToken: "rt", - }); - const as = authServer(() => Response.json({ access_token: "fresh" })); - const api = placesApi(["fresh"]); // "stale" → 401, drives the refresh - const io = sink(); - - const code = await run( - wego("places", "dubai"), - runDeps(io, config({ as: as.url, api: API }), api), - ); - - expect(code).toBe(0); - expect(io.out.join("")).toContain('"resultCount": 1'); - }); - - it("proactively refreshes an expired token before calling, then persists the rotation", async () => { - // An already-expired access token must be refreshed BEFORE the request (not - // via a reactive 401): the API only accepts the fresh token, and the rotated - // pair is written back to disk. Confirms `places` wires the same - // withAccessToken flow whoami does. - await saveCredentials(credPath, { - accessToken: "old", - refreshToken: "rt", - expiresAt: Date.now() - 10_000, // already expired - }); - const as = authServer(() => - Response.json({ access_token: "new", refresh_token: "rt2" }), - ); - const api = placesApi(["new"]); // only the fresh token is accepted - const io = sink(); - - const code = await run( - wego("places", "dubai"), - runDeps(io, config({ as: as.url, api: API }), api), - ); - - expect(code).toBe(0); - expect(io.out.join("")).toContain('"resultCount": 1'); - expect(await readStored()).toMatchObject({ - accessToken: "new", - refreshToken: "rt2", - }); - }); -}); - -describe("places – stored preferences (issue #1386)", () => { - const placesDeps = ( - io: ReturnType, - settings: UserSettings, - api: ReturnType, - ) => ({ - log: io.log, - error: io.error, - loadCredentials, - saveCredentials, - refreshTokens, - loadSettings: async () => settings, - recordAuthFailure: async () => {}, - fetchPlaces: api, - }); - - it("inherits the stored locale, and NEVER the stored market", async () => { - // Carve-out: `apps/api` pins the upstream `site_code` to the wildcard on - // purpose, so place resolution stays market-neutral. roxana (the web client) - // does the opposite for its market-scoped UI — threading a market here would - // narrow every lookup, so the CLI must not send one. The params the call - // receives say it exactly, and no price is returned so no currency is sent. - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: PlacesQuery | undefined; - const api = placesApi(["tok-1"], ({ params }) => { - seen = params; - }); - const code = await places( - config({ api: API }), - ["dubai"], - placesDeps(sink(), { locale: "ar", site: "SA", currency: "SAR" }, api), - ); - expect(code).toBe(0); - expect(seen).toEqual({ query: "dubai", locale: "ar" }); - }); - - it("an explicit --locale still wins", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: PlacesQuery | undefined; - const api = placesApi(["tok-1"], ({ params }) => { - seen = params; - }); - const code = await places( - config({ api: API }), - ["dubai", "--locale", "en"], - placesDeps(sink(), { locale: "ar" }, api), - ); - expect(code).toBe(0); - expect(seen?.locale).toBe("en"); - }); -}); - -describe("logout", () => { - it("removes the stored credentials from disk", async () => { - await saveCredentials(credPath, { accessToken: "at" }); - const io = sink(); - - const code = await logout(config(), { - ...io, - clearCredentials, - clearSession: async () => {}, - }); - - expect(code).toBe(0); - expect(await loadCredentials(credPath)).toBeNull(); - }); - - it("ends the analytics session too, so the next user starts a new one", async () => { - await saveCredentials(credPath, { accessToken: "at" }); - let cleared = false; - - await logout(config(), { - ...sink(), - clearCredentials, - clearSession: async () => { - cleared = true; - }, - }); - - expect(cleared).toBe(true); - }); - - it("still succeeds, loudly, when the session file cannot be cleared", async () => { - await saveCredentials(credPath, { accessToken: "at" }); - const io = sink(); - - const code = await logout(config(), { - ...io, - clearCredentials, - clearSession: async () => { - throw new Error("EPERM"); - }, - }); - - expect(code).toBe(0); - expect(await loadCredentials(credPath)).toBeNull(); - expect(io.err.join("\n")).toContain( - "could not clear the analytics session", - ); - expect(io.err.join("\n")).toContain("EPERM"); - }); -}); - -// --- flights (through run — the argv entry point) --------------------------- -const SEARCH_ID = "s1msr"; -const TRIP_ID = "s1msr:TR610~10"; - -/** - * What every priced read's `metadata` carries since contract 0.6.0 (#1522): the - * currency and locale the read asked for, each beside the API's own - * request-scoped source. - * - * `explicit` here means only "the request carried the param", which is true of a - * currency the CLI took from `settings.json` — the reason the CLI publishes its - * own top-level label (#1529). Both `*Source` copies are what the CLI strips at - * print time (#1400 for `localeSource`, #1534 for the rest): CLI output - * publishes exactly one `*Source` per knob, at top level, in the CLI's own - * vocabulary. The `currencyCode` / `locale` echoes themselves are kept. - */ -const API_PRICED_ECHO = { - currencyCode: "USD", - currencyCodeSource: "explicit", - locale: "en", - localeSource: "explicit", -} as const; - -function tripBody() { - return { - tripId: TRIP_ID, - stops: 0, - durationMinutes: 205, - metadata: { ...API_PRICED_ECHO }, - outbound: { from: "SIN", to: "BKK", airlines: ["SQ"] }, - fares: [ - { - kind: "partner", - providerCode: "expedia.com", - price: { total: 100, currency: "USD" }, - handoffUrl: "https://expedia.com/b?wg_source=wego_api", - }, - ], - }; -} - -/** One results-list card: a price summary, NO `fares[]`. */ -function cardBody() { - return { - tripId: TRIP_ID, - badges: ["cheapest"], - // Trip-level stops/duration, stated by the API since #1308. - stops: 0, - durationMinutes: 205, - price: { - total: 100, - currency: "USD", - scope: "party", - websiteCount: 11, - hasWegoFare: true, - }, - legs: [ - { - from: "SIN", - to: "BKK", - departsAt: "2026-03-01T08:00:00", - arrivesAt: "2026-03-01T09:25:00", - arrivalDayOffset: 0, - overnight: false, - durationMinutes: 205, - stops: 0, - via: [], - airlines: [ - { code: "SQ", name: "Singapore Airlines", logoUrl: "https://l/SQ" }, - ], - aircraft: ["A330"], - }, - ], - }; -} - -/** A results page: lean list cards with NO `fares[]`, the only projection the API - * serves there since #1308. `tripBody()` is what the TRIP read answers with. */ -function resultsBody(empty: boolean) { - return { - searchId: SEARCH_ID, - currencyCode: "USD", - metadata: { - page: 1, - pageSize: 10, - resultCount: empty ? 0 : 1, - totalCandidates: empty ? 0 : 1, - hasMore: false, - snapshotFareCount: empty ? 0 : 1, - ...API_PRICED_ECHO, - }, - results: empty ? [] : [cardBody()], - }; -} - -/** A results body with an explicit `snapshotFareCount` (and a matching result - * count) — the settle signal `flights results --wait` polls on (issue #1112). */ -function resultsBodyWithCount(count: number) { - return { - searchId: SEARCH_ID, - currencyCode: "USD", - metadata: { - page: 1, - pageSize: 10, - resultCount: count > 0 ? 1 : 0, - totalCandidates: count > 0 ? 1 : 0, - hasMore: false, - snapshotFareCount: count, - }, - results: count > 0 ? [cardBody()] : [], - }; -} - -/** A flights API that scripts a sequence of `snapshotFareCount` values over - * successive `…/results` reads (the last value repeats once the list is - * exhausted), and counts how many reads happened. Models an upstream snapshot - * that grows then holds steady (converges) or grows forever (never settles). */ -/** A results dep that walks a fixed sequence of snapshot counts, one per read. */ -function flightsResultsSequence(token: string, counts: number[]) { - let reads = 0; - return { - reads: () => reads, - fetchFlightResults: async (_b: string, tok: string, searchId: string) => { - guard([token], tok); - if (searchId !== SEARCH_ID) { - throw new NotFoundError("GET /v1/flights/searches/:searchId/results"); - } - const count = counts[Math.min(reads, counts.length - 1)] as number; - reads++; - return Promise.resolve( - resultsBodyWithCount(count) as Awaited< - ReturnType - >, - ); - }, - }; -} - -/** - * The flights api calls, injected (#1341). - * - * `emptyReads` = how many results reads answer an empty snapshot before it fills - * (the post-create settle). `onResults` sees the query the CLI built, and an - * unaccepted token or an unknown id raises the typed error `api.ts` would. - */ -function flightsApi( - token: string, - opts: { - emptyReads?: number; - onResults?: (query: FlightResultsQuery) => void; - /** Sees the create body, so a test can pin that the create and its - * settle-read went out in the SAME resolved currency (issue #1400). */ - onCreate?: (body: CreateFlightSearchBody) => void; - createExtra?: Record; - /** Sees the `view` the CLI forwarded to the trip read (`undefined` when no - * `--view` was given), so the flag is asserted at the seam it crosses. */ - onTripView?: (view: string | undefined) => void; - } = {}, -) { - let reads = 0; - return { - createFlightSearch: async ( - _base: string, - tok: string, - body: CreateFlightSearchBody, - ) => { - guard([token], tok); - opts.onCreate?.(body); - return Promise.resolve({ - searchId: SEARCH_ID, - ...opts.createExtra, - } as Awaited>); - }, - fetchFlightResults: async ( - _base: string, - tok: string, - searchId: string, - query: FlightResultsQuery = {}, - ) => { - guard([token], tok); - if (searchId !== SEARCH_ID) { - throw new NotFoundError("GET /v1/flights/searches/:searchId/results"); - } - opts.onResults?.(query); - // One projection since #1308, so there is no view to branch on: every read - // answers with the card page `resultsBody` builds. - const empty = reads < (opts.emptyReads ?? 0); - reads++; - return Promise.resolve( - resultsBody(empty) as Awaited>, - ); - }, - fetchFlightTrip: async ( - _base: string, - tok: string, - tripId: string, - searchId: string, - _currency?: string, - _locale?: string, - view?: string, - ) => { - guard([token], tok); - if (tripId !== TRIP_ID || !searchId) { - throw new NotFoundError("GET /v1/flights/trips/:tripId"); - } - opts.onTripView?.(view); - return Promise.resolve( - tripBody() as Awaited>, - ); - }, - }; -} - -/** - * The two fare calls, injected. `optionsStatus` fails `options` the way an expired - * fare does, and `capture` sees the fareId plus the query the CLI built. - */ -function faresApi( - accepted: string[], - opts: { - optionsStatus?: number; - experienceStatus?: number; - capture?: (call: { - fareId: string; - query: Record; - }) => void; - } = {}, -) { - return { - /** - * `GET …/trips/:tripId/experience` (#1326). One nonstop leg whose - * `shortStopover` the API already dropped, plus one witness present - the two - * omission rules this command must not re-invent client-side. - */ - fetchTripExperience: async ( - _base: string, - token: string, - tripId: string, - query: Record = {}, - ) => { - opts.capture?.({ fareId: tripId, query }); - guard(accepted, token); - if (opts.experienceStatus && opts.experienceStatus !== 200) { - throw opts.experienceStatus === 404 - ? new NotFoundError("GET /v1/flights/trips/:tripId/experience") - : new ApiHttpError( - opts.experienceStatus, - "GET /v1/flights/trips/:tripId/experience", - ); - } - return Promise.resolve({ - tripId, - legs: [ - { - id: "SIN-BKK:TR638~3:0", - departureAirportCode: "SIN", - arrivalAirportCode: "BKK", - stopsCount: 0, - signals: { - overnight: false, - longStopover: false, - earlyDeparture: false, - lateArrival: true, - oldAircraft: true, - }, - }, - ], - metadata: { legCount: 1 }, - } as Awaited>); - }, - fetchFareOptions: async ( - _base: string, - token: string, - fareId: string, - query: { currency?: string; locale?: string } = {}, - ) => { - opts.capture?.({ fareId, query: query as Record }); - guard(accepted, token); - if (opts.optionsStatus && opts.optionsStatus !== 200) { - throw opts.optionsStatus === 404 - ? new NotFoundError("GET /v1/flights/fares/:fareId/options") - : new ApiHttpError( - opts.optionsStatus, - "GET /v1/flights/fares/:fareId/options", - ); - } - return Promise.resolve({ - fareId: "f_88_1", - currencyCode: "USD", - metadata: { ...API_PRICED_ECHO }, - options: [ - { - fareOptionId: "SQ_ECO_LITE", - name: "Economy Lite", - price: { total: 512.3, totalUsd: 512.3, currency: "USD" }, - refundable: false, - exchangeable: false, - baggage: { cabin: "7kg included" }, - penalties: [], - }, - ], - } as Awaited>); - }, - fetchBookingLink: async ( - _base: string, - token: string, - fareId: string, - query: BookingLinkParams, - ) => { - opts.capture?.({ - fareId, - query: query as unknown as Record, - }); - guard(accepted, token); - return Promise.resolve({ - bookingUrl: `https://www.wego.com/flights/searches/x/economy/1a:0c:0i/${String(query.tripId)}/f_88_1/booking?ulang=en&placement_type=integrated_booking&from_v2=true`, - // The checkout link dies with its search, and the response says so (#1326 Q5). - expires: true, - }); - }, - /** - * `GET /v1/flights/search-link` (#1326) — the DURABLE counterpart, so - * `expires: false`. Stateless: every value in the URL is the caller's own search - * context, which is why the link outlives the search a booking link is bound to. - */ - fetchSearchLink: async ( - _base: string, - token: string, - params: SearchLinkParams, - ) => { - opts.capture?.({ - fareId: "", - query: params as unknown as Record, - }); - guard(accepted, token); - const leg = `${String(params.from)}-${String(params.to)}-${String(params.fromDate)}`; - return Promise.resolve({ - searchUrl: `https://www.wego.com/flights/searches/${leg}/economy/1a:0c:0i?ulang=en`, - expires: false, - }); - }, - }; -} - -describe("flights (through run – the argv entry point)", () => { - const runDeps = ( - io: ReturnType, - cfg: CliConfig, - api: Partial< - ReturnType & ReturnType - > = refusingApi(), - // Stored travel preferences (issue #1386). Default: none stored, which is a - // fresh machine and the state every pre-existing test was written against. - settings: UserSettings = {}, - ): RunDeps => { - const cmdIo = { log: io.log, error: io.error }; - const authed = { - ...cmdIo, - loadCredentials, - saveCredentials, - refreshTokens, - loadSettings: async () => settings, - recordAuthFailure: async () => {}, - }; - return { - loadConfig: () => cfg, - io: cmdIo, - login: () => Promise.resolve(0), - whoami: (c) => whoami(c, { ...authed, fetchWhoami }), - places: (c, args) => places(c, args, { ...authed, fetchPlaces }), - info: (c, args) => - info(c, args, { - ...authed, - fetchHolidays, - fetchVisaFree, - fetchSchedules, - fetchNearbyPlaces, - }), - flights: (c, args) => - flights(c, args, { - ...authed, - createFlightSearch, - fetchFlightResults, - fetchFlightTrip, - fetchTripExperience, - fetchFareOptions, - fetchBookingLink, - fetchSearchLink, - ...api, - // No-op sleep so the bounded `--wait` settle loop runs instantly. - sleep: () => Promise.resolve(), - }), - hotels: () => { - throw new Error("hotels is not exercised by the flights tests"); - }, - feedback: () => { - throw new Error("feedback is not exercised by the flights tests"); - }, - skill: () => { - throw new Error("skill is not exercised by the flights tests"); - }, - update: () => { - throw new Error("update is not exercised by the flights tests"); - }, - uninstall: () => { - throw new Error("uninstall is not exercised by the flights tests"); - }, - config: () => { - throw new Error("config is not exercised by the flights tests"); - }, - telemetry: () => { - throw new Error("telemetry is not exercised by the flights tests"); - }, - sendTelemetry: () => { - throw new Error("sendTelemetry is not exercised by the flights tests"); - }, - logout: (c) => - logout(c, { ...cmdIo, clearCredentials, clearSession: async () => {} }), - }; - }; - const wego = (...args: string[]) => ["bun", "wego", ...args]; - - it("search: creates, blocks to settled, and prints the page + searchId", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const api = flightsApi("tok-1"); - const io = sink(); - const code = await run( - wego( - "flights", - "search", - "SIN", - "BKK", - "2026-03-01", - "--return", - "2026-03-08", - ), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - const printed = io.out.join("\n"); - expect(printed).toContain(`"searchId": "${SEARCH_ID}"`); - // A card page, not trips: the price summary is what `search` prints (#1308). - expect(printed).toContain('"hasWegoFare": true'); - expect(printed).not.toContain('"fares"'); - }); - - it("search: --infants above the API's cap or above --adults is a usage error, not a 400", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const api = flightsApi("tok-1"); - const io = sink(); - for (const flags of [ - ["--infants", "9"], - ["--adults", "1", "--infants", "2"], - ["--infants", "2"], - ]) { - const code = await run( - wego("flights", "search", "SIN", "BKK", "2026-03-01", ...flags), - runDeps(io, config({ api: API }), api), - ); - expect(code, flags.join(" ")).toBe(2); - } - }); - - it("search: a date that is not a real calendar day is a usage error, not a 400", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const api = flightsApi("tok-1"); - const io = sink(); - // Both date inputs and both failure modes of the parse: a wrong shape, and a - // shape that names no real day. Same table as `share` and `booking-link` - - // one parse rule for every flights command. - for (const args of [ - ["01-03-2027", []], - ["2027-02-30", []], - ["2027-03-01", ["--return", "2027-13-01"]], - ["2027-03-01", ["--return", "nope"]], - ] as const) { - const code = await run( - wego("flights", "search", "SIN", "BKK", args[0], ...args[1]), - runDeps(io, config({ api: API }), api), - ); - expect(code, `${args[0]} ${args[1].join(" ")}`).toBe(2); - } - }); - - it("search: derives --site from the stored id_token market (source: account)", async () => { - // Logged in with a market decoded from the id_token; no explicit --site. - await saveCredentials(credPath, { accessToken: "tok-1", market: "AE" }); - // The API echoes the siteCode the CLI derived + sent; the CLI reports its - // OWN source (`account`), since only the CLI knows it auto-derived. - const api = flightsApi("tok-1", { - createExtra: { siteCode: "AE", siteCodeSource: "explicit" }, - }); - const io = sink(); - const code = await run( - wego("flights", "search", "SIN", "BKK", "2026-03-01"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - const parsed = JSON.parse(io.out.join("\n")) as { - siteCode: string; - siteCodeSource: string; - }; - expect(parsed.siteCode).toBe("AE"); - expect(parsed.siteCodeSource).toBe("account"); - }); - - it("search: an explicit --site overrides the derived market (source: explicit)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1", market: "AE" }); - const api = flightsApi("tok-1", { - createExtra: { siteCode: "SG", siteCodeSource: "explicit" }, - }); - const io = sink(); - const code = await run( - wego("flights", "search", "SIN", "BKK", "2026-03-01", "--site", "SG"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - const parsed = JSON.parse(io.out.join("\n")) as { - siteCode: string; - siteCodeSource: string; - }; - expect(parsed.siteCode).toBe("SG"); - expect(parsed.siteCodeSource).toBe("explicit"); - }); - - it("search: no --site and no stored market → US default (source: default)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); // no market - const api = flightsApi("tok-1", { - createExtra: { siteCode: "US", siteCodeSource: "default" }, - }); - const io = sink(); - const code = await run( - wego("flights", "search", "SIN", "BKK", "2026-03-01"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - const parsed = JSON.parse(io.out.join("\n")) as { - siteCode: string; - siteCodeSource: string; - }; - expect(parsed.siteCode).toBe("US"); - expect(parsed.siteCodeSource).toBe("default"); - }); - - it("search: threads --currency and --locale into the results read (not the API defaults)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const seen: Array<{ currency?: string; locale?: string }> = []; - const api = flightsApi("tok-1", { - onResults: (query) => - seen.push({ currency: query.currency, locale: query.locale }), - }); - const code = await run( - wego( - "flights", - "search", - "SIN", - "BKK", - "2026-03-01", - "--currency", - "SGD", - "--locale", - "ar", - ), - runDeps(sink(), config({ api: API }), api), - ); - expect(code).toBe(0); - // The post-create results read carries the search's currency + locale. - expect(seen[0]).toEqual({ currency: "SGD", locale: "ar" }); - }); - - it("search: emits ONLY parseable JSON on stdout when the first page is empty, with the hint on stderr", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - // The immediate read returns an empty page: search prints it + the hint. - const api = flightsApi("tok-1", { emptyReads: 99 }); - const io = sink(); - const code = await run( - wego("flights", "search", "SIN", "BKK", "2026-03-01"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - // stdout is the JSON object and nothing else — an agent/script consuming a - // successful `flights search` must be able to JSON.parse it even when empty. - const parsed = JSON.parse(io.out.join("\n")) as { - searchId: string; - results: unknown[]; - }; - expect(parsed.searchId).toBe(SEARCH_ID); - expect(parsed.results).toHaveLength(0); - // The human re-poll hint goes to stderr, never stdout. - expect(io.err.join("\n")).toContain(`wego flights results ${SEARCH_ID}`); - expect(io.out.join("\n")).not.toContain("re-run"); - }); - - it("search: a 401 during the results read refreshes + retries the READ, never re-creating the search", async () => { - // Regression: create + read must be separate withAccessToken calls, so a - // token expiry between them retries only the read (same searchId) — not the - // whole callback, which would spawn a second upstream search. - await saveCredentials(credPath, { - accessToken: "tok-1", - refreshToken: "rt", - }); - const as = authServer(() => Response.json({ access_token: "fresh" })); - let creates = 0; - let firstRead = true; - // The create accepts the initial token; the first results read raises the - // `UnauthorizedError` `api.ts` raises on a 401, then only the refreshed - // "fresh" token is accepted. - const api = { - createFlightSearch: async (_b: string, token: string) => { - guard(["tok-1"], token); - creates++; - return Promise.resolve({ searchId: SEARCH_ID }); - }, - fetchFlightResults: async (_b: string, token: string) => { - if (firstRead) { - firstRead = false; - throw new UnauthorizedError(); - } - guard(["fresh"], token); - return Promise.resolve( - resultsBody(false) as Awaited>, - ); - }, - }; - const io = sink(); - const code = await run( - wego("flights", "search", "SIN", "BKK", "2026-03-01"), - runDeps(io, config({ as: as.url, api: API }), api), - ); - expect(code).toBe(0); - expect(creates).toBe(1); // create ran exactly once, despite the read 401 - expect(io.out.join("\n")).toContain('"hasWegoFare": true'); - }); - - it("results: after a reactive refresh, a non-auth failure keeps its typed exit class (not AUTH)", async () => { - // Regression (#1130 review): a 401 on the first read refreshes + retries, but - // if the RETRIED read fails with a non-auth error (503 here), it must be - // classified by the shared taxonomy — EXIT.RETRYABLE (5) — not swallowed as - // EXIT.AUTH (3) merely because it happened inside the refresh-retry path. - await saveCredentials(credPath, { - accessToken: "tok-1", - refreshToken: "rt", - }); - const as = authServer(() => Response.json({ access_token: "fresh" })); - let firstRead = true; - const api = { - fetchFlightResults: async (_b: string, token: string) => { - if (firstRead) { - firstRead = false; - throw new UnauthorizedError(); - } - guard(["fresh"], token); - // The refreshed token is accepted, but the upstream is unavailable. - throw new ApiHttpError( - 503, - "GET /v1/flights/searches/:searchId/results", - ); - }, - }; - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID), - runDeps(io, config({ as: as.url, api: API }), api), - ); - expect(code).toBe(5); // retryable (503): refresh succeeded, the retried call did not - }); - - it("search: a non-401 results-read failure exits 1 with only the 'Search created – re-run' hint (no raw error, no JSON)", async () => { - // The primary failure surface of the simplified command: create succeeds, - // the immediate read 500s (non-401 → no refresh). stdout must stay empty - // (no JSON) and stderr must carry the single recovery hint — not the raw - // upstream error plus a second hint line. - await saveCredentials(credPath, { accessToken: "tok-1" }); - const api = { - createFlightSearch: async (_b: string, token: string) => { - guard(["tok-1"], token); - return Promise.resolve({ searchId: SEARCH_ID }); - }, - fetchFlightResults: async (_b: string, token: string) => { - guard(["tok-1"], token); - throw new ApiHttpError( - 500, - "GET /v1/flights/searches/:searchId/results", - ); - }, - }; - const io = sink(); - const code = await run( - wego("flights", "search", "SIN", "BKK", "2026-03-01"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(1); - expect(io.out.join("\n")).toBe(""); // no JSON on a failed read - const err = io.err.join("\n"); - expect(err).toContain( - `Search created – re-run: wego flights results ${SEARCH_ID} --wait`, - ); - // Exactly the hint — the raw 5xx is not surfaced alongside it. - expect(err).not.toMatch(/500|boom|failed: 5/); - }); - - it("search: a MID-settle non-401 read failure surfaces its exit-code taxonomy (not the attempt-0 fold)", async () => { - // The attempt-0 fold (test above) is scoped to the first, right-after-create - // read. A failure on a LATER settle re-read is a genuine transient/upstream - // fault (the search was already returning snapshots), so it must surface with - // its real exit-code class + trace-id, NOT collapse to the generic exit-1 - // "Search created — re-run" hint. Read #1 succeeds with a non-converged count - // (forcing a re-read); read #2 (mid-settle) 500s → EXIT.PERMANENT (6). - await saveCredentials(credPath, { accessToken: "tok-1" }); - let reads = 0; - const api = { - createFlightSearch: async (_b: string, token: string) => { - guard(["tok-1"], token); - return Promise.resolve({ searchId: SEARCH_ID }); - }, - fetchFlightResults: async (_b: string, token: string) => { - guard(["tok-1"], token); - reads++; - // Attempt 0: a valid snapshot with a count that cannot converge yet - // (convergence needs two equal reads) → the settle proceeds to a second. - if (reads === 1) { - return Promise.resolve( - resultsBodyWithCount(1) as Awaited< - ReturnType - >, - ); - } - // Attempt 1 (mid-settle): a hard 500 (not 429/503, so no GET retry). - throw new ApiHttpError( - 500, - "GET /v1/flights/searches/:searchId/results", - ); - }, - }; - const io = sink(); - const code = await run( - wego("flights", "search", "SIN", "BKK", "2026-03-01"), - runDeps(io, config({ api: API }), api), - ); - // The real taxonomy (500 → permanent), not the folded exit 1. - expect(code).toBe(6); - expect(io.out.join("\n")).toBe(""); // still no JSON on a failed settle - // The fold hint must NOT appear — the mid-settle error is surfaced on its own. - expect(io.err.join("\n")).not.toContain("Search created – re-run"); - expect(reads).toBeGreaterThanOrEqual(2); // it did re-read past the first - }); - - it("search: blocks to settled – re-reads past the first snapshot, stamps settled (issue #1084)", async () => { - // #1084 unifies the two verticals: `flights search` now BLOCKS to settled - // through the shared engine (it used to return the first snapshot - // immediately). The first read is empty, so the settle must re-read until - // the count converges — never a single read. - await saveCredentials(credPath, { accessToken: "tok-1" }); - let reads = 0; - const api = flightsApi("tok-1", { - emptyReads: 1, - onResults: () => { - reads++; - }, - }); - const io = sink(); - const code = await run( - wego("flights", "search", "SIN", "BKK", "2026-03-01"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - // It re-read past the first (empty) snapshot rather than returning it. - expect(reads).toBeGreaterThan(1); - const parsed = JSON.parse(io.out.join("\n")) as { - settled: string; - results: unknown[]; - }; - expect(parsed.settled).toBe("converged"); - expect(parsed.results.length).toBeGreaterThan(0); - }); - - it("results --wait: settles when snapshotFareCount stops growing → JSON with settled:'converged'", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - // empty → partial → stable: reads report 0, then 3, then 3 again. The - // second stable read means the snapshot converged (issue #1112). - const api = flightsResultsSequence("tok-1", [0, 3, 3]); - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID, "--wait"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - // stdout is a single JSON object (agent-parseable) carrying the honest - // heuristic label; the settle field is part of the JSON, not a stderr hint. - const parsed = JSON.parse(io.out.join("\n")) as { - searchId: string; - settled: string; - metadata: { snapshotFareCount: number }; - }; - expect(parsed.searchId).toBe(SEARCH_ID); - expect(parsed.settled).toBe("converged"); - expect(parsed.metadata.snapshotFareCount).toBe(3); - // It stopped early on convergence — well short of the full read budget. - expect(api.reads()).toBe(3); - }); - - it("results --wait: with no count to trust, the settle falls back to item-presence", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - // The `.catch(undefined)` that drops a malformed `snapshotFareCount` lives in - // `api.ts`'s response schema, so it is asserted there ("drops a malformed - // snapshotFareCount"). What belongs here is the consequence: with the count - // absent, item-presence carries the settle to converged. - const api = { - fetchFlightResults: async (_b: string, token: string) => { - guard(["tok-1"], token); - return Promise.resolve({ - searchId: SEARCH_ID, - currencyCode: "USD", - metadata: { - page: 1, - pageSize: 10, - resultCount: 1, - totalCandidates: 1, - hasMore: false, - }, - results: [cardBody()], - } as Awaited>); - }, - }; - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID, "--wait"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - const parsed = JSON.parse(io.out.join("\n")) as { - settled: string; - metadata: { snapshotFareCount?: number }; - }; - // The malformed count is dropped (undefined), not surfaced as a number; - // item-presence carries the settle to converged. - expect(parsed.metadata.snapshotFareCount).toBeUndefined(); - expect(parsed.settled).toBe("converged"); - }); - - it("results --wait: a transient count drop does not converge – waits for equality", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - // A transient prune/dedup drops the count (12 → 10) before it holds steady. - // Convergence is defined as two successive *equal* non-zero reads, so the - // drop must reset the baseline and keep waiting — never converge on 10 ≤ 12. - const api = flightsResultsSequence("tok-1", [0, 12, 10, 10]); - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID, "--wait"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - const parsed = JSON.parse(io.out.join("\n")) as { - settled: string; - metadata: { snapshotFareCount: number }; - }; - // It settled only once the count held equal (10 == 10), not at the 12 → 10 - // decrease — which required a fourth read past the drop. - expect(parsed.settled).toBe("converged"); - expect(parsed.metadata.snapshotFareCount).toBe(10); - expect(api.reads()).toBe(4); - }); - - it("results --wait: a never-stabilizing snapshot exhausts the budget → settled:'budget_exhausted' + stderr hint", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - // A count that grows on every read never converges — the budget must cap it. - const api = flightsResultsSequence( - "tok-1", - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], - ); - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID, "--wait"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - const parsed = JSON.parse(io.out.join("\n")) as { settled: string }; - expect(parsed.settled).toBe("budget_exhausted"); - // The re-poll hint is human guidance → stderr, keeping stdout JSON-only. - expect(io.err.join("\n")).toMatch(/re-run|still (growing|accruing)/i); - // The read budget is bounded by the unified engine (issue #1084): one - // initial read + DEFAULT_SETTLE_BUDGET.maxRereads (12) re-reads = 13, never - // unbounded. - expect(api.reads()).toBe(13); - }); - - it("results --wait: a 401 mid-settle refreshes once and restarts the whole poll (never resumes)", async () => { - // The whole settle loop runs inside ONE withAccessToken call, which retries - // its entire callback once on a 401. So a token expiry partway through the - // re-reads restarts settleFlightResults from read #1 against the same id — - // correctness-safe (reads are idempotent), it just re-walks the sequence. - await saveCredentials(credPath, { - accessToken: "tok-1", - refreshToken: "rt", - }); - const as = authServer(() => Response.json({ access_token: "fresh" })); - const counts = [0, 2, 2]; // first pass: 0 → 2, then a 401 before it holds - let reads = 0; - const api = { - fetchFlightResults: async (_b: string, token: string) => { - // "tok-1" expires on its 3rd read (mid-settle); only "fresh" works after. - if (token === "tok-1" && reads >= 2) throw new UnauthorizedError(); - guard(["tok-1", "fresh"], token); - const count = counts[Math.min(reads, counts.length - 1)] as number; - reads++; - return Promise.resolve( - resultsBodyWithCount(count) as Awaited< - ReturnType - >, - ); - }, - }; - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID, "--wait"), - runDeps(io, config({ as: as.url, api: API }), api), - ); - expect(code).toBe(0); - const parsed = JSON.parse(io.out.join("\n")) as { - settled: string; - metadata: { snapshotFareCount: number }; - }; - // The restarted poll converges once the count holds equal on "fresh". - expect(parsed.settled).toBe("converged"); - expect(parsed.metadata.snapshotFareCount).toBe(2); - // Reads before the 401 count toward the total: the restart re-walks read #1. - expect(reads).toBeGreaterThan(3); - }); - - it("results: without --wait, reads exactly once (no settle loop) and stamps settled:unsettled (issue #1084)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const api = flightsResultsSequence("tok-1", [1, 2, 3]); - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - expect(api.reads()).toBe(1); - // A bare (un-waited) read is a single snapshot → honestly stamped - // `unsettled` so an empty page is never mistaken for a definitive - // no-results (the metasearch has no completion flag). - const parsed = JSON.parse(io.out.join("\n")) as { settled?: string }; - expect(parsed.settled).toBe("unsettled"); - }); - - it("results: renders the fares-less card body and sends no view param (issues #1117 + #1308)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: FlightResultsQuery | undefined; - const api = flightsApi("tok-1", { onResults: (q) => (seen = q) }); - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - // No projection to choose: the CLI stopped sending `view` with #1308. Asserted - // on the KEY, not on a typed field - `view` is off `FlightResultsQuery`, so a - // stray one would only ever show up as an extra key on what the command passed. - expect(seen && "view" in seen).toBe(false); - const printed = io.out.join("\n"); - expect(printed).toContain(`"tripId": "${TRIP_ID}"`); - expect(printed).toContain('"scope": "party"'); - // A card carries no fares — the render must not invent them. - expect(printed).not.toContain('"fares"'); - // Trip-level stops/duration ARE on the card, so an agent never folds legs[]. - expect(printed).toContain('"durationMinutes": 205'); - }); - - // --- stored travel preferences (issue #1386) ------------------------------- - - it("results: a stored currency reaches a bare read, which used to revert to USD", async () => { - // THE bug in issue #1386: `--currency SAR` on the search, then a plain read - // came back priced in USD, and 216 SAR looked like it had fallen to 58. The - // query the read passed is where that is decided; `api.test.ts` owns what it - // then becomes on the wire. - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: FlightResultsQuery | undefined; - const api = flightsApi("tok-1", { onResults: (q) => (seen = q) }); - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID), - runDeps(io, config({ api: API }), api, { - currency: "SAR", - locale: "ar", - }), - ); - expect(code).toBe(0); - expect(seen).toMatchObject({ currency: "SAR", locale: "ar" }); - }); - - it("results: an explicit --currency still beats the stored one", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: FlightResultsQuery | undefined; - const api = flightsApi("tok-1", { onResults: (q) => (seen = q) }); - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID, "--currency", "USD"), - runDeps(io, config({ api: API }), api, { currency: "SAR" }), - ); - expect(code).toBe(0); - expect(seen?.currency).toBe("USD"); - }); - - it("search: a stored site is sent and reported as source `setting`, over the account market", async () => { - // The account says AE; the user buys from SA. The setting wins and the - // output says which layer decided, so an agent can report the market. - await saveCredentials(credPath, { accessToken: "tok-1", market: "AE" }); - const api = flightsApi("tok-1", { - createExtra: { siteCode: "SA", siteCodeSource: "explicit" }, - }); - const io = sink(); - const code = await run( - wego("flights", "search", "RUH", "DXB", "2099-03-01"), - runDeps(io, config({ api: API }), api, { site: "SA", currency: "SAR" }), - ); - expect(code).toBe(0); - const printed = io.out.join("\n"); - expect(printed).toContain('"siteCode": "SA"'); - expect(printed).toContain('"siteCodeSource": "setting"'); - }); - - it("search: names the layer the CURRENCY came from, which the API cannot see", async () => { - // Issue #1400. The request carries one currency string either way, so the - // API's own `metadata.currencyCodeSource` calls a stored currency `explicit` - // — only the CLI can say `setting`. Each rung also pins the invariant that - // erasing the source used to protect: the create and its settle read go out - // in the SAME resolved currency, or the search and its first page are priced - // in different units. - await saveCredentials(credPath, { accessToken: "tok-1" }); - const search = async (settings: UserSettings, extraArgs: string[]) => { - let created: CreateFlightSearchBody | undefined; - let read: FlightResultsQuery | undefined; - const io = sink(); - const code = await run( - wego("flights", "search", "RUH", "DXB", "2099-03-01", ...extraArgs), - runDeps( - io, - config({ api: API }), - flightsApi("tok-1", { - onCreate: (b) => (created = b), - onResults: (q) => (read = q), - }), - settings, - ), - ); - expect(code).toBe(0); - const printed = JSON.parse(io.out.join("\n")) as Record; - return { source: printed.currencyCodeSource, created, read }; - }; - - const explicit = await search({ currency: "SAR" }, ["--currency", "USD"]); - expect(explicit.source).toBe("explicit"); - expect(explicit.created?.currency).toBe("USD"); - expect(explicit.read?.currency).toBe("USD"); - - const stored = await search({ currency: "SAR" }, []); - expect(stored.source).toBe("setting"); - expect(stored.created?.currency).toBe("SAR"); - expect(stored.read?.currency).toBe("SAR"); - - // Nothing stored and no flag: the request carries no currency at all, so the - // API's USD default owns the decision and the label says so. - const defaulted = await search({}, []); - expect(defaulted.source).toBe("default"); - expect(defaulted.created?.currency).toBeUndefined(); - expect(defaulted.read?.currency).toBeUndefined(); - }); - - it("every priced read names the rung, not just the two searches", async () => { - // #1529 labelled the two `search`es. A search is not where most prices are - // read: `results` re-prices a page, and `trip` / `fares` are where a number - // is actually quoted from. Each decides its own unit (a `searchId` carries - // no currency), so each owes the same label — otherwise the rung is legible - // exactly once per funnel, at the step nobody quotes. - await saveCredentials(credPath, { accessToken: "tok-1" }); - // `fares` lives on its own stub, so both are merged: `runDeps` takes one api - // bag and the real functions would otherwise reach the network. - const sourceOf = async (argv: string[], settings: UserSettings) => { - const io = sink(); - const code = await run( - wego(...argv), - runDeps( - io, - config({ api: API }), - { ...flightsApi("tok-1"), ...faresApi(["tok-1"]) }, - settings, - ), - ); - expect(code).toBe(0); - return (JSON.parse(io.out.join("\n")) as Record) - .currencyCodeSource; - }; - - expect(await sourceOf(["flights", "results", SEARCH_ID], {})).toBe( - "default", - ); - expect( - await sourceOf(["flights", "results", SEARCH_ID], { currency: "SAR" }), - ).toBe("setting"); - expect( - await sourceOf(["flights", "results", SEARCH_ID, "--currency", "USD"], { - currency: "SAR", - }), - ).toBe("explicit"); - - expect( - await sourceOf(["flights", "trip", TRIP_ID, "--search", SEARCH_ID], { - currency: "SAR", - }), - ).toBe("setting"); - expect( - await sourceOf(["flights", "fares", "f_88_1"], { currency: "SAR" }), - ).toBe("setting"); - }); - - it("a read's currency reaches the wire from the rung the label names", async () => { - // The label is only worth reading if it describes the request that was - // actually made. Moving `results` off `applyPreferences` is where that could - // silently break: the source would still print while the query went out - // bare. - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: FlightResultsQuery | undefined; - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID), - runDeps( - io, - config({ api: API }), - flightsApi("tok-1", { onResults: (q) => (seen = q) }), - { currency: "SAR", locale: "ar" }, - ), - ); - expect(code).toBe(0); - // Locale still rides `applyPreferences`; only currency moved. - expect(seen).toMatchObject({ currency: "SAR", locale: "ar" }); - }); - - it("every priced read prints ONE *Source per knob, top level, CLI vocabulary (flights four of the eight)", async () => { - // The #1534 rule (decision Q2: "strip"). The API's request-scoped copies - // inside `metadata` — `currencyCodeSource`, `localeSource` — answer a - // narrower question in a narrower vocabulary: with a currency stored in - // settings.json the two fields disagree by construction ("setting" outside, - // "explicit" inside). So the copies are stripped at print time and the - // CLI's own top-level label is the ONE answer a payload carries. The - // `currencyCode` / `locale` echoes themselves are kept. The other four - // priced reads are the hotels half, pinned in `hotels.test.ts`. - await saveCredentials(credPath, { accessToken: "tok-1" }); - const printedFor = async (argv: string[]) => { - const io = sink(); - const code = await run( - wego(...argv), - runDeps( - io, - config({ api: API }), - { ...flightsApi("tok-1"), ...faresApi(["tok-1"]) }, - { currency: "SAR" }, - ), - ); - expect(code).toBe(0); - return io.out.join("\n"); - }; - - for (const argv of [ - ["flights", "search", "RUH", "DXB", "2099-03-01"], - ["flights", "results", SEARCH_ID], - ["flights", "trip", TRIP_ID, "--search", SEARCH_ID], - ["flights", "fares", "f_88_1"], - ]) { - const printed = await printedFor(argv); - const which = argv.join(" "); - // The echoes stay; every `*Source` inside metadata is gone. - expect(printed, which).toContain('"locale": "en"'); - expect(printed, which).not.toContain("localeSource"); - const parsed = JSON.parse(printed) as { - currencyCodeSource: string; - metadata: Record; - }; - // Exactly one currency label: top level, CLI vocabulary (`setting` is the - // rung the API cannot see), and no copy left in metadata. - expect(parsed.currencyCodeSource, which).toBe("setting"); - expect(printed.split('"currencyCodeSource"').length - 1, which).toBe(1); - expect( - Object.keys(parsed.metadata).filter((k) => k.endsWith("Source")), - which, - ).toEqual([]); - } - }); - - it("search: prints the currency hint on a fresh machine, and not once one is stored", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const fresh = sink(); - expect( - await run( - wego("flights", "search", "RUH", "DXB", "2099-03-01"), - runDeps(fresh, config({ api: API }), flightsApi("tok-1")), - ), - ).toBe(0); - expect(fresh.err.join("\n")).toContain("config set currency"); - - const configured = sink(); - expect( - await run( - wego("flights", "search", "RUH", "DXB", "2099-03-01"), - runDeps(configured, config({ api: API }), flightsApi("tok-1"), { - currency: "SAR", - }), - ), - ).toBe(0); - expect(configured.err.join("\n")).not.toContain("config set currency"); - }); - - it("search: an explicit --currency also silences the hint (nothing was defaulted)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const io = sink(); - expect( - await run( - wego( - "flights", - "search", - "RUH", - "DXB", - "2099-03-01", - "--currency", - "SAR", - ), - runDeps(io, config({ api: API }), flightsApi("tok-1")), - ), - ).toBe(0); - expect(io.err.join("\n")).not.toContain("config set currency"); - }); - - it("results --wait: falls back to item-presence when the count is ABSENT (a count-less page) (issue #1084)", async () => { - // When a snapshot carries no `snapshotFareCount` (a legacy API, or a - // count-less page), the unified settle can't converge on the count — it must - // fall back to item-presence and stop as soon as cards appear, not burn the - // whole re-read budget. - await saveCredentials(credPath, { accessToken: "tok-1" }); - let reads = 0; - const api = flightsApi("tok-1", { - onResults: () => { - reads++; - }, - }); - const io = sink(); - const code = await run( - wego("flights", "results", SEARCH_ID, "--wait"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(0); - // The fake always carries one card → converges on the first read's - // item-presence; it must NOT run the full 13-read budget. - expect(reads).toBeLessThanOrEqual(2); - const parsed = JSON.parse(io.out.join("\n")) as { settled: string }; - expect(parsed.settled).toBe("converged"); - }); + parseFeedbackArgs, + parseFlightResultsArgs, + parseLoginArgs, + resolveCliSite, +} from "./commands"; - it("results: an expired/unknown search prints a friendly message (exit 4, not_found)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const api = flightsApi("tok-1"); - const io = sink(); - const code = await run( - wego("flights", "results", "gone123msr"), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(4); // not_found: a 404 keeps its typed exit class - expect(io.err.join("\n")).toMatch(/expired|not found/i); - }); +/** + * The pure halves of the commands: the parsers that turn argv into API-call + * arguments and the rung resolution behind `--site`. What a command prints, the + * exit code it returns and what reaches the wire are the integration tier's + * (`integration/places.test.ts`, `flights.test.ts`, `feedback.test.ts`, + * `auth.test.ts`, `login-more.test.ts`), which drives the compiled binary. + */ - it("trip: forwards --view to the trip read, and sends none without the flag", async () => { - // The projection axis the API publishes on this read (`default|detail`). Before - // the flag, `?view=detail` was reachable only by calling the API directly — which - // is what tier C's `apiGet` bypass existed for. - await saveCredentials(credPath, { accessToken: "tok-1" }); - const views: Array = []; - const api = flightsApi("tok-1", { - onTripView: (view) => views.push(view), +describe("parseLoginArgs", () => { + it("skips the browser only when asked, or when the shell is remote", () => { + expect(parseLoginArgs([], false)).toEqual({ skipBrowser: false }); + expect(parseLoginArgs([], true)).toEqual({ skipBrowser: true }); + expect(parseLoginArgs(["--no-browser"], false)).toEqual({ + skipBrowser: true, }); - const deps = runDeps(sink(), config({ api: API }), api); - expect( - await run( - wego( - "flights", - "trip", - TRIP_ID, - "--search", - SEARCH_ID, - "--view", - "detail", - ), - deps, - ), - ).toBe(0); - expect( - await run(wego("flights", "trip", TRIP_ID, "--search", SEARCH_ID), deps), - ).toBe(0); - // `undefined` on the second call, NOT the string "default": the CLI omits the - // param rather than spelling out the server's own default, so the query string - // of an unflagged read is unchanged by this feature. - expect(views).toEqual(["detail", undefined]); - }); - - it("trip: rejects an unknown --view locally (exit 2, no network)", async () => { - // Guarded against the published enum, like `hotels details --view`. The api URL - // is a closed port, so a call escaping the guard fails as a fault, not a 400. - await saveCredentials(credPath, { accessToken: "tok-1" }); - const io = sink(); - const code = await run( - wego( - "flights", - "trip", - TRIP_ID, - "--search", - SEARCH_ID, - "--view", - "detials", - ), - runDeps(io, config({ api: "http://127.0.0.1:1" })), - ); - expect(code).toBe(2); - expect(io.err.join("\n")).toContain( - "--view must be one of default, detail", - ); - }); - - it("trip: requires --search (usage error, no network)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const io = sink(); - const code = await run( - wego("flights", "trip", TRIP_ID), - runDeps(io, config({ api: "http://127.0.0.1:1" })), - ); - expect(code).toBe(2); // usage error - expect(io.err.join("\n")).toMatch(/--search/); - }); - - it("rejects an unknown flights sub-command with usage (exit 2)", async () => { - const io = sink(); - const code = await run( - wego("flights", "bogus"), - runDeps(io, config({ api: "http://127.0.0.1:1" })), - ); - expect(code).toBe(2); // usage error - expect(io.err.join("\n")).toMatch(/Usage:/); - }); -}); - -// --- flights (issue #1014) — driven through the REAL `run` entry point ------- - -describe("flights fares + booking-link (through run – the argv entry point)", () => { - const runDeps = ( - io: ReturnType, - cfg: CliConfig, - api: Partial> = refusingApi(), - // Stored travel preferences (issue #1386). Default: none stored, the state - // every pre-existing test in this block was written against. - settings: UserSettings = {}, - ): RunDeps => { - const cmdIo = { log: io.log, error: io.error }; - const notExercised = () => { - throw new Error("not exercised by the flights tests"); - }; - return { - loadConfig: () => cfg, - io: cmdIo, - login: notExercised, - whoami: notExercised, - places: notExercised, - info: notExercised, - flights: (c, args) => - flights(c, args, { - ...cmdIo, - loadCredentials, - saveCredentials, - refreshTokens, - loadSettings: async () => settings, - recordAuthFailure: async () => {}, - createFlightSearch, - fetchFlightResults, - fetchFlightTrip, - fetchTripExperience, - fetchFareOptions, - fetchBookingLink, - fetchSearchLink, - ...api, - sleep: () => Promise.resolve(), - }), - hotels: notExercised, - feedback: notExercised, - skill: notExercised, - update: notExercised, - uninstall: notExercised, - config: notExercised, - telemetry: notExercised, - sendTelemetry: notExercised, - logout: (c) => - logout(c, { ...cmdIo, clearCredentials, clearSession: async () => {} }), - }; - }; - const wego = (...args: string[]) => ["bun", "wego", ...args]; - - it("fares: prints the branded-fare options JSON and forwards currency/locale", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: { fareId: string; query: Record } | undefined; - const api = faresApi(["tok-1"], { capture: (call) => (seen = call) }); - const io = sink(); - - const code = await run( - wego("flights", "fares", "f_88_1", "--currency", "USD", "--locale", "en"), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(0); - expect(io.out.join("")).toContain('"fareOptionId": "SQ_ECO_LITE"'); - expect(seen?.fareId).toBe("f_88_1"); - expect(String(seen?.query.currency)).toBe("USD"); - expect(String(seen?.query.locale)).toBe("en"); - }); - - it("fares: an expired fare (API 404) prints the re-search hint (exit 4, not_found)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - // The API returns 404 for an expired fare (its upstream compare 410'd). - const api = faresApi(["tok-1"], { optionsStatus: 404 }); - const io = sink(); - - const code = await run( - wego("flights", "fares", "f_88_1"), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(4); // not_found: an expired fare keeps its typed exit class - expect(io.err.join("")).toMatch(/expired|search again|re-open/i); - }); - - it("experience: prints the per-leg signals and sends no query by default", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: { fareId: string; query: Record } | undefined; - const api = faresApi(["tok-1"], { capture: (call) => (seen = call) }); - const io = sink(); - - const code = await run( - wego("flights", "experience", "s1msr:TR638~3~1250~1425"), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(0); - expect(io.out.join("")).toContain('"lateArrival": true'); - // The tripId reaches the call unmangled; `api.ts` owns the percent-encoding - // into the path, which `api.test.ts` asserts on the URL it builds. - expect(seen?.fareId).toBe("s1msr:TR638~3~1250~1425"); - // No `searchId` unless the caller asked for the cross-check. - expect(seen?.query).toEqual({}); - }); - - it("experience: forwards --search as the cross-check", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: { fareId: string; query: Record } | undefined; - const api = faresApi(["tok-1"], { capture: (call) => (seen = call) }); - const io = sink(); - - const code = await run( - wego( - "flights", - "experience", - "s1msr:TR638~3~1250~1425", - "--search", - "s1msr", - ), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(0); - expect(seen?.query.searchId).toBe("s1msr"); - }); - - it("experience: an expired trip (API 404) prints the re-search hint (exit 4)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const api = faresApi(["tok-1"], { experienceStatus: 404 }); - const io = sink(); - - const code = await run( - wego("flights", "experience", "s1msr:TR638~3~1250~1425"), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(4); - expect(io.err.join("")).toMatch(/expired|search again|re-open/i); - }); - - it("experience: a missing tripId is a usage error with no network call", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let called = false; - const api = faresApi(["tok-1"], { capture: () => (called = true) }); - const io = sink(); - - const code = await run( - wego("flights", "experience"), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(2); - expect(called).toBe(false); - expect(io.err.join("")).toContain("flights experience "); - }); - - it("booking-link: maps every flag to the query and prints { bookingUrl }", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: { fareId: string; query: Record } | undefined; - const api = faresApi(["tok-1"], { capture: (call) => (seen = call) }); - const io = sink(); - - const code = await run( - wego( - "flights", - "booking-link", - "f_88_1", - "--trip", - "trip-abc:xyz", - "--search", - "search-1", - "--fare-option", - "uuid-1", - "--from", - "SIN", - "--to", - "BKK", - "--date", - "2026-08-01", - "--return", - "2026-08-08", - "--cabin", - "business", - "--adults", - "2", - "--children", - "1", - "--infants", - "1", - "--site", - "SG", - "--currency", - "USD", - "--locale", - "en", - "--from-city", - "--to-city", - ), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(0); - expect(io.out.join("")).toContain('"bookingUrl"'); - expect(seen?.fareId).toBe("f_88_1"); - expect(String(seen?.query.tripId)).toBe("trip-abc:xyz"); - expect(String(seen?.query.searchId)).toBe("search-1"); - expect(String(seen?.query.fareOptionId)).toBe("uuid-1"); - expect(String(seen?.query.from)).toBe("SIN"); - expect(String(seen?.query.to)).toBe("BKK"); - expect(String(seen?.query.fromDate)).toBe("2026-08-01"); - expect(String(seen?.query.toDate)).toBe("2026-08-08"); - expect(String(seen?.query.cabin)).toBe("business"); - expect(String(seen?.query.adults)).toBe("2"); - expect(String(seen?.query.children)).toBe("1"); - expect(String(seen?.query.infants)).toBe("1"); - expect(String(seen?.query.siteCode)).toBe("SG"); - expect(String(seen?.query.currency)).toBe("USD"); - expect(String(seen?.query.locale)).toBe("en"); - expect(String(seen?.query.fromCity)).toBe("true"); - expect(String(seen?.query.toCity)).toBe("true"); + // --browser overrules the SSH detection (the X11-forwarding case). + expect(parseLoginArgs(["--browser"], true)).toEqual({ skipBrowser: false }); }); - it("share: maps positionals and every flag to the query and prints { searchUrl, expires }", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: { fareId: string; query: Record } | undefined; - const api = faresApi(["tok-1"], { capture: (call) => (seen = call) }); - const io = sink(); - - const code = await run( - wego( - "flights", - "share", - "SIN", - "BKK", - "2026-09-15", - "--return", - "2026-09-22", - "--cabin", - "business", - "--adults", - "2", - "--children", - "1", - "--infants", - "1", - "--site", - "SG", - "--currency", - "USD", - "--locale", - "en", - "--from-city", - "--to-city", - ), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(0); - expect(io.out.join("")).toContain('"searchUrl"'); - // `expires: false` reaches the agent — the whole point of the pair (#1326 Q5). - expect(io.out.join("")).toContain('"expires"'); - // What this command owns is the argv -> params mapping. The wire KEYS those - // params land under are `api.ts`'s job, asserted in `api.test.ts` - // ("fetchSearchLink puts the whole search context on the wire"). - expect(seen?.query).toEqual({ - from: "SIN", - to: "BKK", - fromDate: "2026-09-15", - toDate: "2026-09-22", - cabin: "business", - adults: 2, - children: 1, - infants: 1, - siteCode: "SG", - currency: "USD", - locale: "en", - fromCity: true, - toCity: true, + it("returns a usage message for an unknown or contradictory flag", () => { + expect(parseLoginArgs(["--nope"], false)).toEqual({ + usage: expect.stringContaining("Unknown option: --nope"), }); - // `toEqual` is exhaustive, so it already proves no search-scoped id rides along - - // which the `SearchLinkParams` Omit also forbids at the type level. - }); - - it("share: inherits the stored currency, locale and market (issue #1386)", async () => { - // The share URL carries all three, and it is DURABLE: a link built in the - // API's USD hands the wrong currency to everyone it reaches, not once. - await saveCredentials(credPath, { accessToken: "tok-1", market: "AE" }); - let seen: { fareId: string; query: Record } | undefined; - const api = faresApi(["tok-1"], { capture: (call) => (seen = call) }); - const code = await run( - wego("flights", "share", "SIN", "BKK", "2026-09-15"), - runDeps(sink(), config({ api: API }), api, { - currency: "SAR", - locale: "ar", - site: "SA", - }), - ); - expect(code).toBe(0); - expect(seen?.query).toMatchObject({ - currency: "SAR", - locale: "ar", - // The stored site beats the account market (AE), same rung order as search. - siteCode: "SA", + expect(parseLoginArgs(["--browser", "--no-browser"], false)).toEqual({ + usage: expect.stringContaining("not both"), }); }); - - it("share: an explicit flag still beats the stored setting", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: { fareId: string; query: Record } | undefined; - const api = faresApi(["tok-1"], { capture: (call) => (seen = call) }); - const code = await run( - wego( - "flights", - "share", - "SIN", - "BKK", - "2026-09-15", - "--currency", - "USD", - "--site", - "SG", - ), - runDeps(sink(), config({ api: API }), api, { - currency: "SAR", - site: "SA", - }), - ); - expect(code).toBe(0); - expect(seen?.query).toMatchObject({ currency: "USD", siteCode: "SG" }); - }); - - it("share: a missing positional is a usage error BEFORE any network call", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let hits = 0; - const api = faresApi(["tok-1"], { capture: () => (hits += 1) }); - const io = sink(); - - for (const args of [ - ["flights", "share"], - ["flights", "share", "SIN"], - ["flights", "share", "SIN", "BKK"], - ]) { - const code = await run( - wego(...args), - runDeps(io, config({ api: API }), api), - ); - expect(code, args.join(" ")).toBe(2); - } - expect(hits).toBe(0); - }); - - it("share: an unknown flag and a fourth positional are usage errors", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let hits = 0; - const api = faresApi(["tok-1"], { capture: () => (hits += 1) }); - const io = sink(); - - for (const args of [ - ["flights", "share", "SIN", "BKK", "2026-09-15", "--trip", "abc:TR1"], - ["flights", "share", "SIN", "BKK", "2026-09-15", "extra"], - ]) { - const code = await run( - wego(...args), - runDeps(io, config({ api: API }), api), - ); - expect(code, args.join(" ")).toBe(2); - } - expect(hits).toBe(0); - }); - - it("share: an out-of-range pax count is rejected client-side, costing no request", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let hits = 0; - const api = faresApi(["tok-1"], { capture: () => (hits += 1) }); - const io = sink(); - - // Both ends of every published cap (apps/api linkContextShape: adults 1-9, - // children 0-8, infants 0-8), so a one-sided bound cannot pass. - for (const flags of [ - ["--adults", "0"], - ["--adults", "10"], - ["--children", "-1"], - ["--children", "9"], - ["--infants", "-1"], - ["--infants", "9"], - ]) { - const code = await run( - wego("flights", "share", "SIN", "BKK", "2026-09-15", ...flags), - runDeps(io, config({ api: API }), api), - ); - expect(code, flags.join(" ")).toBe(2); - } - expect(hits).toBe(0); - }); - - it("share: more infants than adults is rejected client-side, resolved defaults included", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let hits = 0; - const api = faresApi(["tok-1"], { capture: () => (hits += 1) }); - const io = sink(); - - // The second case omits --adults: the API still resolves it to 1, so the - // cross-field rule has to read the defaulted count, not just the flags. - for (const flags of [ - ["--adults", "1", "--infants", "2"], - ["--infants", "2"], - ]) { - const code = await run( - wego("flights", "share", "SIN", "BKK", "2026-09-15", ...flags), - runDeps(io, config({ api: API }), api), - ); - expect(code, flags.join(" ")).toBe(2); - } - expect(hits).toBe(0); - }); - - it("share: an unknown --cabin is rejected client-side, costing no request", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let hits = 0; - const api = faresApi(["tok-1"], { capture: () => (hits += 1) }); - const io = sink(); - - const code = await run( - wego("flights", "share", "SIN", "BKK", "2026-09-15", "--cabin", "coach"), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(2); - expect(io.err.join("")).toContain("--cabin must be one of"); - expect(hits).toBe(0); - }); - - it("share: every published cabin is accepted", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const api = faresApi(["tok-1"]); - const io = sink(); - - for (const cabin of ["economy", "premium_economy", "business", "first"]) { - const code = await run( - wego("flights", "share", "SIN", "BKK", "2026-09-15", "--cabin", cabin), - runDeps(io, config({ api: API }), api), - ); - expect(code, cabin).toBe(0); - } - }); - - it("share: a date that is not a real calendar day is a usage error, costing no request", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let hits = 0; - const api = faresApi(["tok-1"], { capture: () => (hits += 1) }); - const io = sink(); - - // The command's other malformed inputs are all exit 2; a date was the one that - // reached the route and came back as a 400 the caller reads as exit 6. Only the - // SHAPE is checked here - past and beyond-horizon stay the route's call, since - // they depend on the server's clock. - for (const args of [ - ["SIN", "BKK", "15-09-2026"], - ["SIN", "BKK", "2026-02-30"], - ["SIN", "BKK", "2026-09-15", "--return", "2026-13-01"], - ["SIN", "BKK", "2026-09-15", "--return", "nope"], - ]) { - const code = await run( - wego("flights", "share", ...args), - runDeps(io, config({ api: API }), api), - ); - expect(code, args.join(" ")).toBe(2); - } - expect(hits).toBe(0); - }); - - it("share: `--help` prints usage on stdout with exit 0", async () => { - const io = sink(); - const code = await run( - wego("flights", "share", "--help"), - runDeps(io, config({ api: "http://unused.invalid" })), - ); - expect(code).toBe(0); - expect(io.out.join("")).toContain("flights share"); - expect(io.err.join("")).toBe(""); - }); - - it("booking-link: missing required flags → usage error BEFORE any network call", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let hits = 0; - const api = faresApi(["tok-1"], { capture: () => (hits += 1) }); - const io = sink(); - - // No --trip/--from/--to/--date. - const code = await run( - wego("flights", "booking-link", "f_88_1"), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(2); // usage error - expect(io.err.join("")).toMatch(/--trip is required|Usage/); - expect(hits).toBe(0); - }); - - it("booking-link: missing --fare-option → usage error BEFORE any network call", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let hits = 0; - const api = faresApi(["tok-1"], { capture: () => (hits += 1) }); - const io = sink(); - - // Every flag EXCEPT --fare-option: a Book-on-Wego link without a selected - // branded fare dead-ends at "Fare is no longer available", so the CLI must - // refuse client-side rather than emit an uncheckoutable link. - const code = await run( - wego( - "flights", - "booking-link", - "f_88_1", - "--trip", - "trip-1", - "--from", - "SIN", - "--to", - "BKK", - "--date", - "2026-08-01", - ), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(2); // usage error - expect(io.err.join("")).toMatch(/--fare-option is required/); - expect(io.err.join("")).toContain("wego flights fares"); - expect(hits).toBe(0); // refused before the booking-link request - }); - - it("booking-link: a non-calendar --date, an over-cap pax count and infants above adults are usage errors", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let hits = 0; - const api = faresApi(["tok-1"], { capture: () => (hits += 1) }); - const io = sink(); - - // This command shared nothing with its siblings, so it carried NO upper pax - // cap, no date-shape check, and no `infants <= adults` check at all - all three - // now come from `applyFlightPax` and `parseIsoDate`, the one source `share` and - // `search` read. - for (const extra of [ - ["--date", "2026-02-30"], - ["--date", "01-08-2026"], - ["--date", "2026-08-01", "--adults", "10"], - ["--date", "2026-08-01", "--children", "9"], - ["--date", "2026-08-01", "--infants", "9"], - ["--date", "2026-08-01", "--adults", "1", "--infants", "2"], - ["--date", "2026-08-01", "--return", "2026-13-01"], - ]) { - const code = await run( - wego( - "flights", - "booking-link", - "f_88_1", - "--trip", - "trip-1", - "--fare-option", - "uuid-1", - "--from", - "SIN", - "--to", - "BKK", - ...extra, - ), - runDeps(io, config({ api: API }), api), - ); - expect(code, extra.join(" ")).toBe(2); - } - expect(hits).toBe(0); - }); - - it("booking-link: accepts --children 0 and --infants 0 (no-child/no-infant search)", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let seen: { fareId: string; query: Record } | undefined; - const api = faresApi(["tok-1"], { capture: (call) => (seen = call) }); - const io = sink(); - - const code = await run( - wego( - "flights", - "booking-link", - "f_88_1", - "--trip", - "trip-1", - "--fare-option", - "uuid-1", - "--from", - "SIN", - "--to", - "BKK", - "--date", - "2026-08-01", - "--children", - "0", - "--infants", - "0", - ), - runDeps(io, config({ api: API }), api), - ); - - expect(code).toBe(0); - expect(String(seen?.query.children)).toBe("0"); - expect(String(seen?.query.infants)).toBe("0"); - }); - - // --- the per-leg handoff (#1254) ------------------------------------------ - - it("booking-link: --fare-option repeats and comma lists both send one id per leg", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const forms = [ - ["--fare-option", "uuid-1", "--fare-option", "uuid-2"], - ["--fare-option", "uuid-1,uuid-2"], - ]; - for (const form of forms) { - let seen: { fareId: string; query: Record } | undefined; - const api = faresApi(["tok-1"], { capture: (call) => (seen = call) }); - const code = await run( - wego( - "flights", - "booking-link", - "f_88_1", - "--trip", - "trip-1", - ...form, - "--from", - "SIN", - "--to", - "BKK", - "--date", - "2026-08-01", - ), - runDeps(sink(), config({ api: API }), api), - ); - expect(code, form.join(" ")).toBe(0); - expect(String(seen?.query.fareOptionId), form.join(" ")).toBe( - "uuid-1,uuid-2", - ); - } - }); - - it("booking-link: a repeated fare option is a usage error before any network call", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let hits = 0; - const api = faresApi(["tok-1"], { capture: () => (hits += 1) }); - const io = sink(); - - const code = await run( - wego( - "flights", - "booking-link", - "f_88_1", - "--trip", - "trip-1", - "--fare-option", - "uuid-1", - "--fare-option", - "uuid-1", - "--from", - "SIN", - "--to", - "BKK", - "--date", - "2026-08-01", - ), - runDeps(io, config({ api: API }), api), - ); - expect(code).toBe(2); - expect(io.err.join("")).toMatch(/must not repeat a fare option id/); - expect(hits).toBe(0); - }); - - it("booking-link: a blank fare option id is a usage error, never silently dropped", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - // `splitCsv` drops empty parts, so these would otherwise under-send as one id. - const forms = [ - ["--fare-option", "uuid-1,,uuid-2"], - ["--fare-option", "uuid-1,"], - ["--fare-option", ",uuid-1"], - ["--fare-option", " "], - ["--fare-option=uuid-1,,uuid-2"], - ["--fare-option", "uuid-1", "--fare-option", " "], - ]; - for (const form of forms) { - let hits = 0; - const api = faresApi(["tok-1"], { capture: () => (hits += 1) }); - const io = sink(); - const code = await run( - wego( - "flights", - "booking-link", - "f_88_1", - "--trip", - "trip-1", - ...form, - "--from", - "SIN", - "--to", - "BKK", - "--date", - "2026-08-01", - ), - runDeps(io, config({ api: API }), api), - ); - expect(code, form.join(" ")).toBe(2); - expect(io.err.join(""), form.join(" ")).toMatch(/blank fare option id/); - expect(hits, form.join(" ")).toBe(0); - } - }); - - it("booking-link: an absent --fare-option still reports required, not blank", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const io = sink(); - const code = await run( - wego( - "flights", - "booking-link", - "f_88_1", - "--trip", - "trip-1", - "--from", - "SIN", - "--to", - "BKK", - "--date", - "2026-08-01", - ), - runDeps(io, config({ api: API }), faresApi(["tok-1"])), - ); - expect(code).toBe(2); - expect(io.err.join("")).toMatch(/--fare-option is required/); - }); - - it("unknown/missing subcommand → usage error", async () => { - const io = sink(); - expect(await run(wego("flights", "nope"), runDeps(io, config()))).toBe(2); - expect(io.err.join("")).toMatch(/Unknown flights sub-command/); - - const io2 = sink(); - expect(await run(wego("flights"), runDeps(io2, config()))).toBe(2); - expect(io2.err.join("")).toMatch(/Usage/); - }); }); - // --- flights results filter flags (issue #1117) ------------------------------ // // TEST-FIRST reproduction of the CLI gap: the departure-time / alliance / @@ -3901,102 +455,6 @@ describe("flights results – CLI<->OpenAPI parity guardrail (issue #1117)", () expect(undocumented).toEqual([]); }); }); - -// --- flights help: -h/--help/help short-circuit (issue #1119) -------------- -// -// Before the fix, every level below the root treated `--help`/`-h` as an -// unknown sub-command/option: usage went to STDERR with exit 1 (an "Unknown -// flights sub-command: --help" / "Unknown option: --help" error), breaking -// help discovery and any automation that treats a nonzero exit as failure. -// These are golden tests: they pin the FIXED behavior (stdout, exit 0, empty -// stderr, no network call) for every flights command node, plus a negative -// case per level proving a genuinely unknown sub-command/option is still a -// real error (stderr, exit 1) — `--help` is special-cased, not silently -// tolerant of anything. -describe("flights help: -h/--help/help short-circuit (issue #1119)", () => { - function flightsDeps(io: ReturnType): FlightsDeps { - return { - log: io.log, - error: io.error, - loadCredentials, - saveCredentials, - refreshTokens, - loadSettings: async () => ({}), - recordAuthFailure: async () => {}, - createFlightSearch, - fetchFlightResults, - fetchFlightTrip, - fetchTripExperience, - fetchFareOptions, - fetchBookingLink, - fetchSearchLink, - // Help short-circuits before any settle, so a no-op sleep suffices. - sleep: () => Promise.resolve(), - }; - } - // An unreachable API host: were the fix to regress and `--help` fall through - // to a real sub-command's network call, these tests would see it as a - // connection failure (wrong exit code/stderr) rather than silently passing — - // that's the "no network dependency" assertion for this matrix. - const noNetwork = () => config({ api: "http://127.0.0.1:1" }); - - for (const help of ["-h", "--help", "help"]) { - it(`flights ${help}: prints the group usage to stdout, exit 0, empty stderr`, async () => { - const io = sink(); - const code = await flights(noNetwork(), [help], flightsDeps(io)); - expect(code).toBe(0); - const printed = io.out.join("\n"); - expect(printed).toMatch(/^Usage: wego flights/); - expect(printed).toMatch(/^ {2}search /m); - expect(printed).toMatch(/^ {2}booking-link /m); - expect(io.err.length).toBe(0); - }); - } - - // Every leaf is exercised with all three help tokens — bare `help`, `-h`, and - // `--help` — so a leaf can never regress to recognizing only the dash forms - // while the group dispatcher accepts bare `help` (the #1119 leaf-level bug). - const leaves = [ - "search", - "results", - "trip", - "experience", - "fares", - "booking-link", - ]; - for (const sub of leaves) { - for (const help of ["help", "--help", "-h"]) { - it(`flights ${sub} ${help}: prints that command's usage to stdout, exit 0, empty stderr, no network call`, async () => { - const io = sink(); - const code = await flights(noNetwork(), [sub, help], flightsDeps(io)); - expect(code).toBe(0); - expect(io.out.join("\n")).toContain(`Usage: wego flights ${sub}`); - expect(io.err.length).toBe(0); - }); - } - } - - it("negative: a genuinely unknown flights sub-command exits 2 (usage) on stderr", async () => { - const io = sink(); - const code = await flights(noNetwork(), ["bogus"], flightsDeps(io)); - expect(code).toBe(2); // usage: bad sub-command, before any network call - expect(io.out.length).toBe(0); - expect(io.err.join("\n")).toMatch(/Unknown flights sub-command: bogus/); - }); - - it("negative: a genuinely unknown leaf option exits 2 (usage) on stderr (not confused with --help)", async () => { - const io = sink(); - const code = await flights( - noNetwork(), - ["results", "--bogus"], - flightsDeps(io), - ); - expect(code).toBe(2); // usage: bad option, before any network call - expect(io.out.length).toBe(0); - expect(io.err.join("\n")).toMatch(/Unknown option: --bogus/); - }); -}); - describe("resolveCliSite", () => { it("prefers an explicit --site over both the setting and the market (source: explicit)", () => { expect(resolveCliSite("SG", "SA", "AE")).toEqual({ @@ -4091,143 +549,3 @@ describe("parseFeedbackArgs", () => { ); }); }); - -describe("feedback command", () => { - const deps = ( - io: ReturnType, - api: ReturnType, - ) => ({ - ...io, - loadCredentials, - saveCredentials, - refreshTokens, - loadSettings: async () => ({}), - recordAuthFailure: async () => {}, - sendFeedback: api, - version: "9.9.9", - }); - - it("sends feedback and prints a confirmation, stamping the CLI version", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - let body: Record | undefined; - const api = feedbackApi(["tok-1"], (b) => { - body = b; - }); - const io = sink(); - - const code = await feedback( - config({ api: API }), - ["--rating", "5", "--category", "flights", "--message", "nice"], - deps(io, api), - ); - - expect(code).toBe(0); - expect(io.out.join("")).toContain("Thanks"); - expect(body).toEqual({ - rating: 5, - category: "flights", - message: "nice", - version: "9.9.9", - }); - }); - - it("prints scoped usage on --help (exit 0)", async () => { - const io = sink(); - const code = await feedback( - config(), - ["--help"], - deps(io, feedbackApi([])), - ); - expect(code).toBe(0); - expect(io.out.join("")).toContain("feedback"); - }); - - it("returns a usage error (exit 2) on a malformed submission", async () => { - const io = sink(); - const code = await feedback( - config(), - ["--rating", "9"], - deps(io, feedbackApi([])), - ); - expect(code).toBe(2); - expect(io.err.join("")).toContain("--rating"); - }); - - it("recovers from a 401 by refreshing once and retrying the submission", async () => { - await saveCredentials(credPath, { - accessToken: "stale", - refreshToken: "rt", - }); - const as = authServer(() => Response.json({ access_token: "fresh" })); - let body: Record | undefined; - const api = feedbackApi(["fresh"], (b) => { - body = b; - }); // "stale" → 401 drives the reactive refresh - const io = sink(); - - const code = await feedback( - config({ as: as.url, api: API }), - ["--rating", "5"], - deps(io, api), - ); - - expect(code).toBe(0); - expect(body).toMatchObject({ rating: 5 }); - // The refreshed access token is persisted; the refresh token is preserved. - expect(await readStored()).toMatchObject({ - accessToken: "fresh", - refreshToken: "rt", - }); - }); - - it("exits 3 (auth) on a persistent 401 with no refresh token", async () => { - await saveCredentials(credPath, { accessToken: "stale" }); - const api = feedbackApi([]); // always 401 - const io = sink(); - - const code = await feedback( - config({ api: API }), - ["--rating", "5"], - deps(io, api), - ); - - expect(code).toBe(3); - expect(io.err.join("")).toBeTruthy(); - }); - - it("exits 3 (auth) when not logged in", async () => { - const io = sink(); - const code = await feedback( - config(), - ["--rating", "5"], - deps(io, feedbackApi([])), - ); - expect(code).toBe(3); - expect(io.err.join("")).toMatch(/login/); - }); - - it("exits 7 (network) when the api host is unreachable", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const io = sink(); - // The api call fails the way `api.ts` reports an unreachable host: a typed - // `ApiUnreachableError`, which is what the command layer classifies. - const unreachable = (() => { - throw new ApiUnreachableError("http://127.0.0.1:1", new Error("refused")); - }) as ReturnType; - const code = await feedback( - config({ api: "http://127.0.0.1:1" }), - ["--rating", "5"], - deps(io, unreachable), - ); - expect(code).toBe(7); - expect(io.err.join("")).toMatch(/reach/i); - }); -}); - -/** An id_token whose `exp` is at `expiredAt`, for the carry-forward rules. */ -function idTokenExpiring(expiredAtMs: number): string { - const payload = Buffer.from( - JSON.stringify({ exp: Math.floor(expiredAtMs / 1000) }), - ).toString("base64url"); - return `h.${payload}.s`; -} diff --git a/src/commands.ts b/src/commands.ts index b065d51..f1bb74a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2061,7 +2061,7 @@ function applyOccupancyFlags( if (locale) body.locale = locale; } -async function settleRates< +export async function settleRates< T extends { searchComplete?: boolean; rates?: unknown[] }, >( read: () => Promise, @@ -2655,14 +2655,14 @@ const ROOMS_FLAGS = new Set([ "--locale", ]); -interface RoomsPlan { +export interface RoomsPlan { hotelId: number; searchId?: string; createBody?: HotelsSearchBody; ratesQuery: WireQueryValues<"getHotelRates">; } -function parseRoomsArgs(args: string[]): RoomsPlan { +export function parseRoomsArgs(args: string[]): RoomsPlan { const { positional, single } = tokenizeFlagSets( args, HOTELS_ROOMS_USAGE, @@ -2708,7 +2708,7 @@ function parseRoomsArgs(args: string[]): RoomsPlan { // the create body: it copies all three, so an explicit flag reaches the create // as well as the rates read and never loses to a stored setting for half the // operation. Do NOT re-copy them here — two reviewers read this function alone - // and reported the flags as dropped. `hotels.test.ts` pins all three. + // and reported the flags as dropped. `integration/hotels.test.ts` pins all three. applyOccupancyFlags(createBody, single); return { hotelId, createBody, ratesQuery }; } diff --git a/src/config-command.test.ts b/src/config-command.test.ts index a9eef4a..3bcd323 100644 --- a/src/config-command.test.ts +++ b/src/config-command.test.ts @@ -1,147 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { - CONFIG_USAGE, - type ConfigCommandDeps, - config, - effectiveSettings, -} from "./config-command"; -import { EXIT } from "./error-report"; -import type { UserSettings } from "./settings"; +import { effectiveSettings } from "./config-command"; -const PATH = "/home/u/.config/wego/settings.json"; - -function makeDeps( - initial: UserSettings = {}, - accountMarket?: string, -): { - deps: ConfigCommandDeps; - out: string[]; - err: string[]; - stored: () => UserSettings; -} { - const out: string[] = []; - const err: string[] = []; - let stored: UserSettings = initial; - return { - out, - err, - stored: () => stored, - deps: { - log: (m) => out.push(m), - error: (m) => err.push(m), - settingsPath: PATH, - loadSettings: async () => stored, - saveSettings: async (next) => { - stored = next; - }, - accountMarket: async () => accountMarket, - }, - }; -} - -const json = (out: string[]) => - JSON.parse(out[out.length - 1] ?? "{}") as Record< - string, - { value: string | null; source: string } | string - >; - -describe("wego config list", () => { - it("prints every value with the layer that decided it, plus the path", async () => { - const { deps, out } = makeDeps({ currency: "SAR" }, "SG"); - expect(await config(["list"], deps)).toBe(EXIT.OK); - expect(json(out)).toEqual({ - currency: { value: "SAR", source: "setting" }, - site: { value: "SG", source: "account" }, - locale: { value: null, source: "default" }, - path: PATH, - }); - }); - - it("defaults to `list` when no subcommand is given", async () => { - const { deps, out } = makeDeps(); - expect(await config([], deps)).toBe(EXIT.OK); - expect(json(out).path).toBe(PATH); - }); - - it("reports source `setting` for a site that overrides the account market", async () => { - // The decision in issue #1386: an explicit setting beats the id_token market. - const { deps, out } = makeDeps({ site: "SA" }, "SG"); - expect(await config(["list"], deps)).toBe(EXIT.OK); - expect(json(out).site).toEqual({ value: "SA", source: "setting" }); - }); - - it("reports `default` for site when logged out and nothing is stored", async () => { - const { deps, out } = makeDeps({}, undefined); - expect(await config(["list"], deps)).toBe(EXIT.OK); - expect(json(out).site).toEqual({ value: null, source: "default" }); - }); - - it("prints usage on --help, on stdout, exit 0", async () => { - const { deps, out } = makeDeps(); - expect(await config(["--help"], deps)).toBe(EXIT.OK); - expect(out[0]).toBe(CONFIG_USAGE); - }); - - it("rejects a stray argument as a usage error", async () => { - const { deps, err } = makeDeps(); - expect(await config(["list", "extra"], deps)).toBe(EXIT.USAGE); - expect(err.join("\n")).toContain("Unexpected argument: extra"); - }); -}); - -describe("wego config set", () => { - it("stores a normalized value and prints the new effective config", async () => { - const { deps, out, stored } = makeDeps(); - expect(await config(["set", "currency", "sar"], deps)).toBe(EXIT.OK); - expect(stored()).toEqual({ currency: "SAR" }); - expect(json(out).currency).toEqual({ value: "SAR", source: "setting" }); - }); - - it("keeps the other keys (read-modify-write, not a one-key rewrite)", async () => { - const { deps, stored } = makeDeps({ locale: "ar" }); - expect(await config(["set", "site", "SA"], deps)).toBe(EXIT.OK); - expect(stored()).toEqual({ locale: "ar", site: "SA" }); - }); - - it("rejects a value the API would reject, writing nothing", async () => { - const { deps, err, stored } = makeDeps(); - expect(await config(["set", "currency", "riyal"], deps)).toBe(EXIT.USAGE); - expect(err.join("\n")).toContain("ISO 4217"); - expect(stored()).toEqual({}); - }); - - it("rejects an unknown setting name", async () => { - const { deps, err } = makeDeps(); - expect(await config(["set", "cabin", "business"], deps)).toBe(EXIT.USAGE); - expect(err.join("\n")).toContain("Unknown setting: cabin"); - }); - - it("needs a value", async () => { - const { deps, err } = makeDeps(); - expect(await config(["set", "currency"], deps)).toBe(EXIT.USAGE); - expect(err.join("\n")).toContain("needs a value"); - }); -}); - -describe("wego config unset", () => { - it("drops one key and leaves the rest", async () => { - const { deps, stored } = makeDeps({ currency: "SAR", site: "SA" }); - expect(await config(["unset", "currency"], deps)).toBe(EXIT.OK); - expect(stored()).toEqual({ site: "SA" }); - }); - - it("falls back to the account market once the site setting is gone", async () => { - const { deps, out } = makeDeps({ site: "SA" }, "SG"); - expect(await config(["unset", "site"], deps)).toBe(EXIT.OK); - expect(json(out).site).toEqual({ value: "SG", source: "account" }); - }); - - it("is a no-op on a key that was never set", async () => { - const { deps, stored } = makeDeps({}); - expect(await config(["unset", "locale"], deps)).toBe(EXIT.OK); - expect(stored()).toEqual({}); - }); -}); +/** + * The precedence `wego config` reports, which is pure. What the command prints, + * stores and rejects is `integration/config.test.ts`, which drives the compiled + * binary against its own settings file. + */ describe("effectiveSettings", () => { it("gives site the only middle rung, because only a client knows the market", () => { @@ -151,18 +15,12 @@ describe("effectiveSettings", () => { locale: { value: null, source: "default" }, }); }); -}); - -describe("wego config (bad input)", () => { - it("rejects an unknown subcommand with usage", async () => { - const { deps, err } = makeDeps(); - expect(await config(["show"], deps)).toBe(EXIT.USAGE); - expect(err.join("\n")).toContain("Unknown subcommand: show"); - }); - it("calls an unknown flag an option, not a subcommand", async () => { - const { deps, err } = makeDeps(); - expect(await config(["--all"], deps)).toBe(EXIT.USAGE); - expect(err.join("\n")).toContain("Unknown option: --all"); + it("lets a stored value beat the account market, and names it", () => { + expect(effectiveSettings({ site: "SA", currency: "SAR" }, "SG")).toEqual({ + currency: { value: "SAR", source: "setting" }, + site: { value: "SA", source: "setting" }, + locale: { value: null, source: "default" }, + }); }); }); diff --git a/src/hotels.test.ts b/src/hotels.test.ts index 38f08d2..9a81381 100644 --- a/src/hotels.test.ts +++ b/src/hotels.test.ts @@ -1,2778 +1,193 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { ApiHttpError, UnauthorizedError } from "./api"; -import { type HotelsDeps, hotels } from "./commands"; -import type { CliConfig } from "./config"; -import { EXIT } from "./error-report"; -import { refreshTokens } from "./oauth"; -import type { UserSettings } from "./settings"; -import { loadCredentials, saveCredentials } from "./storage"; -import { loadTestCliConfig } from "./test-config"; +import { describe, expect, it } from "bun:test"; +import { parseRoomsArgs, settleRates } from "./commands"; +import { HOTELS } from "./verticals"; /** - * Behavioral tests for `wego hotels …` (issues #1041 + #1042). The API is a - * local HTTP server (real network boundary); credentials are a real on-disk - * seed. Assertions are on what a user/agent observes: exit code, stdout JSON, - * stderr hints, and the requests the CLI actually makes (incl. the settle). + * The pure pieces behind `wego hotels …`: the `/rates` settle loop, the `rooms` + * argument parser, and the stderr note an empty results page earns. What a caller + * sees from the binary (exit codes, stdout JSON, stderr, what reaches the wire) is + * `integration/hotels.test.ts`. */ -const TOKEN = "tok-1"; const RATE_ID = "sid-1:hotels.wego.com:85481:abc123:7"; -type Stub = { url: string; stop: () => void }; -const running: Stub[] = []; - -/** No socket is opened for `apps/api` any more, so the base URL only has to be the - * value the deps receive. */ -const API = "https://api.wego.test"; - -/** - * One call the CLI made into an `apps/api` dep (#1341). - * - * This suite used to drive a hand-written `Bun.serve` stand-in for `apps/api` and - * assert on the HTTP requests it received. That fake could only fail when it - * disagreed with itself — the defect #1328 exists to remove — and #1341 owns - * collapsing it. The five api calls `hotels` already takes as deps are stubbed - * instead, so the suite opens no socket and records what the CLI actually passed. - * - * `body` stays a JSON string: it is the create body the CLI built, serialized, so - * the assertions that read it as text still read the thing they always did. - */ -interface Recorded { - fn: - | "create" - | "results" - | "details" - | "rates" - | "reviews" - | "bookingLink" - | "searchLink"; - token: string; - body?: string; - searchId?: string; - hotelId?: number; - rateId?: string; - query?: Record; -} - -interface ApiOpts { - /** Snapshot per results read, so a test drives the settle. */ - resultsFor?: (n: number) => unknown; - /** Snapshot per rates read. */ - ratesFor?: (n: number) => unknown; - /** Fail every results read with this status, as the API would. */ - resultsStatus?: number; - /** Fail every rates read with this status. */ - ratesStatus?: number; - /** The machine `code` on that failure, where a command branches on it. */ - ratesCode?: string; - /** - * Reject a call with `UnauthorizedError` — what `api.ts` raises on a 401 — so a - * test can expire a token mid-settle and watch `withAccessToken` refresh and - * retry. `reads` counts results reads so far, matching the old fake's counter. - */ - unauthorized?: (call: { - fn: string; - token: string; - reads: number; - }) => boolean; - /** Fail every reviews read with this status. */ - reviewsStatus?: number; -} - -/** The echo contract 0.6.0 (#1522) put on every priced read, as the API really - * answers it: `explicit` for anything the request carried, a stored currency - * included. Both `*Source` copies are what the CLI strips at print time - * (#1400 for `localeSource`, #1534 for the rest); the echoes stay. */ -const API_PRICED_ECHO = { - currencyCode: "USD", - currencyCodeSource: "explicit", - locale: "en", - localeSource: "explicit", -} as const; - -const DEFAULT_RESULTS = { - searchId: "sid-1", - searchComplete: true, - results: [{ hotelId: 1, name: "Grand Hyatt" }], - metadata: { - page: 1, - pageSize: 10, - resultCount: 1, - totalCandidates: 1, - hasMore: false, - ...API_PRICED_ECHO, - }, -}; -const DEFAULT_RATES = { - hotelId: 85481, - searchId: "sid-1", - searchComplete: true, - rates: [{ id: RATE_ID, roomName: "Classic", refundable: false }], - metadata: { ...API_PRICED_ECHO }, -}; -const DEFAULT_DETAIL = { hotelId: 85481, name: "Grand Hyatt", star: 5 }; -const DEFAULT_REVIEWS = { - hotelId: 85481, - results: [ - { - rating: 9.2, - postedAt: "2026-06-14", - providerCode: "booking.com", - guestType: "couple", - pros: ["Breakfast spread was huge"], - cons: [], - }, - ], - metadata: { - page: 1, - pageSize: 10, - resultCount: 1, - totalCandidates: 49, - hasMore: true, - topics: ["breakfast"], - matchedTerms: ["breakfast", "Breakfast"], - }, -}; -const DEFAULT_BOOKING_LINK = { - bookingUrl: "https://www.wego.com/hotels/booking/checkout?search_id=sid-1", -}; -const DEFAULT_SEARCH_LINK = { - searchUrl: - "https://www.wego.com/hotels/searches/bkk/2099-09-15/2099-09-17?guests=2&ulang=en", - expires: false as const, -}; - -/** - * An api that fails the test if any call reaches it. - * - * Replaces the old "point the config at an unreachable host" trick: a `--help` or - * usage path that regressed into a real sub-command used to surface as a connection - * failure, which is indirect. Now it names the call that should not have happened. - */ -function unreachableApi(): ReturnType["api"] { - const boom = (fn: string) => (): never => { - throw new Error( - `${fn} must not be called: a usage or --help path reached the API`, - ); - }; - return { - createHotelSearch: boom("createHotelSearch"), - fetchHotelResults: boom("fetchHotelResults"), - fetchHotelDetails: boom("fetchHotelDetails"), - fetchHotelRates: boom("fetchHotelRates"), - fetchHotelReviews: boom("fetchHotelReviews"), - fetchHotelBookingLink: boom("fetchHotelBookingLink"), - fetchHotelSearchLink: boom("fetchHotelSearchLink"), - } as unknown as ReturnType["api"]; -} - -/** The seven hotels api deps, programmable per read and recording every call. */ -function hotelsApiStubs(opts: ApiOpts = {}): { - api: Pick< - HotelsDeps, - | "createHotelSearch" - | "fetchHotelResults" - | "fetchHotelDetails" - | "fetchHotelRates" - | "fetchHotelReviews" - | "fetchHotelBookingLink" - | "fetchHotelSearchLink" - >; - recorded: Recorded[]; -} { - const recorded: Recorded[] = []; - const counts = { results: 0, rates: 0 }; - - /** Record, then apply the 401 rule the old fake expressed with a status code. */ - const enter = (call: Recorded): void => { - recorded.push(call); - if ( - opts.unauthorized?.({ - fn: call.fn, - token: call.token, - reads: counts.results, - }) - ) { - throw new UnauthorizedError(); - } - }; - - // Every stub is `async`, so an injected failure REJECTS the way the real - // `api.ts` function does. A synchronous throw would skip the per-call - // `.catch(translateNotFound(...))` the commands hang off the promise, and a - // 404 would surface as a raw fault instead of the friendly exit-4 hint. - return { - recorded, - api: { - createHotelSearch: async (_base, token, body) => { - enter({ fn: "create", token, body: JSON.stringify(body) }); - // The create echoes the priced occupancy (resolved child ages) the API - // returns since #1114, so the CLI's surfacing of it is exercised, and - // the market the search was created for (#1386), which is what `rooms` - // reports as `siteCode`/`siteCodeSource`. - const sent = (body as { siteCode?: string }).siteCode; - return { - searchId: "sid-1", - occupancy: { adults: 2, childrenAges: [11], rooms: 1 }, - ...(sent === undefined ? {} : { siteCode: sent }), - } as Awaited>; - }, - fetchHotelResults: async (_base, token, searchId, query) => { - counts.results += 1; - enter({ fn: "results", token, searchId, query }); - if (opts.resultsStatus) { - throw new ApiHttpError( - opts.resultsStatus, - "GET /v1/hotels/searches/:searchId/results", - ); - } - return (opts.resultsFor?.(counts.results) ?? - DEFAULT_RESULTS) as Awaited< - ReturnType - >; - }, - fetchHotelDetails: async (_base, token, hotelId, query) => { - enter({ fn: "details", token, hotelId, query }); - return DEFAULT_DETAIL as Awaited< - ReturnType - >; - }, - fetchHotelRates: async (_base, token, hotelId, query) => { - counts.rates += 1; - enter({ fn: "rates", token, hotelId, query }); - if (opts.ratesStatus) { - throw new ApiHttpError( - opts.ratesStatus, - "GET /v1/hotels/:hotelId/rates", - opts.ratesCode === undefined ? {} : { code: opts.ratesCode }, - ); - } - return (opts.ratesFor?.(counts.rates) ?? DEFAULT_RATES) as Awaited< - ReturnType - >; - }, - fetchHotelReviews: async (_base, token, hotelId, query) => { - enter({ fn: "reviews", token, hotelId, query }); - if (opts.reviewsStatus) { - throw new ApiHttpError( - opts.reviewsStatus, - "GET /v1/hotels/:id/reviews", - ); - } - return DEFAULT_REVIEWS as Awaited< - ReturnType - >; - }, - fetchHotelBookingLink: async (_base, token, hotelId, rateId, query) => { - enter({ fn: "bookingLink", token, hotelId, rateId, query }); - return DEFAULT_BOOKING_LINK; - }, - fetchHotelSearchLink: async (_base, token, query) => { - enter({ fn: "searchLink", token, query }); - return DEFAULT_SEARCH_LINK; - }, - }, - }; -} - -let dir: string; -let credPath: string; - -beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), "wego-hotels-")); - credPath = join(dir, "credentials.json"); - await saveCredentials(credPath, { - accessToken: TOKEN, - expiresAt: Date.now() + 3_600_000, - }); -}); -afterEach(async () => { - while (running.length) running.pop()?.stop(); - await rm(dir, { recursive: true, force: true }); -}); - -const sink = () => { - const out: string[] = []; - const err: string[] = []; - return { - out, - err, - log: (m: string) => out.push(m), - error: (m: string) => err.push(m), - }; -}; - -function config(apiUrl: string, asUrl?: string): CliConfig { - return loadTestCliConfig({ - WEGO_CLI_CLIENT_ID: "cli-abc", - WEGO_API_URL: apiUrl, - WEGO_CREDENTIALS_PATH: credPath, - // When an auth-server URL is given, point the token/authorize endpoints at - // it so a reactive-401 refresh actually round-trips (the flights suite wires - // this the same way; without it the CLI has no live token endpoint to hit). - ...(asUrl - ? { - WEGO_AUTH_AUTHORIZE_URL: `${asUrl}/authorize`, - WEGO_AUTH_TOKEN_URL: `${asUrl}/token`, - } - : {}), - }); -} - -/** A bare local server (auth-aware handlers build their own responses). Pushed - * to `running` so `afterEach` stops it. */ -function serve(handler: (req: Request) => Response | Promise): Stub { - const s = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: handler }); - const stub = { url: `http://127.0.0.1:${s.port}`, stop: () => s.stop(true) }; - running.push(stub); - return stub; -} - -/** A fake authorization server: only the `/token` endpoint the refresh calls. */ -function authServer(token: () => Response): Stub { - return serve((req) => { - const url = new URL(req.url); - if (url.pathname === "/token" && req.method === "POST") return token(); - return new Response("not found", { status: 404 }); - }); -} - -function deps( - io: ReturnType, - api: ReturnType["api"], - // Stored travel preferences (issue #1386); none by default, which is the - // fresh-machine state every pre-existing test here was written against. - settings: UserSettings = {}, -): HotelsDeps { - return { - log: io.log, - error: io.error, - loadCredentials, - saveCredentials, - refreshTokens, - loadSettings: async () => settings, - recordAuthFailure: async () => {}, - ...api, - sleep: () => Promise.resolve(), - }; -} - -const lastJson = (io: ReturnType) => - JSON.parse(io.out.at(-1) ?? "null"); - -describe("wego hotels search", () => { - it("creates the search then prints the first page (stdout is valid JSON)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(0); - const post = recorded.find((r) => r.fn === "create"); - expect(post?.body).toContain("DXB"); - const printed = lastJson(io); - expect(printed.results[0].name).toBe("Grand Hyatt"); - // The one stderr line on a fresh machine is the currency-setting hint the - // search prints while no currency is stored (issue #1386). - expect(io.err).toEqual([expect.stringContaining("config set currency")]); - }); - - it("names the layer the CURRENCY came from, over all three rungs", async () => { - // Issue #1400, the hotels half of the same stamp `flights search` makes. The - // create and the settle read must carry the SAME resolved currency: that is - // the invariant the old merge-before-create protected, and resolving the - // currency inside the vertical must not drop it. - const rung = async (settings: UserSettings, extraArgs: string[]) => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05", ...extraArgs], - deps(io, api, settings), - ); - expect(code).toBe(0); - const body = JSON.parse( - recorded.find((r) => r.fn === "create")?.body ?? "{}", - ) as { currency?: string }; - return { - source: lastJson(io).currencyCodeSource, - created: body.currency, - read: ( - recorded.find((r) => r.fn === "results")?.query as - | { currency?: string } - | undefined - )?.currency, - }; - }; - - expect(await rung({ currency: "SAR" }, ["--currency", "USD"])).toEqual({ - source: "explicit", - created: "USD", - read: "USD", - }); - expect(await rung({ currency: "SAR" }, [])).toEqual({ - source: "setting", - created: "SAR", - read: "SAR", - }); - // Neither rung supplies one, so the request carries no currency and the API's - // USD default decides — which the label reports rather than leaving implied. - expect(await rung({}, [])).toEqual({ - source: "default", - created: undefined, - read: undefined, - }); - }); - - it("results and both rooms forms name the rung too, not only search", async () => { - // A `results` page re-prices, and `rooms` is where a room rate is quoted, so - // each decides its own unit and each owes the label (#1400). `rooms` reports - // it on BOTH forms - unlike the market, which only the minting form decides. - const sourceOf = async (argv: string[], settings: UserSettings) => { - const { api } = hotelsApiStubs(); - const io = sink(); - expect(await hotels(config(API), argv, deps(io, api, settings))).toBe(0); - return lastJson(io).currencyCodeSource; - }; - - expect(await sourceOf(["results", "sid-1"], { currency: "SAR" })).toBe( - "setting", - ); - expect( - await sourceOf(["results", "sid-1", "--currency", "USD"], { - currency: "SAR", - }), - ).toBe("explicit"); - expect(await sourceOf(["results", "sid-1"], {})).toBe("default"); - - // The --search form reuses a search and mints nothing. - expect( - await sourceOf(["rooms", "85481", "--search", "sid-1"], { - currency: "SAR", - }), - ).toBe("setting"); - // The scoped form mints its own search. - expect( - await sourceOf( - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - ], - { currency: "SAR" }, - ), - ).toBe("setting"); - }); - - it("the minted rooms search and its rates read go out in ONE currency", async () => { - // The invariant `applyPreferences` held by filling both from the same file. - // Resolving the rung moved that decision earlier, so it is pinned here: a - // create in SAR feeding a rates read in USD prices the room twice. - const { api, recorded } = hotelsApiStubs(); - expect( - await hotels( - config(API), - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - ], - deps(sink(), api, { currency: "SAR" }), - ), - ).toBe(0); - const created = JSON.parse( - recorded.find((r) => r.fn === "create")?.body ?? "{}", - ) as { currency?: string }; - const rates = recorded.find((r) => r.fn === "rates")?.query as - | { currency?: string } - | undefined; - expect(created.currency).toBe("SAR"); - expect(rates?.currency).toBe("SAR"); - }); - - it("every priced read prints ONE *Source per knob, top level, CLI vocabulary (hotels four of the eight)", async () => { - // The #1534 rule (decision Q2: "strip"), hotels half — the flights half is - // pinned in `commands.test.ts`. The API's request-scoped copies inside - // `metadata` are stripped at print time, so the CLI's own top-level label - // (`setting` here, a rung the API cannot see) is the ONE `*Source` a - // payload carries per knob. The `currencyCode` / `locale` echoes stay. - for (const argv of [ - ["search", "DXB", "2099-03-01", "2099-03-05"], - ["results", "sid-1"], - ["rooms", "85481", "--search", "sid-1"], - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - ], - ]) { - const { api } = hotelsApiStubs(); - const io = sink(); - expect( - await hotels(config(API), argv, deps(io, api, { currency: "SAR" })), - ).toBe(0); - const which = argv.join(" "); - const printed = io.out.at(-1) ?? ""; - expect(printed, which).not.toContain("localeSource"); - expect(printed, which).toContain('"locale": "en"'); - const parsed = JSON.parse(printed) as { - currencyCodeSource: string; - metadata?: Record; - }; - expect(parsed.currencyCodeSource, which).toBe("setting"); - expect(printed.split('"currencyCodeSource"').length - 1, which).toBe(1); - expect( - Object.keys(parsed.metadata ?? {}).filter((k) => k.endsWith("Source")), - which, - ).toEqual([]); - } - }); - - it("accepts --children 0 (forwards an explicit zero, not a positive-int error)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05", "--children", "0"], - deps(io, api), - ); - expect(code).toBe(0); - const post = recorded.find((r) => r.fn === "create"); - expect(post?.body).toContain('"children":0'); - // The one stderr line on a fresh machine is the currency-setting hint the - // search prints while no currency is stored (issue #1386). - expect(io.err).toEqual([expect.stringContaining("config set currency")]); - }); - - it("settles: re-reads while empty, stops when results appear", async () => { - const { api, recorded } = hotelsApiStubs({ - resultsFor: (n) => - n < 2 - ? { - searchId: "sid-1", - searchComplete: false, - results: [], - metadata: {}, - } - : { - searchId: "sid-1", - searchComplete: true, - results: [{ hotelId: 1, name: "Later Hotel" }], - metadata: {}, - }, - }); - const io = sink(); - await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - const resultReads = recorded.filter((r) => r.fn === "results").length; - expect(resultReads).toBe(2); - expect(lastJson(io).results[0].name).toBe("Later Hotel"); - }); - - it("converges on snapshotCandidateCount stabilizing even while searchComplete stays false (issue #1084)", async () => { - // The core #1113/#1084 fix: `searchComplete` stays false for most of a - // search's life, but the candidate count stabilizes far sooner. The settle - // must key off the count, not wait out `searchComplete`. - const { api, recorded } = hotelsApiStubs({ - resultsFor: (n) => ({ - searchId: "sid-1", - searchComplete: false, // never flips – the settle must not depend on it - results: [{ hotelId: 1, name: "Hotel A" }], - // count grows on read 1, then holds equal on reads 2 & 3 → converged. - metadata: { snapshotCandidateCount: n === 1 ? 4 : 7 }, - }), - }); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(0); - // read1 count=4, read2 count=7, read3 count=7 (== read2) → stop at read 3. - const resultReads = recorded.filter((r) => r.fn === "results").length; - expect(resultReads).toBe(3); - expect(lastJson(io).settled).toBe("converged"); - expect(lastJson(io).searchComplete).toBe(false); - }); - - it("stamps an honest `settled` marker on the search snapshot (issue #1084)", async () => { - // DEFAULT_RESULTS is searchComplete:true → converged on the first read. - const { api } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(0); - expect(lastJson(io).settled).toBe("converged"); - }); - - it("stops early on searchComplete even with zero results", async () => { - const { api, recorded } = hotelsApiStubs({ - resultsFor: () => ({ - searchId: "sid-1", - searchComplete: true, - results: [], - metadata: {}, - }), - }); - const io = sink(); - await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(recorded.filter((r) => r.fn === "results").length).toBe(1); - // searchComplete:true + empty is definitive, so the "keep polling" hint is - // suppressed (would otherwise mislead agents/users into re-running). - expect(io.err.join("")).not.toContain("No hotels have settled yet"); - }); - - it("prints a 'not settled yet' stderr hint when the page stays empty", async () => { - // Settle exhausts with an empty, still-aggregating snapshot (searchComplete - // false): exit 0 + JSON on stdout, but a re-poll hint on stderr so an agent - // reading only exit-code + stdout doesn't treat it as a definitive result. - const { api, recorded } = hotelsApiStubs({ - resultsFor: () => ({ - searchId: "sid-1", - searchComplete: false, - results: [], - metadata: {}, - }), - }); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(0); - expect(io.err.join("")).toContain( - "No hotels have settled yet – re-run: wego hotels results sid-1 --wait", - ); - }); - - it("reports an authoritative no-match (not a 'still settling' hint) on a completed empty search", async () => { - // searchComplete:true AND totalCandidates:0 is a definitive no-match — the - // "re-run, still settling" hint would contradict it (issue #1113 review). - const { api } = hotelsApiStubs({ - resultsFor: () => ({ - searchId: "sid-1", - searchComplete: true, - results: [], - metadata: { totalCandidates: 0 }, - }), - }); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(0); - const err = io.err.join(""); - expect(err).toContain("no hotels match"); - expect(err).not.toContain("No hotels have settled yet"); - }); - - it("says the FILTERS emptied it, not that no hotels exist", async () => { - const { api } = hotelsApiStubs({ - resultsFor: () => ({ - searchId: "sid-1", - searchComplete: true, - results: [], - metadata: { totalCandidates: 0, totalBeforeFilters: 415 }, - }), - }); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(0); - const err = io.err.join(""); - expect(err).toContain("none of the 415 hotels found match these filters"); - expect(err).toContain("the filters excluded them"); - }); - - it("claims no bookable inventory only when nothing survived the join", async () => { - const { api } = hotelsApiStubs({ - resultsFor: () => ({ - searchId: "sid-1", - searchComplete: true, - results: [], - metadata: { totalCandidates: 0, totalBeforeFilters: 0 }, - }), - }); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(0); - expect(io.err.join("")).toContain( - "no Book-on-Wego bookable hotels surfaced", - ); - }); - - it("prints no still-settling hint on a completed empty PAGE over existing candidates (paged past the end)", async () => { - // searchComplete:true but totalCandidates>0 → an empty page is pagination, - // not a no-match and not still-settling, so neither hint fires. - const { api } = hotelsApiStubs({ - resultsFor: () => ({ - searchId: "sid-1", - searchComplete: true, - results: [], - metadata: { totalCandidates: 12 }, - }), - }); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(0); - const err = io.err.join(""); - expect(err).not.toContain("No hotels have settled yet"); - expect(err).not.toContain("no hotels match"); - }); - - it("stays indeterminate (no no-match claim) on a completed empty search whose count is MISSING or malformed", async () => { - // A completed response with totalCandidates omitted (legacy API) or invalid - // must NOT be reported as a zero-candidate no-match (issue #1113 review): - // no `?? 0` fallback, and a fractional/negative count degrades to undefined. - for (const metadata of [ - {}, // count omitted - { totalCandidates: -1 }, // negative → degrades to undefined via .catch - { totalCandidates: 2.5 }, // fractional → degrades to undefined via .catch - ]) { - const { api } = hotelsApiStubs({ - resultsFor: () => ({ - searchId: "sid-1", - searchComplete: true, - results: [], - metadata, - }), - }); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(0); - // Indeterminate: neither an authoritative no-match nor a false no-match. - expect(io.err.join("")).not.toContain("no hotels match"); - } - }); - - it("degrades a malformed snapshotCandidateCount to undefined (settle never trusts it as a count)", async () => { - // Parity with the totalCandidates test above: the convergence signal - // (metadata.snapshotCandidateCount, issue #1084) is `.int().nonnegative() - // .optional().catch(undefined)`, so a negative, fractional, non-numeric, or - // null value degrades to undefined → the settle falls back to item-presence, - // never converging on a bogus count and never throwing on a non-numeric value. - // The `.catch(undefined)` that drops a malformed count lives in `api.ts`'s - // response schema, so it is asserted there (`api.test.ts` → "drops a malformed - // snapshotCandidateCount"). What belongs here is the consequence: with no count - // to trust, the settle falls back to item-presence and still converges. - const { api } = hotelsApiStubs({ - resultsFor: () => ({ - searchId: "sid-1", - searchComplete: false, - results: [{ hotelId: 1, name: "Grand Hyatt" }], - metadata: {}, - }), - }); - const io = sink(); - const code = await hotels( - config(API), - ["results", "sid-1", "--wait"], - deps(io, api), - ); - expect(code).toBe(0); - expect(lastJson(io).metadata.snapshotCandidateCount).toBeUndefined(); - expect(lastJson(io).settled).toBe("converged"); - }); - - it("preserves the searchId with a re-poll hint when the settle read fails", async () => { - // Create succeeds (201 + searchId) but the immediate results read 5xxs; - // the id must survive as a `wego hotels results ` re-run hint. - const { api, recorded } = hotelsApiStubs({ resultsStatus: 503 }); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(5); // retryable upstream failure (503) - expect(recorded.some((r) => r.fn === "create")).toBe(true); - expect(io.err.join("")).toContain("re-run: wego hotels results sid-1"); - }); - - it("rejects a bad location before any network call", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["search", "not-a-place", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(2); // usage error (bad location, no network) - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("Invalid location"); - }); -}); - -describe("wego hotels results", () => { - it("re-polls with paging/sort forwarded", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "results", - "sid-1", - "--page", - "2", - "--sort", - "price_asc", - "--min-star", - "4", - ], - deps(io, api), - ); - expect(code).toBe(0); - const read = recorded.find((r) => r.fn === "results"); - expect(read?.query?.page).toBe("2"); - expect(read?.query?.sort).toBe("price_asc"); - expect(read?.query?.["min-star"]).toBe("4"); - }); - - it("forwards --refundable as the refundable filter param (issue #1115)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["results", "sid-1", "--refundable", "true"], - deps(io, api), - ); - expect(code).toBe(0); - const read = recorded.find((r) => r.fn === "results"); - expect(read?.query?.refundable).toBe("true"); - }); - - // The expired-search 404 moved to `hotels-e2e.test.ts` (issue #1340): there it - // runs against the 404 the upstream really sends for a stale searchId, recorded, - // rather than against a status this fake chose to return. - - it("without --wait: a single read (no client-side settle)", async () => { - const { api, recorded } = hotelsApiStubs({ - // Even if the first page is empty+incomplete, a plain read does NOT re-poll. - resultsFor: () => ({ - searchId: "sid-1", - searchComplete: false, - results: [], - metadata: {}, - }), - }); - const io = sink(); - const code = await hotels(config(API), ["results", "sid-1"], deps(io, api)); - expect(code).toBe(0); - expect(recorded.filter((r) => r.fn === "results").length).toBe(1); - // A bare read is a single, un-waited snapshot → stamped `unsettled` so an - // empty page is never mistaken for a definitive no-results (issue #1084). - expect(lastJson(io).settled).toBe("unsettled"); - }); - - it("--wait re-reads while empty, stops when results appear (CLI-5 symmetry)", async () => { - const { api, recorded } = hotelsApiStubs({ - resultsFor: (n) => - n < 2 - ? { - searchId: "sid-1", - searchComplete: false, - results: [], - metadata: {}, - } - : { - searchId: "sid-1", - searchComplete: true, - results: [{ hotelId: 1, name: "Later Hotel" }], - metadata: {}, - }, - }); - const io = sink(); - const code = await hotels( - config(API), - ["results", "sid-1", "--wait"], - deps(io, api), - ); - expect(code).toBe(0); - expect(recorded.filter((r) => r.fn === "results").length).toBe(2); - expect(lastJson(io).results[0].name).toBe("Later Hotel"); - expect(io.err.length).toBe(0); - }); - - it("--wait: a 401 mid-settle refreshes once and restarts the whole poll (parity with flights)", async () => { - // Mirror of the flights `results --wait` 401-mid-settle test: the whole - // hotels settle runs inside ONE withAccessToken call, which retries its - // entire callback once on a 401. So a token expiry partway through the - // count-convergence re-reads restarts the poll from read #1 against the same - // searchId — correctness-safe (reads are idempotent), it just re-walks the - // sequence on the fresh token. - await saveCredentials(credPath, { - accessToken: "tok-1", - refreshToken: "rt", - expiresAt: Date.now() + 3_600_000, // valid → only a reactive 401 refreshes - }); - const as = authServer(() => Response.json({ access_token: "fresh" })); - const { api, recorded } = hotelsApiStubs({ - // 0 on the first read, then 2 — so the count can only hold equal across two - // reads after the restart. - resultsFor: (n) => ({ - searchId: "sid-1", - searchComplete: false, // never flips; the settle keys on the count - results: [{ hotelId: 1, name: "Grand Hyatt" }], - metadata: { snapshotCandidateCount: n === 1 ? 0 : 2 }, - }), - // "tok-1" expires on its third read, mid-settle. `api.ts` raises - // `UnauthorizedError` on a 401, which is what the dep does here. - unauthorized: ({ fn, token, reads }) => - fn === "results" && token === "tok-1" && reads >= 3, - }); - const io = sink(); - const code = await hotels( - config(API, as.url), - ["results", "sid-1", "--wait"], - deps(io, api), - ); - expect(code).toBe(0); - // The restarted poll converges once the count holds equal on "fresh". - expect(lastJson(io).settled).toBe("converged"); - expect(lastJson(io).metadata.snapshotCandidateCount).toBe(2); - const reads = recorded.filter((r) => r.fn === "results"); - // Reads before the 401 count toward the total: the restart re-walks read #1. - expect(reads.length).toBeGreaterThan(3); - // And the reads that converged are the ones on the refreshed token — the old - // HTTP fake could only show that the request succeeded, not which token carried - // it, because the header never reached an assertion. - expect( - reads.filter((r) => r.token === "fresh").length, - ).toBeGreaterThanOrEqual(2); - }); - - it("--wait prints a 'not settled yet' stderr hint when the page stays empty (stdout stays JSON)", async () => { - const { api, recorded } = hotelsApiStubs({ - resultsFor: () => ({ - searchId: "sid-1", - searchComplete: false, - results: [], - metadata: {}, - }), - }); - const io = sink(); - const code = await hotels( - config(API), - ["results", "sid-1", "--wait"], - deps(io, api), - ); - expect(code).toBe(0); - // stdout is still a single parseable JSON object, honestly stamped as a - // spent-budget settle so an empty page is not read as a definitive result. - expect(lastJson(io).results).toEqual([]); - expect(lastJson(io).settled).toBe("budget_exhausted"); - expect(io.err.join("")).toContain( - "No hotels have settled yet – re-run: wego hotels results sid-1 --wait", - ); - }); - - it("--wait suppresses the re-run hint on a completed empty snapshot (searchComplete:true is definitive)", async () => { - // searchComplete:true with a steady count converges, so the empty page is definitive - const { api, recorded } = hotelsApiStubs({ - resultsFor: () => ({ - searchId: "sid-1", - searchComplete: true, - results: [], - metadata: {}, - }), - }); - const io = sink(); - const code = await hotels( - config(API), - ["results", "sid-1", "--wait"], - deps(io, api), - ); - expect(code).toBe(0); - expect(lastJson(io).results).toEqual([]); - expect(io.err.join("")).not.toContain("No hotels have settled yet"); - }); - - it("rejects --wait=value (the bool flag takes no value, exit 2)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["results", "sid-1", "--wait=1"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("--wait takes no value"); - }); - - // Client-side guards mirroring flights: the same typo that flights catches - // locally (exit 2) must not round-trip to the API's 400 → exit 6 here (CLI-1). - it("rejects a non-numeric --page locally (exit 2, no network call)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["results", "sid-1", "--page", "abc"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("--page must be a positive integer"); - }); - - it("rejects an out-of-range --page-size locally (exit 2, mirrors API max 50)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["results", "sid-1", "--page-size", "500"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("--page-size must be between 1 and 50"); - }); - - it("rejects an unknown --sort value locally (exit 2, no network call)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["results", "sid-1", "--sort", "cheapest"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("--sort must be one of"); - }); - - // foundations#87, raised in review: the guest-cohort flags had no local - // coverage, so the sort the help advertises and the validation that guards the - // cohort were both unpinned. - it("forwards --sort guest_rating_desc with --guest-type as both query keys", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "results", - "sid-1", - "--sort", - "guest_rating_desc", - "--guest-type", - "family", - "--min-guest-rating", - "8.5", - ], - deps(io, api), - ); - expect(code).toBe(0); - const q = recorded[0]?.query; - // The sort is registered locally as well as published - it used to be - // advertised in the help while HOTEL_SORTS rejected it before any call. - expect(q?.sort).toBe("guest_rating_desc"); - expect(q?.["guest-type"]).toBe("family"); - expect(q?.["min-guest-rating"]).toBe("8.5"); - }); - - it("rejects an unknown --guest-type locally (exit 2, no network call)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - // The /reviews spelling: valid on that command, not on this one. - ["results", "sid-1", "--guest-type", "family_with_children"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("--guest-type must be one of"); - }); - - it("rejects a --min-guest-rating outside 0-10, and a non-numeric one (exit 2)", async () => { - // A bare `--min-guest-rating` with no value is a different guard entirely - // ("requires a value"), so it is not in this list - these are the shapes that - // LOOK like a value and are not one. - for (const bad of ["abc", "-1", "11", "Infinity"]) { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "results", - "sid-1", - "--guest-type", - "family", - "--min-guest-rating", - bad, - ], - deps(io, api), - ); - expect(code).toBe(2); - // The point of the local check: no request is spent to be told this. - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain( - "--min-guest-rating must be a number between 0 and 10", - ); - } - }); - - it("rejects --view at all: the results read has one projection (issue #1308)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - // `card` was the one legal value before #1308 - the flag itself is gone now, - // so the once-valid value is rejected too. - ["results", "sid-1", "--view", "card"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("Unknown option: --view"); - }); - - it("rejects a non-boolean --refundable locally (exit 2, no network call)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["results", "sid-1", "--refundable", "yes"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("--refundable must be one of"); - }); - - it("rejects an extra positional argument locally (exit 2, no network call)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["results", "sid-1", "extra"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("Unexpected argument: extra"); - }); -}); - -describe("wego hotels details", () => { - // The happy-path detail read and the unknown-hotel 404 both live in - // `hotels-e2e.test.ts` (issues #1340, #1341), against the real recorded body. - - it("rejects an unknown --view value locally (exit 2, prints the message – no swallow, CLI-3)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["details", "85481", "--view", "summary"], - deps(io, api), - ); - expect(code).toBe(2); // usage: default|detail guarded before the network call - expect(recorded.length).toBe(0); - // The parse error is now printed (was swallowed to bare usage before CLI-3). - expect(io.err.join("")).toContain("--view must be one of"); - }); - - it("surfaces a bad hotelId's explanation instead of swallowing it to bare usage (CLI-3)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["details", "not-a-number"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("hotelId must be a positive integer"); - }); - - it("rejects an extra positional argument locally (exit 2, no network call)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["details", "85481", "extra"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("Unexpected argument: extra"); - }); - - it("reports the extra-positional error before an invalid id (structural error wins, matches hotels results)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["details", "abc", "extra"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - // The too-many-args error is surfaced, not masked by the bad-hotelId error. - expect(io.err.join("")).toContain("Unexpected argument: extra"); - expect(io.err.join("")).not.toContain("hotelId must be a positive integer"); - }); -}); - -describe("wego hotels reviews", () => { - it("prints the review page (stdout is valid JSON)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels(config(API), ["reviews", "85481"], deps(io, api)); - expect(code).toBe(0); - expect(recorded[0]?.fn).toBe("reviews"); - expect(recorded[0]?.hotelId).toBe(85481); - expect(lastJson(io).metadata.totalCandidates).toBe(49); - expect(io.err.join("")).toBe(""); - }); - - it("forwards each flag under its published parameter name", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "reviews", - "85481", - "--topics", - "breakfast,pool", - "--guest-type", - "couple", - "--sort", - "rating_desc", - "--page-size", - "20", - "--view", - "detail", - ], - deps(io, api), - ); - expect(code).toBe(0); - const q = recorded[0]?.query; - expect(q?.topics).toBe("breakfast,pool"); - // Kebab for the net-new knob, camel for the mirrored one - one request - // legitimately carries both spellings. - expect(q?.["guest-type"]).toBe("couple"); - expect(q?.pageSize).toBe("20"); - expect(q?.sort).toBe("rating_desc"); - expect(q?.view).toBe("detail"); - }); - - it("rejects an empty --topics locally, before any request (exit 2)", async () => { - // The API splits this on commas and needs one non-empty term, so `,` reached - // it as an empty list and 400'd - exit 6 with the round trip already paid, - // where every sibling flag exits 2 locally. - for (const value of [",", " ", ",,"]) { - const io = sink(); - const code = await hotels( - config(API), - ["reviews", "85481", "--topics", value], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); - expect(io.err.join("")).toContain("--topics needs at least one"); - } - }); - - it("trims the topics it forwards, so a stray comma costs no request", async () => { - const { api, recorded } = hotelsApiStubs(); - const code = await hotels( - config(API), - ["reviews", "85481", "--topics", " breakfast , ,pool "], - deps(sink(), api), - ); - expect(code).toBe(0); - expect(recorded[0]?.query?.topics).toBe("breakfast,pool"); - }); - - it("rejects a bad enum locally, before any request (exit 2)", async () => { - for (const args of [ - ["--sort", "newest"], - ["--guest-type", "business"], - ["--view", "full"], - ]) { - const io = sink(); - const code = await hotels( - config(API), - ["reviews", "85481", ...args], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); - expect(io.err.join("")).toContain(`${args[0]} must be one of`); - } - }); - - it("rejects an out-of-range --page-size rather than clamping it", async () => { - const io = sink(); - const code = await hotels( - config(API), - ["reviews", "85481", "--page-size", "500"], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); - }); - - it("prints an unknown-hotel hint on 404 (exit 4 not_found)", async () => { - const { api } = hotelsApiStubs({ reviewsStatus: 404 }); - const io = sink(); - const code = await hotels( - config(API), - ["reviews", "999999"], - deps(io, api), - ); - expect(code).toBe(4); - expect(io.err.join("")).toContain("Unknown hotel id"); - }); - - it("prints its own scoped usage on --help (exit 0, stdout)", async () => { - const io = sink(); - const code = await hotels( - config(API), - ["reviews", "--help"], - deps(io, unreachableApi()), - ); - expect(code).toBe(0); - expect(io.out.join("")).toContain("hotels reviews "); - }); - - it("surfaces a bad hotelId's explanation rather than bare usage", async () => { - const io = sink(); - const code = await hotels( - config(API), - ["reviews", "not-a-number"], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); - expect(io.err.join("")).toContain("hotelId must be a positive integer"); - }); -}); - -describe("wego hotels rooms", () => { - it("with --search: settles the rates read (no create)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "--search", "sid-1"], - deps(io, api), - ); - expect(code).toBe(0); - expect(recorded.some((r) => r.fn === "create")).toBe(false); - expect(recorded.filter((r) => r.fn === "rates").length).toBe(4); - expect(lastJson(io).rates[0].id).toBe(RATE_ID); - expect(lastJson(io).settled).toBe("converged"); - }); - - it("keeps reading while the rate count grows, even past searchComplete:true", async () => { - const page = (rates: unknown[]) => ({ - hotelId: 85481, - searchId: "sid-1", - searchComplete: true, - rates, - }); - const one = [{ id: "r-1" }]; - const three = [{ id: "r-1" }, { id: "r-2" }, { id: RATE_ID }]; - const { api, recorded } = hotelsApiStubs({ - ratesFor: (n) => page(n === 1 ? one : three), - }); - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "--search", "sid-1"], - deps(io, api), - ); - expect(code).toBe(0); - // 1 growing read + 4 steady reads at the full depth. - expect(recorded.filter((r) => r.fn === "rates").length).toBe(5); - expect(lastJson(io).rates.length).toBe(3); - expect(lastJson(io).settled).toBe("converged"); - }); - - it("takes the dates as positionals, the same shape as `hotels search`", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "2099-03-01", "2099-03-05", "--adults", "3"], - deps(io, api), - ); - expect(code).toBe(0); - const post = recorded.find((r) => r.fn === "create"); - expect(JSON.parse(post?.body ?? "{}")).toMatchObject({ - hotelId: 85481, - checkIn: "2099-03-01", - checkOut: "2099-03-05", - adults: 3, - }); - expect(recorded.some((r) => r.fn === "rates")).toBe(true); - }); - - it("mints the identical create from the positional and the flag spelling", async () => { - const bodyFor = async (args: string[]): Promise => { - const { api, recorded } = hotelsApiStubs(); - expect(await hotels(config(API), args, deps(sink(), api))).toBe(0); - return JSON.parse( - recorded.find((r) => r.fn === "create")?.body ?? "null", - ); - }; - expect( - await bodyFor(["rooms", "85481", "2099-03-01", "2099-03-05"]), - ).toEqual( - await bodyFor([ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - ]), - ); - }); - - it("rejects the dates given twice, positionally AND as flags (exit 2)", async () => { - const io = sink(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "2099-03-01", - "2099-03-05", - "--check-in", - "2099-04-01", - "--check-out", - "2099-04-05", - ], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); - expect(io.err.join("")).toContain("not both"); - }); - - it("rejects one positional date on its own (exit 2)", async () => { - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "2099-03-01"], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); - expect(io.err.join("")).toContain(" "); - }); - - it("rejects the positional dates alongside --search, naming them (exit 2)", async () => { - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "2099-03-01", "2099-03-05", "--search", "sid-1"], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); - const err = io.err.join(""); - expect(err).toContain("not both"); - expect(err).toContain("positional dates"); - }); - - it("exits non-zero with the re-run command when the API refuses a city search", async () => { - // The 409 the API answers when `--search` names a city search: nothing to - // retry, so the one stderr line has to name the command that fixes it. - const { api, recorded } = hotelsApiStubs({ - ratesStatus: 409, - ratesCode: "rates_require_hotel_search", - }); - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "--search", "city-1"], - deps(io, api), - ); - expect(code).toBe(EXIT.PERMANENT); - // One read: the settle never retries a scope the search cannot change. - expect(recorded.filter((r) => r.fn === "rates").length).toBe(1); - const err = io.err.join(""); - expect(err).toContain("this search is not hotel-scoped"); - expect(err).toContain("hotels rooms "); - }); - - it("cautions on a converged EMPTY rate list, and still exits 0", async () => { - // A zero here is this search's answer, never the hotel's: a fresh mint can - // differ, so the caution is what stops it being quoted as "no rooms". - const { api } = hotelsApiStubs({ - ratesFor: () => ({ - hotelId: 85481, - searchId: "sid-1", - searchComplete: true, - rates: [], - }), - }); - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(0); - expect(lastJson(io).settled).toBe("converged"); - const err = io.err.join(""); - expect(err).toContain("not proof the hotel has no rooms"); - expect(err).toContain("hotels rooms 85481 "); - }); - - it("prints no empty caution when the converged list carries rates", async () => { - const { api } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "2099-03-01", "2099-03-05"], - deps(io, api), - ); - expect(code).toBe(0); - expect(io.err.join("")).not.toContain("no rooms"); - }); - - it("without --search: mints a hotel-scoped search then reads rates", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - ], - deps(io, api), - ); - expect(code).toBe(0); - const post = recorded.find((r) => r.fn === "create"); - expect(post?.body).toContain("85481"); - expect(recorded.some((r) => r.fn === "rates")).toBe(true); - }); - - it("errors when neither --search nor dates are given", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels(config(API), ["rooms", "85481"], deps(io, api)); - expect(code).toBe(2); // usage error (neither --search nor dates) - expect(recorded.length).toBe(0); - }); - - it("errors when BOTH forms are given (exit 2, no network call)", async () => { - // `--search` used to win silently at exit 0, spending a metered rates call. - const io = sink(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--search", - "sid-1", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - ], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); - const err = io.err.join(""); - expect(err).toContain("not both"); - expect(err).toContain("--check-in"); - expect(err).toContain("--check-out"); - }); - - it("names only the conflicting flags actually passed", async () => { - // Built from the argv, so it points at what the caller wrote. - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "--search", "sid-1", "--adults", "3"], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); - const err = io.err.join(""); - expect(err).toContain("--adults"); - expect(err).not.toContain("--check-in,"); - }); - - it.each([ - "--check-in", - "--check-out", - "--adults", - "--children", - "--rooms", - ])("rejects %s alongside --search", async (flag) => { - const code = await hotels( - config(API), - ["rooms", "85481", "--search", "sid-1", flag, "1"], - deps(sink(), unreachableApi()), - ); - expect(code).toBe(2); - }); - - it("rejects --children-ages alongside --search before the --children pairing check", async () => { - // Else the caller is sent to add `--children`, a second flag that also dies. - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "--search", "sid-1", "--children-ages", "5"], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); - const err = io.err.join(""); - expect(err).toContain("not both"); - expect(err).not.toContain("requires --children"); - }); - - it("keeps --currency and --locale legal on the --search form", async () => { - // Both price or translate the read itself, so neither belongs to a branch. - const { api, recorded } = hotelsApiStubs(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--search", - "sid-1", - "--currency", - "AED", - "--locale", - "ar", - ], - deps(sink(), api), - ); - expect(code).toBe(0); - expect(recorded.some((r) => r.fn === "create")).toBe(false); - expect(recorded.find((r) => r.fn === "rates")?.query).toMatchObject({ - currency: "AED", - locale: "ar", - searchId: "sid-1", - }); - }); - - it("rejects an extra positional argument locally (exit 2, no network call)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - // A fourth positional is rejected even when the leaf would otherwise - // succeed, proving the guard fires before the network. - const code = await hotels( - config(API), - ["rooms", "85481", "2099-03-01", "2099-03-05", "extra"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("Unexpected argument: extra"); - }); +describe("settleRates", () => { + /** A scripted rates read (the last snapshot repeats) that counts its reads and + * the spacing it was asked to sleep. */ + function reads( + snapshot: (n: number) => { + searchComplete?: boolean; + rates?: unknown[]; + }, + ) { + let n = 0; + const sleeps: number[] = []; + return { + read: () => Promise.resolve(snapshot(++n)), + sleep: (ms: number) => { + sleeps.push(ms); + return Promise.resolve(); + }, + count: () => n, + sleeps, + }; + } - it("preserves the minted searchId with a re-poll hint when the rates read fails", async () => { - // No --search: a hotel-scoped search is minted (201 + sid-1), but the rates - // settle 5xxs; the minted id must survive as a `--search` re-run hint. - const { api, recorded } = hotelsApiStubs({ ratesStatus: 503 }); - const io = sink(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - ], - deps(io, api), - ); - expect(code).toBe(5); // retryable upstream failure (503) - expect(recorded.some((r) => r.fn === "create")).toBe(true); - expect(io.err.join("")).toContain( - "re-run: wego hotels rooms 85481 --search sid-1", - ); + it("converges once a non-empty rate count holds for four reads, 1.5 s apart", async () => { + const r = reads(() => ({ searchComplete: true, rates: [{ id: RATE_ID }] })); + const { state, snapshot } = await settleRates(r.read, r.sleep); + expect(state).toBe("converged"); + expect(snapshot.rates).toEqual([{ id: RATE_ID }]); + expect(r.count()).toBe(4); + expect(r.sleeps).toEqual([1500, 1500, 1500]); }); - it("prints a re-poll hint when the settle budget runs out still empty", async () => { - const { api, recorded } = hotelsApiStubs({ - ratesFor: () => ({ - hotelId: 85481, - searchId: "sid-1", - searchComplete: false, - rates: [], - }), - }); - const io = sink(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - ], - deps(io, api), - ); - expect(code).toBe(0); - expect(recorded.filter((r) => r.fn === "rates").length).toBe(10); - expect(lastJson(io).settled).toBe("budget_exhausted"); - expect(io.err.join("")).toContain( - "Rates were still aggregating – re-run: wego hotels rooms 85481 --search sid-1", - ); + it("keeps reading while the rate count grows, even past searchComplete:true", async () => { + const one = [{ id: "r-1" }]; + const three = [{ id: "r-1" }, { id: "r-2" }, { id: RATE_ID }]; + const r = reads((n) => ({ + searchComplete: true, + rates: n === 1 ? one : three, + })); + const { state, snapshot } = await settleRates(r.read, r.sleep); + // 1 growing read + 4 steady reads at the full depth. + expect(r.count()).toBe(5); + expect(snapshot.rates).toHaveLength(3); + expect(state).toBe("converged"); }); it("an empty page with searchComplete:true twice converges as the definitive no-rates", async () => { - const { api, recorded } = hotelsApiStubs({ - ratesFor: () => ({ - hotelId: 85481, - searchId: "sid-1", - searchComplete: true, - rates: [], - }), - }); - const io = sink(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - ], - deps(io, api), - ); - expect(code).toBe(0); - expect(recorded.filter((r) => r.fn === "rates").length).toBe(2); - expect(lastJson(io).settled).toBe("converged"); - expect(io.err.join("")).not.toContain("re-run: wego hotels rooms"); + const r = reads(() => ({ searchComplete: true, rates: [] })); + const { state } = await settleRates(r.read, r.sleep); + expect(r.count()).toBe(2); + expect(state).toBe("converged"); }); it("a slow starter is not declared empty: rates landing late still converge", async () => { - const empty = { hotelId: 85481, searchId: "sid-1", searchComplete: false }; - const { api, recorded } = hotelsApiStubs({ - ratesFor: (n) => - n <= 3 - ? { ...empty, rates: [] } - : { ...empty, rates: [{ id: RATE_ID }] }, - }); - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "--search", "sid-1"], - deps(io, api), - ); - expect(code).toBe(0); + const r = reads((n) => ({ + searchComplete: false, + rates: n <= 3 ? [] : [{ id: RATE_ID }], + })); + const { state, snapshot } = await settleRates(r.read, r.sleep); // 3 empty reads + 4 steady non-empty reads. - expect(recorded.filter((r) => r.fn === "rates").length).toBe(7); - expect(lastJson(io).rates.length).toBe(1); - expect(lastJson(io).settled).toBe("converged"); - }); - - it("does not print a re-poll hint when --search was supplied and rates fail", async () => { - const { api, recorded } = hotelsApiStubs({ ratesStatus: 503 }); - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "--search", "sid-1"], - deps(io, api), - ); - expect(code).toBe(5); // retryable upstream failure (503) - expect(recorded.some((r) => r.fn === "create")).toBe(false); - expect(io.err.join("")).not.toContain("re-run: wego hotels rooms"); - }); -}); - -describe("wego hotels booking-link", () => { - // The happy-path link mint lives in `hotels-e2e.test.ts`, from a rate id - // harvested out of a real `rooms` read. - - it("requires --rate before any network call", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["booking-link", "85481"], - deps(io, api), - ); - expect(code).toBe(2); // usage error (missing --rate) - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("--rate"); - }); - - it("rejects an extra positional argument locally (exit 2, no network call)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - // Valid --rate supplied so the leaf would otherwise proceed; the extra - // positional must still be rejected before any network work. - const code = await hotels( - config(API), - ["booking-link", "85481", "extra", "--rate", "r1"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("Unexpected argument: extra"); - }); -}); - -describe("wego hotels share", () => { - it("forwards the city, dates and occupancy, and prints the durable link", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "BKK", "2099-09-15", "2099-09-17", "--adults", "2"], - deps(io, api), - ); - expect(code).toBe(0); - const link = recorded.find((r) => r.fn === "searchLink"); - expect(link?.query).toMatchObject({ - cityCode: "BKK", - checkIn: "2099-09-15", - checkOut: "2099-09-17", - adults: "2", - }); - expect(io.out.join("")).toContain("/hotels/searches/bkk/"); - }); - - it("sends --rooms, the same occupancy vocabulary as hotels search", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "share", - "BKK", - "2099-09-15", - "2099-09-17", - "--adults", - "4", - "--rooms", - "2", - ], - deps(io, api), - ); - expect(code).toBe(0); - expect(recorded.find((r) => r.fn === "searchLink")?.query).toMatchObject({ - adults: "4", - rooms: "2", - }); - }); - - it("refuses more rooms than adults locally, naming the default", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "BKK", "2099-09-15", "2099-09-17", "--rooms", "3"], - deps(io, api), - ); - expect(code).toBe(EXIT.USAGE); - expect(recorded).toHaveLength(0); - expect(io.err.join("")).toContain("the default when --adults is omitted"); - }); - - it("rejects a non-numeric --rooms before any call", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "BKK", "2099-09-15", "2099-09-17", "--rooms", "two"], - deps(io, api), - ); - expect(code).toBe(EXIT.USAGE); - expect(recorded).toHaveLength(0); - }); - - it("packs --children-ages into a CSV the API parses", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "share", - "BKK", - "2099-09-15", - "2099-09-17", - "--children", - "2", - "--children-ages", - "5,9", - ], - deps(io, api), - ); - expect(code).toBe(0); - expect(recorded.find((r) => r.fn === "searchLink")?.query).toMatchObject({ - children: "2", - childrenAges: "5,9", - }); - }); - - it("refuses a hotelId locally, naming the city code as the way through", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "710862", "2099-09-15", "2099-09-17"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("city code"); - }); - - it("refuses lat,lng locally, for the same reason", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "13.75,100.5", "2099-09-15", "2099-09-17"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - }); - - it("refuses --children without ages, so no guessed age reaches the link", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "BKK", "2099-09-15", "2099-09-17", "--children", "1"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("--children-ages"); - }); - - it("names both counts when --children-ages disagrees with --children", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "share", - "BKK", - "2099-09-15", - "2099-09-17", - "--children", - "2", - "--children-ages", - "5", - ], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("1 age(s) but --children is 2"); - }); - - it("inherits the stored currency and locale, which the link hands on", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "BKK", "2099-09-15", "2099-09-17"], - deps(io, api, { currency: "SAR", locale: "ar" }), - ); - expect(code).toBe(0); - expect(recorded.find((r) => r.fn === "searchLink")?.query).toMatchObject({ - currency: "SAR", - locale: "ar", - }); - }); - - it("rejects an extra positional argument before any network call", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "BKK", "2099-09-15", "2099-09-17", "extra"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("Unexpected argument: extra"); + expect(r.count()).toBe(7); + expect(snapshot.rates).toHaveLength(1); + expect(state).toBe("converged"); }); - it("refuses a bare city name with the city-code message, not locationFields'", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "bangkok", "2099-09-15", "2099-09-17"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("city code"); - expect(io.err.join("")).not.toContain("hotelId"); - }); - - it("inherits the stored site, the rung no other share test covers", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "BKK", "2099-09-15", "2099-09-17"], - deps(io, api, { site: "SA" }), - ); - expect(code).toBe(0); - expect(recorded.find((r) => r.fn === "searchLink")?.query).toMatchObject({ - siteCode: "SA", - }); - }); - - it("resolves an explicit --site over the stored one", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "BKK", "2099-09-15", "2099-09-17", "--site", "AE"], - deps(io, api, { site: "SA" }), - ); - expect(code).toBe(0); - expect(recorded.find((r) => r.fn === "searchLink")?.query).toMatchObject({ - siteCode: "AE", - }); - }); - - it("leaves siteCode absent when no rung resolves one", async () => { - // Only `setWireQuery`'s undefined-skip keeps the key off the wire. - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["share", "BKK", "2099-09-15", "2099-09-17"], - deps(io, api), - ); - expect(code).toBe(0); - expect( - recorded.find((r) => r.fn === "searchLink")?.query?.siteCode, - ).toBeUndefined(); - }); - - it("prints its own usage on --help without calling the API", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels(config(API), ["share", "--help"], deps(io, api)); - expect(code).toBe(0); - expect(recorded.length).toBe(0); - expect(io.out.join("")).toContain("hotels share"); + it("spends the ten-read budget on a page that stays empty and incomplete", async () => { + const r = reads(() => ({ searchComplete: false, rates: [] })); + const { state } = await settleRates(r.read, r.sleep); + expect(r.count()).toBe(10); + expect(state).toBe("budget_exhausted"); }); }); -describe("wego hotels – child ages (issue #1114)", () => { - it("search forwards --children-ages into the create body", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "search", - "DXB", - "2099-03-01", - "2099-03-05", - "--children", - "1", - "--children-ages", - "11", - ], - deps(io, api), - ); - expect(code).toBe(0); - const post = recorded.find((r) => r.fn === "create"); - expect(JSON.parse(post?.body ?? "{}").childrenAges).toEqual([11]); - // The one stderr line on a fresh machine is the currency-setting hint the - // search prints while no currency is stored (issue #1386). - expect(io.err).toEqual([expect.stringContaining("config set currency")]); - }); - - it("rooms forwards --children-ages into the minted create body", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - "--children", - "2", - "--children-ages", - "0,17", - ], - deps(io, api), - ); - expect(code).toBe(0); - const post = recorded.find((r) => r.fn === "create"); - expect(JSON.parse(post?.body ?? "{}").childrenAges).toEqual([0, 17]); - }); - - it("rooms (scoped form): the minted create carries the stored currency AND market", async () => { - const { api, recorded } = hotelsApiStubs(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - ], - deps(sink(), api, { currency: "SAR", site: "SA", locale: "ar" }), - ); - expect(code).toBe(0); - const post = recorded.find((r) => r.fn === "create"); - const body = JSON.parse(post?.body ?? "{}") as Record; - expect(body.currency).toBe("SAR"); - expect(body.siteCode).toBe("SA"); - expect(recorded.find((r) => r.fn === "rates")?.query).toMatchObject({ - currency: "SAR", - }); - }); - - it("rooms (scoped form): an explicit --currency reaches the create too, not just the rates read", async () => { - // The flag must win for the WHOLE operation. Minting the search in the - // stored SAR and then reading its rates in the requested USD would let a - // setting beat a flag for half of one command. - const { api, recorded } = hotelsApiStubs(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - "--currency", - "USD", - "--locale", - "en", - ], - deps(sink(), api, { currency: "SAR", locale: "ar" }), - ); - expect(code).toBe(0); - const post = recorded.find((r) => r.fn === "create"); - const body = JSON.parse(post?.body ?? "{}") as Record; - expect(body.currency).toBe("USD"); - expect(body.locale).toBe("en"); - expect(recorded.find((r) => r.fn === "rates")?.query).toMatchObject({ - currency: "USD", - locale: "en", +describe("parseRoomsArgs", () => { + it("takes the dates as positionals, the same shape as `hotels search`", () => { + const plan = parseRoomsArgs([ + "85481", + "2099-03-01", + "2099-03-05", + "--adults", + "3", + ]); + expect(plan.searchId).toBeUndefined(); + expect(plan.createBody).toMatchObject({ + hotelId: 85481, + checkIn: "2099-03-01", + checkOut: "2099-03-05", + adults: 3, }); }); - it("rooms (scoped form): an explicit --site mints the search in THAT market", async () => { - // `--site` is copied onto the create by `applyOccupancyFlags`, so an - // explicit `AE` beats a stored `SA`. Pinned because `parseRoomsArgs` reads - // as if it drops the flag, and two reviewers reported exactly that. - const { api, recorded } = hotelsApiStubs(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - "--site", - "AE", - ], - deps(sink(), api, { site: "SA" }), - ); - expect(code).toBe(0); - const post = recorded.find((r) => r.fn === "create"); - const body = JSON.parse(post?.body ?? "{}") as Record; - expect(body.siteCode).toBe("AE"); - }); - - it("rooms (scoped form): REPORTS the market it minted in, and which layer decided it", async () => { - // This form mints a search, so a stored `site` can decide the point of sale. - // `resolveRoomsSearchId` resolved that market and then dropped it, so the - // rates printed with no indication of the market they were priced for - the - // silent market decision issue #1386 exists to remove. - const io = sink(); - const code = await hotels( - config(API), - [ - "rooms", + it("mints the identical create from the positional and the flag spelling", () => { + expect(parseRoomsArgs(["85481", "2099-03-01", "2099-03-05"])).toEqual( + parseRoomsArgs([ "85481", "--check-in", "2099-03-01", "--check-out", "2099-03-05", - ], - deps(io, hotelsApiStubs().api, { site: "SA" }), + ]), ); - expect(code).toBe(0); - const printed = JSON.parse(io.out.join("\n")) as Record; - expect(printed.siteCode).toBe("SA"); - expect(printed.siteCodeSource).toBe("setting"); }); - it("rooms (scoped form): the reported source names the layer, not just `explicit`", async () => { - // An explicit flag reads `explicit`; the account market reads `account`. The - // API can only ever say explicit/default, so only the CLI can name these. - const io = sink(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - "--site", - "AE", - ], - deps(io, hotelsApiStubs().api, { site: "SA" }), - ); - expect(code).toBe(0); - const printed = JSON.parse(io.out.join("\n")) as Record; - expect(printed.siteCode).toBe("AE"); - expect(printed.siteCodeSource).toBe("explicit"); + it("carries --children-ages onto the minted create", () => { + const plan = parseRoomsArgs([ + "85481", + "--check-in", + "2099-03-01", + "--check-out", + "2099-03-05", + "--children", + "2", + "--children-ages", + "0,17", + ]); + expect(plan.createBody?.childrenAges).toEqual([0, 17]); }); +}); - it("rooms (--search form): an explicit --site is a usage error — that search fixed the market", async () => { - // Carve-out 3: no create for a market to apply to, and rates take no siteCode. - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "--search", "sid-1", "--site", "AE"], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); - expect(io.err.join("")).toContain("--site"); +describe("HOTELS empty-page note", () => { + type Snapshot = Parameters[0]; + const page = ( + searchComplete: boolean, + metadata: Record, + results: unknown[] = [], + ) => ({ searchId: "sid-1", searchComplete, results, metadata }) as Snapshot; + + it("an incomplete empty page says the search is still settling, and how to wait", () => { + const note = + "No hotels have settled yet – re-run: wego hotels results sid-1 --wait"; + expect(HOTELS.searchNote(page(false, {}), "sid-1")).toBe(note); + // `results` phrases it the same, bare or `--wait`: the note keys off the page. + expect( + HOTELS.resultsNote(page(false, {}), "sid-1", "budget_exhausted", true), + ).toBe(note); }); - it("rooms (--search form): inherits currency but NEVER a market — that search fixed one", async () => { - // Carve-out (issue #1386): the rates belong to an existing search, whose - // market is already decided. Sending a stored `site` here would claim a - // market the rates are not in. - const { api, recorded } = hotelsApiStubs(); - const code = await hotels( - config(API), - ["rooms", "85481", "--search", "sid-1"], - deps(sink(), api, { currency: "SAR", site: "SA" }), - ); - expect(code).toBe(0); - // No create at all on this form, so no market can be applied. - expect(recorded.some((r) => r.fn === "create")).toBe(false); - const rates = recorded.find((r) => r.fn === "rates"); - expect(rates?.query).toMatchObject({ currency: "SAR" }); - expect(rates?.query).not.toHaveProperty("siteCode"); + it("reports an authoritative no-match on a completed zero-candidate search", () => { + const note = HOTELS.searchNote(page(true, { totalCandidates: 0 }), "sid-1"); + expect(note).toContain("no hotels match"); + expect(note).not.toContain("No hotels have settled yet"); }); - it("results: a bare read inherits the stored currency instead of reverting to USD", async () => { - const { api, recorded } = hotelsApiStubs(); - const code = await hotels( - config(API), - ["results", "sid-1"], - deps(sink(), api, { currency: "SAR" }), + it("says the FILTERS emptied it, not that no hotels exist", () => { + const note = HOTELS.searchNote( + page(true, { totalCandidates: 0, totalBeforeFilters: 415 }), + "sid-1", ); - expect(code).toBe(0); - expect(recorded.find((r) => r.fn === "results")?.query).toMatchObject({ - currency: "SAR", - }); + expect(note).toContain("none of the 415 hotels found match these filters"); + expect(note).toContain("the filters excluded them"); }); - it("results: a bare read uses the STORED currency even when the search was created with a flag", async () => { - // The known limit of the file, pinned so it cannot drift into a claim it does - // not make: a `searchId` carries no currency, so a bare read cannot reproduce - // the `--currency` the create used and applies the stored preference instead. - // Documented in docs/settings.md, and the read echoes `currencyCode`, so the - // mismatch is visible rather than inferred. Closing it needs a new precedence - // rung (a per-searchId record), which is a design call for the issue. - const { api, recorded } = hotelsApiStubs(); - const code = await hotels( - config(API), - ["results", "sid-1"], - deps(sink(), api, { currency: "SAR" }), - ); - expect(code).toBe(0); - // No `--currency USD` here, so the SAR setting decides - NOT the USD the - // search behind `sid-1` may have been created with. - expect(recorded.find((r) => r.fn === "results")?.query).toMatchObject({ - currency: "SAR", - }); - // ...and repeating the flag on the read is the documented way to match it. - const second = hotelsApiStubs(); + it("claims no bookable inventory only when nothing survived the join", () => { expect( - await hotels( - config(API), - ["results", "sid-1", "--currency", "USD"], - deps(sink(), second.api, { currency: "SAR" }), + HOTELS.searchNote( + page(true, { totalCandidates: 0, totalBeforeFilters: 0 }), + "sid-1", ), - ).toBe(0); - expect( - second.recorded.find((r) => r.fn === "results")?.query, - ).toMatchObject({ currency: "USD" }); + ).toContain("no Book-on-Wego bookable hotels surfaced"); }); - it("details/reviews inherit the locale and are sent no currency (neither takes one)", async () => { - const { api, recorded } = hotelsApiStubs(); - const settings = { currency: "SAR", locale: "ar", site: "SA" }; - expect( - await hotels( - config(API), - ["details", "85481"], - deps(sink(), api, settings), - ), - ).toBe(0); + it("says nothing on a completed empty PAGE over existing candidates (paged past the end)", () => { expect( - await hotels( - config(API), - ["reviews", "85481"], - deps(sink(), api, settings), - ), - ).toBe(0); - for (const r of recorded.filter( - (x) => x.fn === "details" || x.fn === "reviews", - )) { - expect(r.query).toMatchObject({ locale: "ar" }); - expect(r.query).not.toHaveProperty("currency"); - } - }); - it("rejects a children-ages/children count mismatch before any network call", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "search", - "DXB", - "2099-03-01", - "2099-03-05", - "--children", - "1", - "--children-ages", - "5,11", - ], - deps(io, api), - ); - expect(code).toBe(2); // usage: rejected before any network call - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("must equal --children"); - }); - - it("rejects --children-ages without --children", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05", "--children-ages", "11"], - deps(io, api), - ); - expect(code).toBe(2); // usage: rejected before any network call - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("requires --children"); - }); - - it("rejects an out-of-range age before any network call", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "search", - "DXB", - "2099-03-01", - "2099-03-05", - "--children", - "1", - "--children-ages", - "18", - ], - deps(io, api), - ); - expect(code).toBe(2); // usage: rejected before any network call - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("0–17"); - }); - - it("booking-link rejects every dropped flag before any network call", async () => { - for (const flag of [ - ["--adults", "2"], - ["--children-ages", "11"], - ["--guests", "2:11"], - ]) { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["booking-link", "85481", "--rate", RATE_ID, ...flag], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain(`Unknown option: ${flag[0]}`); - } - }); - - it("booking-link sends no guests, and no countryCode unless asked", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["booking-link", "85481", "--rate", RATE_ID], - deps(io, api), - ); - expect(code).toBe(0); - const link = recorded.find((r) => r.fn === "bookingLink"); - expect(link?.query?.guests).toBeUndefined(); - expect(link?.query?.countryCode).toBeUndefined(); - }); - - it("booking-link uppercases --country, like the info commands", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["booking-link", "85481", "--rate", RATE_ID, "--country", "ae"], - deps(io, api), - ); - expect(code).toBe(0); - const link = recorded.find((r) => r.fn === "bookingLink"); - expect(link?.query?.countryCode).toBe("AE"); - }); - - it("booking-link rejects a malformed --country before any network call", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["booking-link", "85481", "--rate", RATE_ID, "--country", "usa"], - deps(io, api), - ); - expect(code).toBe(2); - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("2-letter ISO country code"); - }); -}); - -describe("wego hotels – occupancy echo surfacing (issue #1114)", () => { - it("search surfaces the priced occupancy the create echoed", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "search", - "DXB", - "2099-03-01", - "2099-03-05", - "--children", - "1", - "--children-ages", - "11", - ], - deps(io, api), - ); - expect(code).toBe(0); - // The resolved ages must reach stdout — a strict schema would have stripped - // them, defeating #1114's audit goal. - expect(lastJson(io).occupancy).toEqual({ - adults: 2, - childrenAges: [11], - rooms: 1, - }); - }); - - it("rooms (minted search) surfaces the priced occupancy", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "rooms", - "85481", - "--check-in", - "2099-03-01", - "--check-out", - "2099-03-05", - "--children", - "1", - "--children-ages", - "11", - ], - deps(io, api), - ); - expect(code).toBe(0); - expect(lastJson(io).occupancy).toEqual({ - adults: 2, - childrenAges: [11], - rooms: 1, - }); - }); - - it("rooms with --search omits occupancy (no create, nothing echoed)", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["rooms", "85481", "--search", "sid-1"], - deps(io, api), - ); - expect(code).toBe(0); - expect(recorded.some((r) => r.fn === "create")).toBe(false); - expect(lastJson(io).occupancy).toBeUndefined(); - }); -}); - -describe("wego hotels – children cap + ages boundaries (issue #1114)", () => { - it("accepts exactly MAX_CHILDREN (8) ages", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "search", - "DXB", - "2099-03-01", - "2099-03-05", - "--children", - "8", - "--children-ages", - "1,2,3,4,5,6,7,8", - ], - deps(io, api), - ); - expect(code).toBe(0); - const post = recorded.find((r) => r.fn === "create"); - expect(JSON.parse(post?.body ?? "{}").childrenAges).toEqual([ - 1, 2, 3, 4, 5, 6, 7, 8, - ]); - }); - - it("rejects 9 children (over MAX_CHILDREN) before any network call", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "search", - "DXB", - "2099-03-01", - "2099-03-05", - "--children", - "9", - "--children-ages", - "1,2,3,4,5,6,7,8,9", - ], - deps(io, api), - ); - expect(code).toBe(2); // usage: rejected before any network call - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("between 0 and 8"); - }); - - it("rejects a non-numeric age token before any network call", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "search", - "DXB", - "2099-03-01", - "2099-03-05", - "--children", - "1", - "--children-ages", - "abc", - ], - deps(io, api), - ); - expect(code).toBe(2); // usage: rejected before any network call - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("0–17"); - }); - - it("rejects a negative age before any network call", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - [ - "search", - "DXB", - "2099-03-01", - "2099-03-05", - "--children", - "1", - "--children-ages", - "-1", - ], - deps(io, api), - ); - expect(code).toBe(2); // usage: rejected before any network call - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("0–17"); - }); - - it("rejects an empty-after-filter ages list before any network call", async () => { - const { api, recorded } = hotelsApiStubs(); - const io = sink(); - const code = await hotels( - config(API), - ["search", "DXB", "2099-03-01", "2099-03-05", "--children-ages", ","], - deps(io, api), - ); - expect(code).toBe(2); // usage: rejected before any network call - expect(recorded.length).toBe(0); - expect(io.err.join("")).toContain("at least one age"); - }); -}); - -describe("wego hotels – dispatch + round trip", () => { - it("prints usage for an unknown sub-command", async () => { - const io = sink(); - const code = await hotels( - config(API), - ["frobnicate"], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); // usage error - expect(io.err.join("")).toContain("Usage: wego hotels"); + HOTELS.searchNote(page(true, { totalCandidates: 12 }), "sid-1"), + ).toBeUndefined(); }); - // The search → details → rooms → booking-link chain moved to - // `hotels-e2e.test.ts` (issue #1340). Driven in-process against this fake it could - // only fail when the fake disagreed with itself; it now runs the real CLI against a - // real `apps/api` serving real recorded upstream data, and additionally asserts - // the two `rooms` forms and the refundability witness — neither of which a fake - // can express, since both are properties of what the upstream actually sends. -}); - -// --- hotels help: -h/--help/help short-circuit (issue #1119) --------------- -// -// Before the fix, `wego hotels --help` (and every leaf sub-command's -// `--help`) fell into the "unknown sub-command"/"unknown option" arm: usage -// printed to STDERR with exit 1. Golden tests below pin the FIXED behavior — -// stdout, exit 0, empty stderr, no network call — for the group and every -// leaf, plus a negative case per level proving a genuinely unknown -// sub-command/option is still a real error. -describe("wego hotels help: -h/--help/help short-circuit (issue #1119)", () => { - // If the fix regressed and `--help` fell through to a real sub-command, the api - // stub throws and names the call, instead of these tests silently passing. - const noNetwork = () => config(API); - - for (const help of ["-h", "--help", "help"]) { - it(`hotels ${help}: prints the group usage to stdout, exit 0, empty stderr`, async () => { - const io = sink(); - const code = await hotels( - noNetwork(), - [help], - deps(io, unreachableApi()), - ); - expect(code).toBe(0); - const printed = io.out.join("\n"); - expect(printed).toMatch(/^Usage: wego hotels/); - expect(printed).toMatch(/^ {2}booking-link /m); - expect(io.err.length).toBe(0); - }); - } - - const leaves = ["search", "results", "details", "rooms", "booking-link"]; - for (const sub of leaves) { - // Bare `help` is exercised alongside the dash forms so a leaf can never - // regress to recognizing only `-h`/`--help` while the group dispatcher - // accepts bare `help` (the #1119 leaf-level bug). - for (const help of ["help", "-h", "--help"]) { - it(`hotels ${sub} ${help}: prints that command's scoped usage to stdout, exit 0, empty stderr, no network call`, async () => { - const io = sink(); - const code = await hotels( - noNetwork(), - [sub, help], - deps(io, unreachableApi()), - ); - expect(code).toBe(0); - // Each leaf now prints its OWN scoped usage (CLI-3), not the whole group - // block — so the usage names this sub-command, not just "wego hotels". - expect(io.out.join("\n")).toContain(`Usage: wego hotels ${sub}`); - expect(io.err.length).toBe(0); - }); - } - } - - it("negative: a genuinely unknown hotels sub-command exits 2 (usage) on stderr", async () => { - const io = sink(); - const code = await hotels( - noNetwork(), - ["frobnicate"], - deps(io, unreachableApi()), + it("makes no no-match claim when the candidate count is missing", () => { + // `api.ts` degrades a negative or fractional count to undefined (its own + // tests), so this is the shape a malformed count reaches the note in. + expect(HOTELS.searchNote(page(true, {}), "sid-1") ?? "").not.toContain( + "no hotels match", ); - expect(code).toBe(2); // usage: rejected before any network call - expect(io.out.length).toBe(0); - expect(io.err.join("")).toContain("Usage: wego hotels"); }); - it("negative: a genuinely unknown option on a hotels leaf exits 2 (usage) on stderr (not confused with --help)", async () => { - // Every hotels leaf now forwards the real parse error via errorMessage(err) - // (CLI-3 — `details` no longer swallows it), so any leaf proves the "Unknown - // option" message survives; booking-link is kept here as the representative. - const io = sink(); - const code = await hotels( - noNetwork(), - ["booking-link", "85481", "--bogus"], - deps(io, unreachableApi()), - ); - expect(code).toBe(2); // usage: rejected before any network call - expect(io.out.length).toBe(0); - expect(io.err.join("")).toMatch(/Unknown option: --bogus/); + it("says nothing when the page carries hotels", () => { + expect( + HOTELS.searchNote(page(false, {}, [{ hotelId: 1 }]), "sid-1"), + ).toBeUndefined(); }); }); diff --git a/src/index.test.ts b/src/index.test.ts index 8128ad4..9ba00be 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,251 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { CliConfig } from "./config"; -import { buildRealDeps, helpText, type RunDeps, run } from "./index"; -import { loadTestCliConfig } from "./test-config"; +import { helpText } from "./index"; -function deps() { - const calls: string[] = []; - const out: string[] = []; - const err: string[] = []; - const d: RunDeps = { - loadConfig: () => loadTestCliConfig(), - io: { log: (m) => out.push(m), error: (m) => err.push(m) }, - login: () => { - calls.push("login"); - return Promise.resolve(0); - }, - whoami: () => { - calls.push("whoami"); - return Promise.resolve(0); - }, - places: (_config, args) => { - calls.push(`places:${args.join(" ")}`); - return Promise.resolve(0); - }, - info: (_config, args) => { - calls.push(`info:${args.join(" ")}`); - return Promise.resolve(0); - }, - flights: (_config, args) => { - calls.push(`flights:${args.join(" ")}`); - return Promise.resolve(0); - }, - hotels: (_config, args) => { - calls.push(`hotels:${args.join(" ")}`); - return Promise.resolve(0); - }, - feedback: (_config, args) => { - calls.push(`feedback:${args.join(" ")}`); - return Promise.resolve(0); - }, - skill: (args) => { - calls.push(`skill:${args.join(" ")}`); - return Promise.resolve(0); - }, - update: (args) => { - calls.push(`update:${args.join(" ")}`); - return Promise.resolve(0); - }, - uninstall: (args) => { - calls.push(`uninstall:${args.join(" ")}`); - return Promise.resolve(0); - }, - config: (args) => { - calls.push(`config:${args.join(" ")}`); - return Promise.resolve(0); - }, - telemetry: (args) => { - calls.push(`telemetry:${args.join(" ")}`); - return Promise.resolve(0); - }, - sendTelemetry: (args) => { - calls.push(`sendTelemetry:${args.join(" ")}`); - return Promise.resolve(0); - }, - logout: () => { - calls.push("logout"); - return Promise.resolve(0); - }, - }; - return { d, calls, out, err }; -} - -const argv = (cmd?: string) => ["bun", "wego", ...(cmd ? [cmd] : [])]; - -describe("run (command dispatch)", () => { - it("dispatches login / whoami / logout", async () => { - for (const cmd of ["login", "whoami", "logout"]) { - const { d, calls } = deps(); - expect(await run(argv(cmd), d)).toBe(0); - expect(calls).toEqual([cmd]); - } - }); - - // Note: `places` dispatch + arg handling is covered behaviorally through - // `run(argv, …)` in commands.test.ts (real handler, stubbed network), so it is - // not re-asserted here against a command stub — that would couple to the - // "forward the raw args tail" contract an internals migration may change. - - it("prints the version", async () => { - const { d, out } = deps(); - expect(await run(argv("version"), d)).toBe(0); - // Semver X.Y.Z with an optional prerelease suffix — the baked version comes - // from the release tag (`0.1.1`, or `0.1.1-rc.1` for a prerelease tag), and - // a from-source run stamps `0.0.0-dev`. - expect(out.join("")).toMatch(/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/); - }); - - it("prints help with no command", async () => { - const { d, out } = deps(); - expect(await run(argv(), d)).toBe(0); - expect(out.join("")).toMatch(/Usage:/); - }); - - // Golden matrix (issue #1119): the root already handled every help form - // correctly before the fix — pinned here alongside the group/leaf matrix in - // commands.test.ts / hotels.test.ts so the root isn't the one untested gap. - for (const help of ["help", "-h", "--help"]) { - it(`prints help for \`${help}\`: stdout, exit 0, empty stderr`, async () => { - const { d, out, err } = deps(); - expect(await run(argv(help), d)).toBe(0); - expect(out.join("")).toMatch(/Usage:/); - expect(err.length).toBe(0); - }); - } - - for (const cmd of ["login", "whoami", "logout", "version"]) { - for (const help of ["--help", "-h", "help"]) { - it(`${cmd} ${help}: prints its own usage, exit 0, does not dispatch`, async () => { - const { d, calls, out, err } = deps(); - expect(await run([...argv(cmd), help], d)).toBe(0); - expect(out.join("")).toMatch(new RegExp(`^Usage: wego ${cmd}`)); - expect(calls).toEqual([]); - expect(err.length).toBe(0); - }); - } - } - - for (const cmd of ["whoami", "logout", "version"]) { - it(`${cmd} rejects an unknown flag with its usage, exit 2, does not dispatch`, async () => { - const { d, calls, err } = deps(); - expect(await run([...argv(cmd), "--frobnicate"], d)).toBe(2); - expect(err.join("")).toMatch( - new RegExp(`^Unknown option: --frobnicate\\nUsage: wego ${cmd}`), - ); - expect(calls).toEqual([]); - }); - } - - it("config loads on first read, never on a --help path, so help works with no backend configured", async () => { - for (const cmd of [ - ["flights", "--help"], - ["flights", "results", "--help"], - ["hotels", "rooms", "-h"], - ["info", "holidays", "help"], - ["places", "--help"], - ["feedback", "--help"], - ]) { - const { d, calls } = deps(); - d.loadConfig = () => { - throw new Error("WEGO_API_URL is required for source usage"); - }; - expect(await run([...argv(), ...cmd], d)).toBe(0); - expect(calls).toEqual([`${cmd[0]}:${cmd.slice(1).join(" ")}`]); - } - for (const cmd of [ - ["feedback", "--message", "help"], - ["places", "--locale", "help", "dubai"], - ["flights", "results", "abc"], - ]) { - const { d } = deps(); - let loads = 0; - const real = loadTestCliConfig(); - d.loadConfig = () => { - loads += 1; - return real; - }; - const handler = (config: CliConfig) => { - expect(config.apiBaseUrl).toBe(real.apiBaseUrl); - expect(config.credentialsPath).toBe(real.credentialsPath); - return Promise.resolve(0); - }; - d.feedback = handler; - d.places = handler; - d.flights = handler; - expect(await run([...argv(), ...cmd], d)).toBe(0); - expect(loads).toBe(1); - } - }); - - it("a real call with no config still fails the way main() maps it, not inside the command", async () => { - const { d, calls } = deps(); - d.loadConfig = () => { - throw new Error("WEGO_API_URL is required for source usage"); - }; - for (const cmd of [ - ["flights", "results", "abc"], - ["places", "dubai"], - ["feedback", "--message", "x"], - ]) { - expect(run([...argv(), ...cmd], d)).rejects.toThrow(/WEGO_API_URL/); - } - expect(calls).toEqual([]); - }); - - for (const help of ["-h", "--help", "help"]) { - it(`help ${help}: prints the root help, exit 0`, async () => { - const { d, out, err } = deps(); - expect(await run([...argv("help"), help], d)).toBe(0); - expect(out.join("")).toMatch(/^wego – Wego API CLI/); - expect(err.length).toBe(0); - }); - } - - it("help rejects a stray argument, exit 2", async () => { - const { d, err } = deps(); - expect(await run([...argv("help"), "extra"], d)).toBe(2); - expect(err.join("")).toMatch(/^Unexpected argument: extra\n/); - }); - - it("errors and exits 2 (usage) on an unknown command", async () => { - const { d, err } = deps(); - expect(await run(argv("frobnicate"), d)).toBe(2); // EXIT.USAGE - expect(err.join("")).toMatch(/Unknown command: frobnicate/); - }); - - it("dispatches skill with the raw args tail (no config)", async () => { - const { d, calls } = deps(); - expect(await run([...argv("skill"), "install", "-y"], d)).toBe(0); - expect(calls).toEqual(["skill:install -y"]); - }); - - it("dispatches telemetry with the raw args tail (no config)", async () => { - const { d, calls } = deps(); - expect(await run([...argv("telemetry"), "disable"], d)).toBe(0); - expect(calls).toEqual(["telemetry:disable"]); - }); - - it("dispatches config with the raw args tail (no config object – local file only)", async () => { - const { d, calls } = deps(); - expect(await run([...argv("config"), "set", "currency", "SAR"], d)).toBe(0); - expect(calls).toEqual(["config:set currency SAR"]); - }); - - it("dispatches a bare `config` (defaults to list inside the command)", async () => { - const { d, calls } = deps(); - expect(await run([...argv("config")], d)).toBe(0); - expect(calls).toEqual(["config:"]); - }); - - it("dispatches the hidden telemetry sender through the same seam", async () => { - // The detached child re-enters here; it is deliberately absent from help. - const { d, calls } = deps(); - expect(await run([...argv("send-telemetry"), '{"a":1}'], d)).toBe(0); - expect(calls).toEqual(['sendTelemetry:{"a":1}']); - }); -}); +/** + * The root help text, which is pure. Dispatch, the help forms, the version and + * the usage errors are what the compiled binary prints, so they are + * `integration/cli.test.ts`. + */ describe("helpText", () => { const commands = [ @@ -301,50 +61,3 @@ describe("helpText", () => { expect(text).toContain("WEGO_CREDENTIALS_PATH"); }); }); - -describe("buildRealDeps (real wiring, no network)", () => { - it("wires commands that can run without a browser or network", async () => { - const d = buildRealDeps(); - // Invalid source configuration fails before command wiring can start a - // loopback server or make a network call. - expect(() => loadTestCliConfig({ WEGO_CLI_CLIENT_ID: "" })).toThrow( - /WEGO_CLI_CLIENT_ID/, - ); - // whoami short-circuits when there are no stored credentials. - expect( - await d.whoami( - loadTestCliConfig({ - WEGO_CREDENTIALS_PATH: "/nonexistent/dir/creds.json", - }), - ), - ).toBe(3); // auth: not logged in - // places with a query but no stored credentials short-circuits the same way. - expect( - await d.places( - loadTestCliConfig({ - WEGO_CREDENTIALS_PATH: "/nonexistent/dir/creds.json", - }), - ["dubai"], - ), - ).toBe(3); // auth: not logged in - // places with no query is a usage error before any network/credential work. - expect( - await d.places( - loadTestCliConfig({ - WEGO_CREDENTIALS_PATH: "/nonexistent/dir/creds.json", - }), - [], - ), - ).toBe(2); // usage: missing query - // skill `path` is a pure read of the wired deps (no fs write, no network). - expect(await d.skill(["path"])).toBe(0); - // logout just clears the (absent) file. - expect( - await d.logout( - loadTestCliConfig({ - WEGO_CREDENTIALS_PATH: join(tmpdir(), "wego-logout-test.json"), - }), - ), - ).toBe(0); - }); -}); diff --git a/src/info.test.ts b/src/info.test.ts index f71a68b..870ed8c 100644 --- a/src/info.test.ts +++ b/src/info.test.ts @@ -1,227 +1,23 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type { - fetchHolidays, - fetchNearbyPlaces, - fetchSchedules, - fetchVisaFree, -} from "./api"; +import { describe, expect, it } from "bun:test"; import { - info, parseAirportsNearArgs, parseHolidaysArgs, parseSchedulesArgs, parseVisaFreeArgs, } from "./commands"; -import type { CliConfig } from "./config"; -import type { UserSettings } from "./settings"; -import { loadCredentials, saveCredentials } from "./storage"; -import { loadTestCliConfig } from "./test-config"; /** - * Behavioral tests for `wego info` (issue #1326) — the four stateless reference - * lookups. + * The `wego info` parsers (issue #1326), tested directly because they are pure. + * Everything they reject is a request that never happens, which is the point of + * validating client-side: both upstreams behind these commands answer a bad key + * with an empty list rather than an error, so a typo that reached the wire would + * come back looking like a real "nothing found". * - * Two halves, and the split is deliberate: - * - * - **The parsers** are pure, so they are tested directly. Everything they - * reject is a request that never happens, which is the point of validating - * client-side: both upstreams behind these commands answer a bad key with an - * empty list rather than an error, so a typo that reached the wire would come - * back looking like a real "nothing found". - * - **The commands** run through the real `info` dispatcher with the four api - * calls injected as deps (#1341 — this suite used to drive a hand-written - * `apps/api`). Assertions are on what a caller observes: the exit code, the JSON - * on stdout, empty stderr, and the arguments the CLI passed to each call. What - * those arguments then become on the wire is `api.test.ts`'s, where the URL is - * built. + * What a caller observes from the commands themselves (exit codes, the JSON on + * stdout, the query on the wire, stored preferences) is + * `integration/info.test.ts`, which drives the compiled binary. */ -// --- injected api deps (#1341) ---------------------------------------------- -// -// These four commands used to run against a hand-written `Bun.serve` stand-in for -// `apps/api`. That fake could only fail when it disagreed with itself, which is the -// defect #1328 exists to remove, so the deps `info` already takes are stubbed -// directly instead. Two things follow: -// -// - What a caller observes stays asserted here: exit code, JSON on stdout, stderr, -// and the ARGUMENTS the CLI passed to each api call. -// - The wire mapping those arguments produce (`--from` → `fromDate`, a place vs a -// coordinate pair, repeated `types`) moved to `api.test.ts`, where the URL is -// built and where an injected `HttpFetch` sees the real request. It is a -// pure-function concern, so it belongs in tier A. - -type ApiCall = { fn: string; base: string; token: string; params: unknown }; - -/** No socket is opened, so the base only has to be the value the deps receive. */ -const API = "https://api.wego.test"; - -const HOLIDAYS_RESPONSE = { - results: [ - { - name: "National Day", - key: "national_day", - startDate: "2026-08-09", - endDate: "2026-08-09", - }, - ], - metadata: { - resultCount: 1, - countryCode: "SG", - window: "upcoming", - from: "2026-07-31", - to: "2026-10-29", - }, -}; - -const VISA_FREE_RESPONSE = { - results: [{ countryCode: "TH", name: "Thailand", keyCityCode: "BKK" }], - metadata: { - resultCount: 1, - totalCandidates: 1, - hasMore: false, - passportCountryCode: "PH", - upstreamPagesFetched: 1, - coverage: "complete", - }, -}; - -const SCHEDULES_RESPONSE = { - results: [ - { - airlineCode: "TR", - flightNumber: "TR 610", - departureAirportCode: "SIN", - arrivalAirportCode: "BKK", - departureTime: "15:45", - arrivalTime: "16:45", - durationMinutes: 120, - stopsCount: 0, - arrivalDayOffset: 0, - segments: [ - { - departureAirportCode: "SIN", - arrivalAirportCode: "BKK", - departureTime: "15:45", - arrivalTime: "16:45", - airlineCode: "TR", - }, - ], - }, - ], - metadata: { - page: 1, - pageSize: 200, - resultCount: 1, - totalCandidates: 1, - hasMore: false, - coverage: "complete", - from: { requested: "SIN", resolvedCityCode: "SIN" }, - to: { requested: "LHR", resolvedCityCode: "LON" }, - siteCode: "SG", - siteCodeSource: "explicit", - }, -}; - -const NEARBY_RESPONSE = { - results: [ - { id: 212, code: "LCY", name: "London City Airport", type: "airport" }, - ], - metadata: { - resultCount: 1, - origin: { place: "LON", latitude: 51.5, longitude: -0.12 }, - radiusKm: 100, - types: ["airport"], - }, -}; - -/** The four api deps, recording every call. `token` is recorded rather than - * checked here: the Bearer header itself is `api.ts`'s business, and - * `api.test.ts` asserts it. */ -function apiStubs(): { api: InfoApiStubs; calls: ApiCall[] } { - const calls: ApiCall[] = []; - const record = - (fn: string, response: T) => - (base: string, token: string, params: unknown): Promise => { - calls.push({ fn, base, token, params }); - return Promise.resolve(response); - }; - return { - calls, - api: { - fetchHolidays: record("fetchHolidays", HOLIDAYS_RESPONSE), - fetchVisaFree: record("fetchVisaFree", VISA_FREE_RESPONSE), - fetchSchedules: record("fetchSchedules", SCHEDULES_RESPONSE), - fetchNearbyPlaces: record("fetchNearbyPlaces", NEARBY_RESPONSE), - } as unknown as InfoApiStubs, - }; -} - -type InfoApiStubs = { - fetchHolidays: typeof fetchHolidays; - fetchVisaFree: typeof fetchVisaFree; - fetchSchedules: typeof fetchSchedules; - fetchNearbyPlaces: typeof fetchNearbyPlaces; -}; - -const sink = () => { - const out: string[] = []; - const err: string[] = []; - return { - out, - err, - log: (m: string) => out.push(m), - error: (m: string) => err.push(m), - }; -}; - -let dir: string; -let credPath: string; - -beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), "wego-info-")); - credPath = join(dir, "credentials.json"); -}); - -afterEach(async () => { - await rm(dir, { recursive: true, force: true }); -}); - -function config(api: string): CliConfig { - return loadTestCliConfig({ - WEGO_CLI_CLIENT_ID: "cli-abc", - WEGO_AUTH_AUTHORIZE_URL: "http://127.0.0.1:1/authorize", - WEGO_AUTH_TOKEN_URL: "http://127.0.0.1:1/token", - WEGO_API_URL: api, - WEGO_CREDENTIALS_PATH: credPath, - }); -} - -function deps( - io: ReturnType, - api: InfoApiStubs, - // Stored travel preferences (issue #1386); none by default. - settings: UserSettings = {}, -) { - return { - ...io, - loadCredentials, - saveCredentials, - refreshTokens: () => { - throw new Error("refresh is not exercised by the info tests"); - }, - loadSettings: async () => settings, - recordAuthFailure: async () => {}, - ...api, - }; -} - -// --------------------------------------------------------------------------- -// Parsers — everything rejected here is a request that never happens -// --------------------------------------------------------------------------- - describe("parseHolidaysArgs", () => { it("uppercases the country and omits an unset window", () => { expect(parseHolidaysArgs(["sg"])).toEqual({ countryCode: "SG" }); @@ -422,315 +218,3 @@ describe("parseAirportsNearArgs", () => { ); }); }); - -// --------------------------------------------------------------------------- -// The dispatcher -// --------------------------------------------------------------------------- - -describe("info dispatcher", () => { - for (const help of ["--help", "-h", "help"]) { - it(`info ${help}: usage on stdout, exit 0, empty stderr`, async () => { - const io = sink(); - const code = await info( - config("http://127.0.0.1:1"), - [help], - deps(io, apiStubs().api), - ); - expect(code).toBe(0); - expect(io.out.join("\n")).toMatch(/^Usage: wego info /); - expect(io.out.join("\n")).toMatch(/^ {2}holidays /m); - expect(io.err.length).toBe(0); - }); - } - - it("a bare `info` prints group usage on stderr with exit 2", async () => { - const io = sink(); - const code = await info( - config("http://127.0.0.1:1"), - [], - deps(io, apiStubs().api), - ); - expect(code).toBe(2); - expect(io.err.join("\n")).toContain("Usage:"); - expect(io.out.length).toBe(0); - }); - - it("an unknown sub-command names it, then prints usage, exit 2", async () => { - const io = sink(); - const code = await info( - config("http://127.0.0.1:1"), - ["weather"], - deps(io, apiStubs().api), - ); - expect(code).toBe(2); - expect(io.err.join("\n")).toContain("Unknown info sub-command: weather"); - expect(io.out.length).toBe(0); - }); - - for (const [sub, usage] of [ - ["holidays", "info holidays "], - ["visa-free", "info visa-free "], - ["schedules", "info schedules "], - ["airports-near", "info airports-near "], - ] as const) { - it(`info ${sub} --help prints the leaf usage on stdout, exit 0`, async () => { - const io = sink(); - const code = await info( - config("http://127.0.0.1:1"), - [sub, "--help"], - deps(io, apiStubs().api), - ); - expect(code).toBe(0); - expect(io.out.join("\n")).toContain(usage); - expect(io.err.length).toBe(0); - }); - } - - it("a usage error costs exit 2 and NO network call", async () => { - const { api, calls } = apiStubs(); - const io = sink(); - // No credentials on disk either — the parser must fail before either is read. - const code = await info(config(API), ["holidays", "ZZZ"], deps(io, api)); - expect(code).toBe(2); - expect(calls).toEqual([]); - expect(io.err.join("\n")).toContain("2-letter ISO country code"); - }); -}); - -// --------------------------------------------------------------------------- -// The four commands, end to end against a local API -// --------------------------------------------------------------------------- - -describe("info commands against a local API", () => { - it("holidays: prints JSON on stdout and sends the bearer token", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const { api, calls } = apiStubs(); - const io = sink(); - - const code = await info( - config(API), - ["holidays", "sg", "--from", "2026-08-01", "--to", "2026-12-31"], - deps(io, api), - ); - - expect(code).toBe(0); - expect(io.err.length).toBe(0); - // Only JSON on stdout, so an agent can pipe it straight into jq. - const printed = JSON.parse(io.out.join("")) as { - metadata: { window: string }; - }; - expect(printed.metadata.window).toBe("upcoming"); - // The stored token reaches the call, and the country is uppercased before it. - // `api.test.ts` owns what the URL and the Bearer header then look like. - expect(calls).toEqual([ - { - fn: "fetchHolidays", - base: API, - token: "tok-1", - // The CLI's own vocabulary: `from`/`to`. Renaming them to the wire's - // `fromDate`/`toDate` happens inside `api.ts`, which is why that mapping is - // asserted in `api.test.ts` and not here. - params: { - countryCode: "SG", - from: "2026-08-01", - to: "2026-12-31", - }, - }, - ]); - }); - - it("holidays: inherits the stored locale but NEVER the stored market", async () => { - // Carve-out (issue #1386): a holidays site code is the country in the PATH, - // so threading the user's market here would answer the wrong country. The - // params the call receives say it exactly: `locale` arrives, `siteCode` and - // `currency` never do. - await saveCredentials(credPath, { accessToken: "tok-1" }); - const { api, calls } = apiStubs(); - const code = await info( - config(API), - ["holidays", "SG"], - deps(sink(), api, { locale: "ar", site: "SA", currency: "SAR" }), - ); - expect(code).toBe(0); - expect(calls[0]?.params).toEqual({ countryCode: "SG", locale: "ar" }); - }); - - it("schedules: inherits both the stored locale and the stored market", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const { api, calls } = apiStubs(); - const code = await info( - config(API), - ["schedules", "SIN", "BKK"], - deps(sink(), api, { locale: "ar", site: "SA" }), - ); - expect(code).toBe(0); - expect(calls[0]?.params).toMatchObject({ locale: "ar", siteCode: "SA" }); - }); - - it("schedules: an explicit --site beats the stored one", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const { api, calls } = apiStubs(); - const code = await info( - config(API), - ["schedules", "SIN", "BKK", "--site", "SG"], - deps(sink(), api, { site: "SA" }), - ); - expect(code).toBe(0); - expect(calls[0]?.params).toMatchObject({ siteCode: "SG" }); - }); - - it("schedules: stamps the CLI's siteCodeSource at top level and strips the API's copy", async () => { - // The API only sees whether a siteCode arrived, so it calls a CLI-resolved - // stored market `explicit` (the stubbed response returns exactly that). - // Printing it verbatim contradicts the promise that the output names the - // deciding layer, and `explicit` would tell a reader the user typed --site - // when they did not. Since #1534 the CLI's answer is a TOP-LEVEL field — - // one `*Source` per knob, like the search verticals — and the API's - // request-scoped copy never leaves `metadata`. The `metadata.siteCode` - // echo stays. - await saveCredentials(credPath, { accessToken: "tok-1" }); - const io = sink(); - const code = await info( - config(API), - ["schedules", "SIN", "BKK"], - deps(io, apiStubs().api, { site: "SA" }), - ); - expect(code).toBe(0); - const raw = io.out.join("\n"); - const printed = JSON.parse(raw) as { - siteCodeSource: string; - metadata: Record; - }; - expect(printed.siteCodeSource).toBe("setting"); - expect(printed.metadata.siteCode).toBe("SG"); - expect(raw.split('"siteCodeSource"').length - 1).toBe(1); - expect( - Object.keys(printed.metadata).filter((k) => k.endsWith("Source")), - ).toEqual([]); - }); - - it("schedules: with no stored site and no flag the source reads `default`", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const io = sink(); - const code = await info( - config(API), - ["schedules", "SIN", "BKK"], - deps(io, apiStubs().api), - ); - expect(code).toBe(0); - const printed = JSON.parse(io.out.join("\n")) as { - siteCodeSource: string; - }; - expect(printed.siteCodeSource).toBe("default"); - }); - - it("holidays: sends no date params when the window is left to the API", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const { api, calls } = apiStubs(); - const code = await info(config(API), ["holidays", "SG"], deps(sink(), api)); - expect(code).toBe(0); - // Absent, not empty: `api.ts` drops an undefined param, and `fromDate=` on the - // wire is a validation error rather than "no window given". - expect(calls[0]?.params).toEqual({ countryCode: "SG" }); - }); - - it("visa-free: prints the list and forwards paging", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const { api, calls } = apiStubs(); - const io = sink(); - - const code = await info( - config(API), - ["visa-free", "ph", "--page-size", "10"], - deps(io, api), - ); - - expect(code).toBe(0); - const printed = JSON.parse(io.out.join("")) as { - results: { countryCode: string }[]; - metadata: { coverage: string }; - }; - expect(printed.results[0].countryCode).toBe("TH"); - expect(printed.metadata.coverage).toBe("complete"); - expect(calls[0]?.fn).toBe("fetchVisaFree"); - expect(calls[0]?.params).toEqual({ countryCode: "PH", pageSize: 10 }); - }); - - it("schedules: forwards the route and airline, and prints the resolved city", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const { api, calls } = apiStubs(); - const io = sink(); - - const code = await info( - config(API), - ["schedules", "sin", "lhr", "--airline", "sq", "--site", "sg"], - deps(io, api), - ); - - expect(code).toBe(0); - // Every positional and flag is uppercased before it reaches the wire. - expect(calls[0]?.fn).toBe("fetchSchedules"); - expect(calls[0]?.params).toEqual({ - from: "SIN", - to: "LHR", - airline: "SQ", - siteCode: "SG", - }); - // The echo is what tells a caller it got London's timetable, not Heathrow's. - const printed = JSON.parse(io.out.join("")) as { - metadata: { to: { requested: string; resolvedCityCode: string } }; - }; - expect(printed.metadata.to).toEqual({ - requested: "LHR", - resolvedCityCode: "LON", - }); - }); - - it("airports-near: sends a resolved place as `place`, never as a coordinate", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const { api, calls } = apiStubs(); - const io = sink(); - - const code = await info( - config(API), - ["airports-near", "lon", "--types", "airport,city"], - deps(io, api), - ); - - expect(code).toBe(0); - // A place code travels as `place`, never as a coordinate. How it is then - // serialized — including the REPEATED `types` params — is `api.test.ts`'s. - expect(calls[0]?.fn).toBe("fetchNearbyPlaces"); - expect(calls[0]?.params).toEqual({ - place: "LON", - types: ["airport", "city"], - }); - const printed = JSON.parse(io.out.join("")) as { - results: { code: string }[]; - }; - expect(printed.results[0].code).toBe("LCY"); - }); - - it("airports-near: sends a coordinate pair as latitude+longitude", async () => { - await saveCredentials(credPath, { accessToken: "tok-1" }); - const { api, calls } = apiStubs(); - const code = await info( - config(API), - ["airports-near", "51.5,-0.12"], - deps(sink(), api), - ); - expect(code).toBe(0); - expect(calls[0]?.params).toEqual({ latitude: 51.5, longitude: -0.12 }); - }); - - it("with no credentials, exits on the auth class without calling the API", async () => { - const { api, calls } = apiStubs(); - const io = sink(); - const code = await info(config(API), ["holidays", "SG"], deps(io, api)); - // The auth exit class, not a usage error and not a success. - expect(code).toBe(3); - expect(calls).toEqual([]); - expect(io.out.length).toBe(0); - expect(io.err.length).toBeGreaterThan(0); - }); -}); diff --git a/src/target.test.ts b/src/target.test.ts index b0b5151..1d58d15 100644 --- a/src/target.test.ts +++ b/src/target.test.ts @@ -1,12 +1,7 @@ import { describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { - buildTargetReport, - formatTargetReport, - type InfoDeps, - info, -} from "./commands"; +import { buildTargetReport, formatTargetReport } from "./commands"; import { CLI_ENV_VARS, loadCliConfig, resolveConfigScope } from "./config"; import { EXIT } from "./error-report"; import { @@ -249,92 +244,14 @@ describe("one binary, every backend", () => { }); // --- visibility ------------------------------------------------------------- - -/** `info target` reads nothing and calls nothing, so every dep it must not touch - * throws if it does. */ -function infoDeps(): InfoDeps & { out: string[]; err: string[] } { - const out: string[] = []; - const err: string[] = []; - const unreachable = (name: string) => async () => { - throw new Error(`info target must not call ${name}`); - }; - return { - out, - err, - log: (m) => out.push(m), - error: (m) => err.push(m), - loadCredentials: unreachable("loadCredentials") as never, - saveCredentials: unreachable("saveCredentials") as never, - refreshTokens: unreachable("refreshTokens") as never, - loadSettings: unreachable("loadSettings") as never, - recordAuthFailure: unreachable("recordAuthFailure") as never, - fetchHolidays: unreachable("fetchHolidays") as never, - fetchVisaFree: unreachable("fetchVisaFree") as never, - fetchSchedules: unreachable("fetchSchedules") as never, - fetchNearbyPlaces: unreachable("fetchNearbyPlaces") as never, - }; -} - describe("a non-prod target is visible", () => { - it("names the target, its origin and the resolved endpoints for a reader", async () => { - const deps = infoDeps(); - const code = await info( - config({ argv: argv("--target", "staging") }), - ["target"], - deps, - ); - expect(code).toBe(0); - // The readable rendering is on STDERR, where every other human line in this - // CLI goes; stdout is the JSON contract (asserted below). - const text = deps.err.join("\n"); - expect(text).toContain("staging"); - expect(text).toContain("--target"); - expect(text).toContain(STAGING_API_URL); - expect(text).toContain(STAGING_AUTH_HOST); - // The promise the axis makes, written down where a person reads it. - expect(text).toContain("suppressed"); - }); - - it("keeps stdout parseable as JSON with NO --json, like every info command", async () => { - // The regression guard for a real defect this PR shipped and CodeRabbit - // caught: the table used to go to stdout, so an agent following SKILL.md's - // operating contract item 4 ("treat successful stdout from `info *` as - // JSON") would JSON.parse a text table and throw. `--json` is absent here on - // purpose - that is the case that was broken, and the case that a caller who - // never read this flag's docs will hit. - const deps = infoDeps(); - const code = await info( - config({ argv: argv("--target", "staging") }), - ["target"], - deps, - ); - expect(code).toBe(0); - expect(JSON.parse(deps.out.join("\n")).target).toBe("staging"); - }); - - it("prints the same stdout object with and without --json", async () => { - // So the flag cannot drift into choosing a FORMAT: it only suppresses the - // stderr decoration. - const plain = infoDeps(); - const jsonOnly = infoDeps(); - const cfg = () => config({ argv: argv("--target", "staging") }); - expect(await info(cfg(), ["target"], plain)).toBe(0); - expect(await info(cfg(), ["target", "--json"], jsonOnly)).toBe(0); - expect(jsonOnly.out).toEqual(plain.out); - // ...and that it really does suppress it. - expect(jsonOnly.err).toEqual([]); - expect(plain.err.length).toBe(1); - }); - - it("answers machine output as one JSON object", async () => { - const deps = infoDeps(); - const code = await info( - config({ env: { WEGO_TARGET: "staging" } }), - ["target", "--json"], - deps, - ); - expect(code).toBe(0); - expect(JSON.parse(deps.out.join("\n"))).toEqual({ + // What `wego info target` prints, and that stdout stays one JSON object with or + // without --json, is `integration/target.test.ts`, which runs the binary. The + // report it renders is built here. + it("reports a staging run with every resolved endpoint", () => { + expect( + buildTargetReport(config({ env: { WEGO_TARGET: "staging" } })), + ).toEqual({ target: "staging", source: "env", apiUrl: STAGING_API_URL, @@ -345,6 +262,18 @@ describe("a non-prod target is visible", () => { }); }); + it("renders the target, its origin and the endpoints for a reader", () => { + const text = formatTargetReport( + buildTargetReport(config({ argv: argv("--target", "staging") })), + ); + expect(text).toContain("staging"); + expect(text).toContain("--target"); + expect(text).toContain(STAGING_API_URL); + expect(text).toContain(STAGING_AUTH_HOST); + // The promise the axis makes, written down where a person reads it. + expect(text).toContain("suppressed"); + }); + it("reports a prod run as prod, and not suppressed", () => { const report = buildTargetReport(config()); expect(report).toMatchObject({ @@ -355,16 +284,6 @@ describe("a non-prod target is visible", () => { }); expect(formatTargetReport(report)).toContain("as configured"); }); - - it("prints its usage on --help and rejects an unknown argument", async () => { - const help = infoDeps(); - expect(await info(config(), ["target", "--help"], help)).toBe(0); - expect(help.out.join("\n")).toContain("info target"); - - const bad = infoDeps(); - expect(await info(config(), ["target", "--jsn"], bad)).toBe(EXIT.USAGE); - expect(bad.err.join("\n")).toContain("--jsn"); - }); }); // --- telemetry -------------------------------------------------------------- diff --git a/src/telemetry-command.test.ts b/src/telemetry-command.test.ts index 79b8783..593c77c 100644 --- a/src/telemetry-command.test.ts +++ b/src/telemetry-command.test.ts @@ -1,164 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { EXIT } from "./error-report"; -import { - effectiveTelemetry, - TELEMETRY_USAGE, - type TelemetryCommandDeps, - telemetry, -} from "./telemetry-command"; -import type { TelemetryState } from "./telemetry-state"; +import { effectiveTelemetry, TELEMETRY_USAGE } from "./telemetry-command"; -const STATE_PATH = "/home/u/.config/wego/telemetry.json"; - -function deps( - over: Partial & { state?: TelemetryState } = {}, -) { - const out: string[] = []; - const err: string[] = []; - let state: TelemetryState = over.state ?? { - deviceId: "dev-1", - enabled: true, - }; - const { state: _ignored, ...rest } = over; - const base: TelemetryCommandDeps = { - log: (m) => out.push(m), - error: (m) => err.push(m), - env: {}, - statePath: STATE_PATH, - loadState: async () => state, - setEnabled: async (enabled) => { - state = { ...state, enabled }; - return state; - }, - ...rest, - }; - return Object.assign(base, { out, err, current: () => state }); -} - -const json = (lines: string[]) => JSON.parse(lines[0] as string); - -describe("telemetry status", () => { - it("reports the stored setting and where it lives", async () => { - const d = deps(); - expect(await telemetry(["status"], d)).toBe(EXIT.OK); - expect(json(d.out)).toEqual({ - enabled: true, - source: "default", - mode: null, - setting: true, - path: STATE_PATH, - }); - }); - - it("defaults to status with no subcommand", async () => { - const d = deps(); - expect(await telemetry([], d)).toBe(EXIT.OK); - expect(json(d.out).source).toBe("default"); - }); - - it("attributes the state to the setting when it was turned off", async () => { - const d = deps({ state: { enabled: false } }); - expect(await telemetry(["status"], d)).toBe(EXIT.OK); - expect(json(d.out)).toMatchObject({ enabled: false, source: "setting" }); - }); - - it("attributes the state to the environment, which wins", async () => { - const d = deps({ - env: { WEGO_CLI_TELEMETRY: "0" }, - state: { enabled: true }, - }); - expect(await telemetry(["status"], d)).toBe(EXIT.OK); - expect(json(d.out)).toMatchObject({ - enabled: false, - source: "environment", - mode: "off", - setting: true, - }); - }); - - it("reports log mode as NOT sending, since it sends nothing", async () => { - // `enabled` answers "do events leave this machine"; `mode` explains why not. - const d = deps({ env: { WEGO_CLI_TELEMETRY: "log" } }); - expect(await telemetry(["status"], d)).toBe(EXIT.OK); - expect(json(d.out)).toMatchObject({ enabled: false, mode: "log" }); - }); -}); - -describe("telemetry enable / disable", () => { - it("persists a disable and echoes the new state", async () => { - const d = deps(); - expect(await telemetry(["disable"], d)).toBe(EXIT.OK); - expect(d.current().enabled).toBe(false); - expect(json(d.out)).toEqual({ enabled: false, path: STATE_PATH }); - }); - - it("persists an enable", async () => { - const d = deps({ state: { enabled: false } }); - expect(await telemetry(["enable"], d)).toBe(EXIT.OK); - expect(d.current().enabled).toBe(true); - expect(json(d.out)).toEqual({ enabled: true, path: STATE_PATH }); - }); - - it("keeps the machine id across a toggle", async () => { - const d = deps(); - await telemetry(["disable"], d); - await telemetry(["enable"], d); - expect(d.current().deviceId).toBe("dev-1"); - }); - - it("warns on stderr when the environment will override what was just stored", async () => { - const d = deps({ env: { WEGO_CLI_TELEMETRY: "0" } }); - expect(await telemetry(["enable"], d)).toBe(EXIT.OK); - expect(d.err.join("\n")).toMatch(/WEGO_CLI_TELEMETRY=0 overrides/); - expect(d.current().enabled).toBe(true); - }); - - it("stays quiet when the environment agrees with the stored choice", async () => { - const d = deps({ env: { WEGO_CLI_TELEMETRY: "0" } }); - expect(await telemetry(["disable"], d)).toBe(EXIT.OK); - expect(d.err).toHaveLength(0); - }); -}); - -describe("telemetry usage errors", () => { - it("rejects an unknown subcommand with the usage class", async () => { - const d = deps(); - expect(await telemetry(["nuke"], d)).toBe(EXIT.USAGE); - expect(d.err.join("\n")).toMatch(/Unknown subcommand: nuke/); - expect(d.out).toHaveLength(0); - }); - - it("names an unknown option as such", async () => { - const d = deps(); - expect(await telemetry(["--force"], d)).toBe(EXIT.USAGE); - expect(d.err.join("\n")).toMatch(/Unknown option: --force/); - }); - - it("rejects a trailing argument after enable/disable", async () => { - const d = deps(); - expect(await telemetry(["disable", "unexpected"], d)).toBe(EXIT.USAGE); - expect(d.err.join("\n")).toMatch(/Unexpected argument: unexpected/); - expect(d.current().enabled).toBe(true); - }); - - it("rejects a trailing option after enable/disable", async () => { - const d = deps(); - expect(await telemetry(["enable", "--force"], d)).toBe(EXIT.USAGE); - expect(d.err.join("\n")).toMatch(/Unknown option: --force/); - }); - - it("prints usage on --help", async () => { - const d = deps(); - expect(await telemetry(["--help"], d)).toBe(EXIT.OK); - expect(d.out[0]).toBe(TELEMETRY_USAGE); - }); - - it("names the single control in its usage text", () => { - expect(TELEMETRY_USAGE).toMatch(/WEGO_CLI_TELEMETRY/); - expect(TELEMETRY_USAGE).toMatch(//); - expect(TELEMETRY_USAGE).toMatch(/Never your\s+search/); - }); -}); +/** + * The precedence `wego telemetry` reports, and its usage text. What the command + * prints and stores is `integration/telemetry.test.ts`, which drives the compiled + * binary against its own state file. + */ describe("effectiveTelemetry", () => { it.each([ @@ -171,3 +18,11 @@ describe("effectiveTelemetry", () => { expect(effectiveTelemetry(mode, state)).toEqual({ enabled, source }); }); }); + +describe("TELEMETRY_USAGE", () => { + it("names the single control and what is never sent", () => { + expect(TELEMETRY_USAGE).toMatch(/WEGO_CLI_TELEMETRY/); + expect(TELEMETRY_USAGE).toMatch(//); + expect(TELEMETRY_USAGE).toMatch(/Never your\s+search/); + }); +}); diff --git a/src/testing/cli-runner.ts b/src/testing/cli-runner.ts deleted file mode 100644 index 9be926e..0000000 --- a/src/testing/cli-runner.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Run the real `wego` CLI as a subprocess (issue #1333). - * - * In-process `run(argv, deps)` forfeits the two things an agent actually depends - * on — the exit code and the stdout/stderr split — so the e2e tiers spawn the - * shipped entrypoint instead. `commands.test.ts` is the in-process tier and stays - * as it is. - * - * Shared here rather than copied per suite: slices 4 and 5 add hotels and an - * API-direct track against the same booted API. - */ - -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const CLI_DIR = new URL("../../", import.meta.url).pathname; - -export type CliResult = { code: number; out: string; err: string }; - -/** What `runCli` needs from a booted API — `BootedApi` satisfies it as-is, so the - * fixture handle can be passed straight through. */ -export type CliTarget = { - url: string; - credentialsPath: string; -}; - -/** - * One throwaway `$XDG_CONFIG_HOME` for the whole suite, so nothing the CLI writes - * per-run lands in the developer's real `~/.config//`. - * - * This is not belt-and-braces. `WEGO_CLI_TELEMETRY=0` suppresses the *send*, but - * the analytics **session id is deliberately independent of the telemetry opt-out** - * (`src/index.ts`: the session header is sent "whether or not" telemetry is on), so - * `resolveSession` still writes `session.json` through `installConfigPath`, which - * resolves `$XDG_CONFIG_HOME` before `~/.config`. Redirecting the base keeps the - * session id — and therefore the real `X-Wego-Session-Id` request header — flowing - * to the booted API, which is behaviour worth exercising, while writing it - * somewhere disposable. - */ -const CONFIG_HOME = mkdtempSync(join(tmpdir(), "wego-cli-e2e-config-")); - -/** The settings file the spawned CLI will read (issue #1386), inside the suite's - * throwaway config home. The config scope is the constant `wego`, from source and - * from a binary alike. */ -const SETTINGS_PATH = join(CONFIG_HOME, "wego", "settings.json"); - -/** - * Plant the user's travel preferences for the next `runCli` calls, so a suite can - * prove the SHIPPED entrypoint reads the file — path resolution through - * `buildRealDeps` is the one part no in-process test can cover. - */ -export function writeUserSettings(settings: Record): void { - mkdirSync(join(CONFIG_HOME, "wego"), { recursive: true, mode: 0o700 }); - writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`, { - mode: 0o600, - }); -} - -/** Remove it again, so one settings-aware test cannot leak into the rest. */ -export function clearUserSettings(): void { - rmSync(SETTINGS_PATH, { force: true }); -} - -export async function runCli( - args: string[], - target: CliTarget, - extraEnv: Record = {}, -): Promise { - // `loadSourceEnvLocal` fills any key we leave unset from `apps/cli/.env.local`, - // which would silently point the CLI at the configured dev API instead of the - // one under test. Both keys are therefore always set explicitly. - // Both, because both are what the comment above promises. An empty - // credentialsPath would be backfilled from `.env.local` just as silently as an - // empty url, and the CLI would read the developer's real credentials file. - if (!target.url) throw new Error("runCli: target.url is required"); - if (!target.credentialsPath) { - throw new Error("runCli: target.credentialsPath is required"); - } - // `process.execPath`, not the bare name: PATH resolution could pick a DIFFERENT bun - // from the one running this suite, and the child runs the CLI under test. An absolute - // path also keeps the spawn independent of a writeable PATH entry (S4036). - const proc = Bun.spawn([process.execPath, "src/index.ts", ...args], { - cwd: CLI_DIR, - env: { - ...process.env, - // Keep the background behaviours off the network: both would otherwise fire - // after every command in the suite. - WEGO_CLI_NO_AUTO_SKILL: "1", - WEGO_CLI_NO_UPDATE_NOTICE: "1", - WEGO_CLI_TELEMETRY: "0", - ...extraEnv, - // AFTER `extraEnv`, so the isolation the two guards above promise cannot be - // undone by a caller — an empty override would be backfilled from - // `.env.local` just as silently as leaving the key unset. - WEGO_API_URL: target.url, - WEGO_CREDENTIALS_PATH: target.credentialsPath, - // Every per-run file the CLI writes goes here, not into the real config dir. - XDG_CONFIG_HOME: CONFIG_HOME, - }, - stdout: "pipe", - stderr: "pipe", - }); - const [out, err, code] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - return { code, out, err }; -} diff --git a/tsconfig.json b/tsconfig.json index c44fb38..6c82a23 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,6 @@ "verbatimModuleSyntax": true, "types": ["bun", "node"] }, - "include": ["src/**/*.ts", "scripts/**/*.ts"], + "include": ["src/**/*.ts", "scripts/**/*.ts", "integration/**/*.ts"], "exclude": ["node_modules"] } From 5f73dcb7ae023359197ea786b7cf8573c066e755 Mon Sep 17 00:00:00 2001 From: sunny-wego Date: Wed, 23 Sep 2026 23:36:19 +0800 Subject: [PATCH 2/5] fix(login): exit 7, not 2, when the auth server cannot be reached on Linux The token exchange left whatever `fetch` threw to the exit-code taxonomy, which reads only a TypeError as a network failure. Bun raises a TypeError for a refused connection on macOS and a plain Error on Linux, so an unreachable auth server made `wego login` exit 2 (usage) on Linux and 7 (network) on macOS. The new integration tier caught it on its first Linux run. The token request now wraps any fetch rejection as TokenEndpointUnreachableError, the way the API client already does with ApiUnreachableError, and the taxonomy maps it to 7 with a message that names the auth server. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016LEU9d3f2jvK3SFUew6iCp --- src/error-report.test.ts | 11 +++++++++++ src/error-report.ts | 5 +++++ src/oauth.ts | 36 ++++++++++++++++++++++++++++++------ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/error-report.test.ts b/src/error-report.test.ts index dc74520..8487032 100644 --- a/src/error-report.test.ts +++ b/src/error-report.test.ts @@ -6,6 +6,7 @@ import { formatCliError, isTimeoutError, } from "./error-report"; +import { TokenEndpointUnreachableError } from "./oauth"; /** Every published code with the status `apps/api` pairs it with (`CODE_META` in * `apps/api/src/errors.ts`) and the exit class it owes. Two arms have to agree @@ -86,6 +87,16 @@ describe("exitCodeForError (issue #1110 taxonomy)", () => { exitCodeForError(new DOMException("timed out", "TimeoutError")), ).toBe(EXIT.TIMEOUT); expect(exitCodeForError(new TypeError("fetch failed"))).toBe(EXIT.TIMEOUT); + // Whatever the platform's fetch threw, an unreached token endpoint is the + // network class, not a generic error (Bun on Linux throws a plain Error). + expect( + exitCodeForError( + new TokenEndpointUnreachableError( + "http://127.0.0.1:9/token", + new Error("ECONNREFUSED"), + ), + ), + ).toBe(EXIT.TIMEOUT); }); it("falls back to generic error for anything else", () => { diff --git a/src/error-report.ts b/src/error-report.ts index a7c9e55..262376a 100644 --- a/src/error-report.ts +++ b/src/error-report.ts @@ -5,6 +5,7 @@ import { UnauthorizedError, } from "./api"; import type { ProblemCode } from "./api-wire"; +import { TokenEndpointUnreachableError } from "./oauth"; import { SettingsFileError } from "./settings"; /** @@ -100,6 +101,7 @@ export function exitCodeForError(err: unknown): number { // the per-request deadline firing) into ApiUnreachableError; its `cause` is the // underlying TypeError/DOMException. Either way it's a network/timeout class. if (err instanceof ApiUnreachableError) return EXIT.TIMEOUT; + if (err instanceof TokenEndpointUnreachableError) return EXIT.TIMEOUT; if (isTimeoutError(err) || isNetworkError(err)) return EXIT.TIMEOUT; return EXIT.ERROR; } @@ -178,6 +180,9 @@ export function formatCliError(err: unknown, prog: string): string { if (err instanceof ApiUnreachableError) { return `Could not reach the Wego API at ${err.url} – is the local \`apps/api\` running (\`bun dev\`), or check WEGO_API_URL and your network connection.`; } + if (err instanceof TokenEndpointUnreachableError) { + return `Could not reach the auth server at ${err.url} – check WEGO_AUTH_TOKEN_URL and your network connection.`; + } if (isTimeoutError(err)) { return "Request timed out. The API did not respond within the deadline – retry the command (re-poll with the same searchId)."; } diff --git a/src/oauth.ts b/src/oauth.ts index 2cb9964..6f5769e 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -128,6 +128,25 @@ export function parseTokenResponse(json: unknown, now = Date.now()): TokenSet { * failure (a captive portal, a 5xx HTML page) — and the snippet stays off * stderr, reached only by the local failure record. */ +/** + * The token endpoint was never reached: connection refused, DNS, TLS, reset, or + * the deadline. Typed here rather than left as whatever `fetch` threw, because + * that differs by platform: Bun raises a `TypeError` for a refused connection on + * macOS and an `Error` on Linux, and only a `TypeError` read as a network + * failure, so the same unreachable auth server exited 7 on one and 2 on the other + * (found by `integration/login-more.test.ts` on the Linux runner). The api + * client wraps its own `fetch` the same way (`ApiUnreachableError`). + */ +export class TokenEndpointUnreachableError extends Error { + constructor( + readonly url: string, + readonly cause: unknown, + ) { + super(`could not reach the auth server at ${url}`); + this.name = "TokenEndpointUnreachableError"; + } +} + export class TokenEndpointError extends Error { readonly status: number; readonly statusText: string; @@ -327,12 +346,17 @@ async function postToken( config: CliConfig, body: Record, ): Promise { - const res = await fetch(config.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams(body).toString(), - signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS), - }); + let res: Response; + try { + res = await fetch(config.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body).toString(), + signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS), + }); + } catch (err) { + throw new TokenEndpointUnreachableError(config.tokenUrl, err); + } if (!res.ok) { // Read the body so the auth server's OAuth2 `error` survives instead of // being discarded at the throw site (investigation #1360, H5). Bounded, and From a998c7f646502b208185c2a9f49d15c5940fb722 Mon Sep 17 00:00:00 2001 From: sunny-wego Date: Thu, 24 Sep 2026 00:17:45 +0800 Subject: [PATCH 3/5] fix(integration): address review of the release gate (address-pr iteration 1) Remove private-tracker issue numbers and a private workflow filename from the public tree. Make the harness stricter where the review found gaps: an unsupported keyword inside anyOf/oneOf fails, the skill check never falls back to root help and matches whole flags, the unit-tier guard resolves every import form, and a provided binary must match SHA256SUMS.txt before the release run executes it. A truncated body is now a real cut-off read, login helpers kill the child on failure, temp binaries are removed on exit, next-report requests time out and log one-line titles, and the auth-server message names its cause. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016LEU9d3f2jvK3SFUew6iCp --- .agents/skills/cli-tests/SKILL.md | 2 +- .github/CODEOWNERS | 2 +- .github/workflows/release-cli.yml | 9 +++ CONTRIBUTING.md | 2 +- docs/release.md | 2 +- integration/README.md | 2 +- integration/contract.test.ts | 42 ++++++++++- integration/errors.test.ts | 23 ++++-- integration/flights.test.ts | 6 +- integration/harness/binary.ts | 42 ++++++++++- integration/harness/contract.ts | 11 ++- integration/harness/fake.ts | 34 ++++----- integration/harness/login.ts | 21 +++++- integration/hotels.test.ts | 10 +-- integration/login-more.test.ts | 30 ++------ integration/skill-matches-cli.test.ts | 23 +++++- scripts/next-report.test.ts | 12 +++ scripts/next-report.ts | 17 ++++- scripts/unit-tier-guard.test.ts | 103 ++++++++++++++++++++------ src/error-report.test.ts | 12 +++ src/error-report.ts | 5 +- 21 files changed, 313 insertions(+), 97 deletions(-) diff --git a/.agents/skills/cli-tests/SKILL.md b/.agents/skills/cli-tests/SKILL.md index bd3017a..183c634 100644 --- a/.agents/skills/cli-tests/SKILL.md +++ b/.agents/skills/cli-tests/SKILL.md @@ -57,7 +57,7 @@ it("prints the caller's identity", async () => { needs `route(first, then)`, not a read count. - Logged-out, expired and refreshed sessions: `signIn(s.home, {...})` plus the fake's `accept` and `refresh` options (see `integration/auth.test.ts`). -- Faults: `{ fault: "non-json" }`, `{ fault: "truncated" }`, `startDropper()`. +- Faults: `{ fault: "non-json" }`, `startDropper()` (a reset) and `startDropper({ partial: true })` (a body cut off). - The test fails by itself if any request or answer breaks the contract, or a request reaches a route nobody declared. Do not assert that separately. - Keep a scenario under a few seconds. A path that needs a long wait is tested diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ab1ae28..790904c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -183,7 +183,7 @@ contract/openapi.json @sunny-wego @yeouchien-wego @chuyeowego # - `harness/contract.ts` and `harness/fake.ts` hold every exchange to the # contract. Relaxing the validator, or letting an unexpected request pass, # turns the tier back into a fake that can only fail when it disagrees with -# itself (#1328), and every scenario still reads green. +# itself, and every scenario still reads green. # - `fixtures/` is what the fake answers with. A fixture copied from a real # response must not carry the account it came from; review is the check. integration/ @sunny-wego @yeouchien-wego @chuyeowego diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 5e8d4dd..8e715bb 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -382,6 +382,14 @@ jobs: name: cli-binaries path: dist + # The suite refuses to run a binary that does not match its manifest line + # (`WEGO_INTEGRATION_MANIFEST`): verify-then-execute, on every target. + - name: Download the manifest + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-checksums + path: dist + # An artifact does not keep the executable bit. Windows has none to restore. - name: Make the binary executable if: runner.os != 'Windows' @@ -396,6 +404,7 @@ jobs: shell: bash env: WEGO_INTEGRATION_BINARY: ${{ github.workspace }}/dist/${{ matrix.asset }} + WEGO_INTEGRATION_MANIFEST: ${{ github.workspace }}/dist/SHA256SUMS.txt run: bun run test:integration # The only job that moves a ring. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3262046..9c2a926 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -245,7 +245,7 @@ Or one at a time: bun run lint # biome check: formatting, lint rules, import order bun run format # the same, writing every safe fix bun run typecheck # tsc --noEmit -bun test # unit tests +bun run test # unit tests bun run test:integration # the compiled binary against a contract-checked fake ``` diff --git a/docs/release.md b/docs/release.md index 8b976a1..84fc3f9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -147,7 +147,7 @@ reading it. 2. The receiver accepts exactly one identity: a `push` of `refs/tags/` from `wego/cli/.github/workflows/release-cli.yml@refs/tags/`, with the body's `sha`. Anything else is a `400`, `401` or `403`. It then starts wego-ai's - `cli-next-smoke.yml` for that tag. + smoke workflow for that tag. 3. That workflow writes two check runs on the tag's commit, as the gate App (id `4987365`, `checks: write` on this repository only): - **`cli-next-smoke`**: it installs the published binary after verifying its diff --git a/integration/README.md b/integration/README.md index 1398f38..2fb9af0 100644 --- a/integration/README.md +++ b/integration/README.md @@ -24,7 +24,7 @@ exist at the process boundary. ## Why the fake can be trusted A fake that only answers what its author expected can only fail when it disagrees -with itself (#1328). This one is held to the API's published contract instead: +with itself. This one is held to the API's published contract instead: - **Every request** the binary sends is matched to an operation in `contract/openapi.json` and checked against it: path parameters, query parameters diff --git a/integration/contract.test.ts b/integration/contract.test.ts index 4fd53be..a85a60d 100644 --- a/integration/contract.test.ts +++ b/integration/contract.test.ts @@ -1,9 +1,14 @@ /** * The contract check can fail. A validator that passes everything would make every - * scenario vacuous, so each way it rejects is pinned here once. + * scenario vacuous, so each way it rejects is pinned here once, with the manifest + * check that stands before a provided binary runs. */ import { describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { checkAgainstManifest } from "./harness/binary"; import { matchOperation, operationById, @@ -73,6 +78,16 @@ describe("contract check", () => { expect(validate({}, { type: "object", patternProperties: {} })).toEqual([ '$: unsupported schema keyword "patternProperties"', ]); + // Inside a branch too, where a passing sibling would otherwise hide it. + const unread = { type: "object", patternProperties: {} }; + for (const branches of [ + { anyOf: [unread, { type: "object" }] }, + { oneOf: [unread, { type: "object" }] }, + ]) { + expect(validate({}, branches)).toEqual([ + '$: unsupported schema keyword "patternProperties"', + ]); + } }); it("records a request no route expects, and an invalid fixture", async () => { @@ -108,3 +123,28 @@ describe("contract check", () => { } }); }); + +describe("a provided binary is checked before it runs", () => { + it("accepts matching bytes and refuses changed or unlisted ones", () => { + const dir = mkdtempSync(join(tmpdir(), "wego-manifest-")); + try { + const bin = join(dir, "wego-linux-x64"); + writeFileSync(bin, "the built bytes"); + const sum = new Bun.CryptoHasher("sha256") + .update("the built bytes") + .digest("hex"); + const manifest = join(dir, "SHA256SUMS.txt"); + writeFileSync(manifest, `${sum} wego-linux-x64\n`); + expect(() => checkAgainstManifest(bin, manifest)).not.toThrow(); + + writeFileSync(bin, "other bytes"); + expect(() => checkAgainstManifest(bin, manifest)).toThrow( + /does not match/, + ); + writeFileSync(manifest, `${sum} wego-darwin-arm64\n`); + expect(() => checkAgainstManifest(bin, manifest)).toThrow(/lists no/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/integration/errors.test.ts b/integration/errors.test.ts index dc05e5d..d53edda 100644 --- a/integration/errors.test.ts +++ b/integration/errors.test.ts @@ -89,14 +89,27 @@ describe("error classes", () => { } }); - for (const fault of ["non-json", "truncated"] as const) { - it(`a ${fault} body exits non-zero and prints nothing on stdout`, async () => { - const { result } = await placesAnswering({ fault }); + it("a non-json body exits non-zero and prints nothing on stdout", async () => { + const { result } = await placesAnswering({ fault: "non-json" }); + expect(result.code).not.toBe(0); + expect(result.out).toBe(""); + expect(result.err).not.toBe(""); + }); + + it("a body cut off mid-read exits non-zero and prints nothing on stdout", async () => { + signIn(s.home); + const dropper = startDropper({ partial: true }); + try { + const result = await s.run(["places", "London"], { + env: { WEGO_API_URL: dropper.url }, + }); expect(result.code).not.toBe(0); expect(result.out).toBe(""); expect(result.err).not.toBe(""); - }); - } + } finally { + dropper.stop(); + } + }); it("reaches nothing outside this machine", async () => { // The suite's own guarantee, not the CLI's: a real host is refused before a diff --git a/integration/flights.test.ts b/integration/flights.test.ts index 9e6c143..f120e0f 100644 --- a/integration/flights.test.ts +++ b/integration/flights.test.ts @@ -98,7 +98,7 @@ describe("flights search", () => { const printed = json(result); expect(printed.searchId).toBe(body("flights-search").searchId); expect(printed.settled).toBe("converged"); - // A card page, not trips: a price summary and no fares (#1308). + // A card page, not trips: a price summary and no fares. expect(printed.results[0]?.price).toEqual( body("flights-results").results[0].price, ); @@ -568,7 +568,7 @@ describe("flights results", () => { }); it("a bare read inherits the stored currency and locale", async () => { - // Issue #1386: a plain read after a SAR search used to come back in USD. + // Regression: a plain read after a SAR search used to come back in USD. signIn(s.home); writeSettings(s.home, { currency: "SAR", locale: "ar" }); const fake = s.fake({ routes: [route("flights-results")] }); @@ -637,7 +637,7 @@ describe("priced reads and their provenance", () => { it(`${read.argv.slice(0, 2).join(" ")} prints one *Source per knob, top level`, async () => { // The API's request-scoped copies inside `metadata` disagree with the CLI's // label by construction when a preference is stored, so they are stripped - // at print time and the echoes themselves are kept (#1534). + // at print time and the echoes themselves are kept. signIn(s.home); writeSettings(s.home, { currency: "SAR" }); s.fake({ routes: read.routes() }); diff --git a/integration/harness/binary.ts b/integration/harness/binary.ts index 2abd775..b61baac 100644 --- a/integration/harness/binary.ts +++ b/integration/harness/binary.ts @@ -4,7 +4,8 @@ * `WEGO_INTEGRATION_BINARY` names an already-built binary, which is how the release * run tests the exact artifact it is about to publish, on each target's own runner. * Without it, the host binary is compiled from this checkout, the way a pull request - * and a developer run it. Either way the suite drives a single-file binary as its + * and a developer run it. With `WEGO_INTEGRATION_MANIFEST` also set, the provided + * binary must match its line in that `SHA256SUMS.txt` before it runs. Either way the suite drives a single-file binary as its * own process: its argv parsing, exit codes and stream split are what an agent * depends on, and no in-process test sees them. * @@ -18,19 +19,31 @@ import { constants, copyFileSync, mkdtempSync, + readFileSync, + rmSync, statSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { fileURLToPath } from "node:url"; export const BINARY_ENV = "WEGO_INTEGRATION_BINARY"; +/** The release run's `SHA256SUMS.txt`, which a provided binary must match. */ +export const MANIFEST_ENV = "WEGO_INTEGRATION_MANIFEST"; const CLI_DIR = fileURLToPath(new URL("../../", import.meta.url)); /** A cold `bun build --compile` is slow, not unbounded. */ const COMPILE_TIMEOUT_MS = 180_000; +/** Each directory holds a full single-file binary, so it lives only as long as the + * `bun test` process that made it; otherwise a developer's tmpdir keeps one per run. */ +function binaryDir(): string { + const dir = mkdtempSync(join(tmpdir(), "wego-integration-bin-")); + process.on("exit", () => rmSync(dir, { recursive: true, force: true })); + return dir; +} + function checkProvided(path: string): string { let isFile = false; try { @@ -46,11 +59,32 @@ function checkProvided(path: string): string { throw new Error(`${BINARY_ENV}=${path} is not executable`); } } + const manifest = process.env[MANIFEST_ENV]?.trim(); + if (manifest) checkAgainstManifest(path, manifest); return path; } +/** Verify-then-execute: a provided binary arrived over an artifact hop, so it runs + * only once its bytes match the `SHA256SUMS.txt` line for its file name. */ +export function checkAgainstManifest(path: string, manifest: string): void { + const name = basename(path); + const line = readFileSync(manifest, "utf8") + .split(/\r?\n/) + .map((l) => /^([0-9a-f]{64})\s+\*?(.+)$/.exec(l.trim())) + .find((m) => m?.[2] === name); + if (!line) throw new Error(`${manifest} lists no ${name}`); + const actual = new Bun.CryptoHasher("sha256") + .update(readFileSync(path)) + .digest("hex"); + if (actual !== line[1]) { + throw new Error( + `${name} does not match ${manifest}: sha256 ${actual}, expected ${line[1]}`, + ); + } +} + async function compileHost(): Promise { - const dir = mkdtempSync(join(tmpdir(), "wego-integration-bin-")); + const dir = binaryDir(); const path = join(dir, process.platform === "win32" ? "wego.exe" : "wego"); // `process.execPath` pins the compiler to the Bun running this suite. stdout is // ignored, not piped: only stderr is drained, and a full pipe would block. @@ -82,7 +116,7 @@ async function compileHost(): Promise { * `wego-linux-x64`. Installed, it is `wego`, so the suite drives a copy by that * name: what a user's help and usage lines actually say. */ function installAsWego(provided: string): string { - const dir = mkdtempSync(join(tmpdir(), "wego-integration-bin-")); + const dir = binaryDir(); const path = join(dir, process.platform === "win32" ? "wego.exe" : "wego"); copyFileSync(provided, path); chmodSync(path, 0o755); diff --git a/integration/harness/contract.ts b/integration/harness/contract.ts index a7fc913..b52c2e4 100644 --- a/integration/harness/contract.ts +++ b/integration/harness/contract.ts @@ -2,7 +2,7 @@ * The contract every exchange in the fake is checked against: `contract/openapi.json`, * the vendored copy of the API's published document. * - * This is what keeps the fake honest (#1328). A hand-written stand-in can only fail + * This is what keeps the fake honest. A hand-written stand-in can only fail * when it disagrees with itself; a fake whose every request and every answer must * satisfy the API's own published schema fails when the CLI and the API disagree, * which is the one disagreement this tier exists to catch. @@ -183,7 +183,14 @@ export function validate(value: unknown, schema: Schema, at = "$"): string[] { if (s.anyOf || s.oneOf) { const branches = (s.anyOf ?? s.oneOf) as Schema[]; - const passing = branches.filter((b) => validate(value, b, at).length === 0); + const results = branches.map((b) => validate(value, b, at)); + // A branch the validator cannot read fails the whole schema: counting it as a + // mere mismatch would let a sibling branch pass the value unread. + const unsupported = results + .flat() + .filter((e) => e.includes("unsupported schema keyword")); + if (unsupported.length > 0) return unsupported; + const passing = results.filter((r) => r.length === 0); if (s.anyOf && passing.length === 0) { errors.push(`${at}: matches none of anyOf`); } diff --git a/integration/harness/fake.ts b/integration/harness/fake.ts index 5e45b12..11f3672 100644 --- a/integration/harness/fake.ts +++ b/integration/harness/fake.ts @@ -22,9 +22,8 @@ import { * and `application/problem+json` from 400, which is what the API does. */ export type Answer = | { status: number; body?: unknown; headers?: Record } - /** Close the connection mid-body: the length promises more than arrives. */ - | { fault: "truncated" } - /** A 200 whose body is not JSON: what a proxy's error page looks like. */ + /** A 200 whose body is not JSON: what a proxy's error page looks like. A body + * cut off mid-read is `startDropper({ partial: true })`. */ | { fault: "non-json" }; export interface Route { @@ -148,14 +147,24 @@ function oauthError(error: string): Response { }); } -/** A port that accepts a connection and closes it before any answer: a reset, which - * a `Bun.serve` handler cannot produce. Point `WEGO_API_URL` at it. */ -export function startDropper(): { url: string; stop: () => void } { +/** The start of an answer whose length promises far more body than is sent. */ +const PARTIAL_ANSWER = + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 4096\r\n\r\n{"; + +/** A port that accepts a connection and closes it, either before any answer (a + * reset) or, with `partial`, one byte into a 4096-byte body. A `Bun.serve` handler + * can produce neither: it rewrites the length to match the body it is given. Point + * `WEGO_API_URL` at it. */ +export function startDropper(opts: { partial?: boolean } = {}): { + url: string; + stop: () => void; +} { const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data(socket) { + if (opts.partial) socket.write(PARTIAL_ANSWER); socket.end(); }, }, @@ -228,18 +237,9 @@ export function startFake(options: FakeOptions = {}): Fake { function send(answer: Answer): Response { if ("fault" in answer) { - if (answer.fault === "non-json") { - return new Response("Bad gateway", { - status: 200, - headers: { "content-type": "text/html" }, - }); - } - return new Response("{", { + return new Response("Bad gateway", { status: 200, - headers: { - "content-type": "application/json", - "content-length": "4096", - }, + headers: { "content-type": "text/html" }, }); } return json(answer.status, answer.body ?? {}, answer.headers); diff --git a/integration/harness/login.ts b/integration/harness/login.ts index b764181..a8570cc 100644 --- a/integration/harness/login.ts +++ b/integration/harness/login.ts @@ -26,12 +26,27 @@ export async function loginThroughBrowser( redirectUri: string, state: string, ) => string | { url: string; headers: Record }; + /** Login's arguments: `--no-browser` unless a scenario says otherwise. */ + args?: string[]; + env?: Record; }, ): Promise { - const running = spawnWego(["login", "--no-browser"], { fake, home }); + const running = spawnWego(["login", ...(opts.args ?? ["--no-browser"])], { + fake, + home, + env: opts.env, + }); const escaped = fake.authorizeUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const [printed] = await running.waitForErr(new RegExp(`${escaped}\\?\\S+`)); - const authorize = new URL(printed).searchParams; + let authorize: URLSearchParams; + try { + const [printed] = await running.waitForErr(new RegExp(`${escaped}\\?\\S+`)); + authorize = new URL(printed).searchParams; + } catch (err) { + // Otherwise the binary keeps its loopback port and outlives the + // scenario's home directory. + running.kill(); + throw err; + } const redirectUri = authorize.get("redirect_uri") ?? ""; const state = authorize.get("state") ?? ""; fake.armCode({ diff --git a/integration/hotels.test.ts b/integration/hotels.test.ts index 8464ffd..0e6de2c 100644 --- a/integration/hotels.test.ts +++ b/integration/hotels.test.ts @@ -74,7 +74,7 @@ const query = ( op: string, ) => Object.fromEntries(fake.requests(op)[0]?.query ?? []); -/** The #1534 rule: the API's request-scoped `*Source` copies inside `metadata` +/** The rule: the API's request-scoped `*Source` copies inside `metadata` * are stripped at print time, so the CLI's own top-level label is the ONE * `*Source` a payload carries per knob. The `locale` echo stays. */ function expectOneSourcePerKnob( @@ -465,7 +465,7 @@ describe("hotels search", () => { ); expect(printed.settled).toBe("converged"); // The one stderr line on a fresh machine is the currency-setting hint the - // search prints while no currency is stored (issue #1386). + // search prints while no currency is stored. expect(result.err.trim().split("\n")).toEqual([ expect.stringContaining("config set currency"), ]); @@ -489,8 +489,8 @@ describe("hotels search", () => { ["neither leaves it to the API's default", {}, [], "default", undefined], ] as const) { it(`names the layer the currency came from: ${rung}`, async () => { - // The create and the settle read must carry the SAME resolved currency - // (#1400), so the page is priced in the unit the search was created in. + // The create and the settle read must carry the SAME resolved currency, + // so the page is priced in the unit the search was created in. signIn(s.home); writeSettings(s.home, settings); const fake = s.fake({ @@ -599,7 +599,7 @@ describe("hotels search", () => { ); }); - it("converges on a steady candidate count while searchComplete stays false (#1084)", async () => { + it("converges on a steady candidate count while searchComplete stays false", async () => { signIn(s.home); const fake = s.fake({ routes: [ diff --git a/integration/login-more.test.ts b/integration/login-more.test.ts index 73a10e0..ae16fe7 100644 --- a/integration/login-more.test.ts +++ b/integration/login-more.test.ts @@ -16,7 +16,7 @@ import { problem } from "./harness/fake"; import { route } from "./harness/fixtures"; import { loginThroughBrowser } from "./harness/login"; import { useScenario } from "./harness/scenario"; -import { idToken, signIn, spawnWego } from "./harness/wego"; +import { idToken, signIn } from "./harness/wego"; const s = useScenario(); @@ -27,31 +27,13 @@ const failurePath = () => join(s.home.configDir, "last-auth-failure.json"); const idTokenExpiringIn = (ms: number) => idToken({ exp: Math.floor((Date.now() + ms) / 1000) }); -/** Start `login`, wait for the authorize URL, and deliver the callback the - * "browser" would, returning the finished run. */ -async function loginWith(args: string[], env: Record) { - const fake = s.fake(); - const running = spawnWego(["login", ...args], { - fake, - home: s.home, - env, - }); - const escaped = fake.authorizeUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const [printed] = await running.waitForErr(new RegExp(`${escaped}\\?\\S+`)); - const authorize = new URL(printed).searchParams; - const redirectUri = authorize.get("redirect_uri") ?? ""; - fake.armCode({ - code: "code-1", - codeChallenge: authorize.get("code_challenge") ?? "", - redirectUri, +/** Log in through the "browser" with login's own arguments and environment. */ +const loginWith = (args: string[], env: Record) => + loginThroughBrowser(s.fake(), s.home, { tokens: { access_token: "access-9" }, + args, + env, }); - const state = encodeURIComponent(authorize.get("state") ?? ""); - await fetch(`${redirectUri}?code=code-1&state=${state}`).catch( - () => undefined, - ); - return { fake, result: await running.result }; -} /** A stored session whose access token has expired, so the next call refreshes. */ function signInExpired(extra: { refreshToken?: string; idToken?: string }) { diff --git a/integration/skill-matches-cli.test.ts b/integration/skill-matches-cli.test.ts index 3ee887e..6b9e97e 100644 --- a/integration/skill-matches-cli.test.ts +++ b/integration/skill-matches-cli.test.ts @@ -54,15 +54,24 @@ export function longFlags(command: string): string[] { return [...new Set(command.match(/(?(); /** `wego --help`, falling back to the parent when a word is an argument - * rather than a subcommand (`config set currency`, `places London`). */ + * rather than a subcommand (`config set currency`, `places London`). The first + * word is never dropped: root help always answers, so falling back to it would + * pass a renamed top-level command. */ async function helpFor( path: string[], ): Promise<{ path: string[]; out: string } | undefined> { - for (let n = path.length; n >= 0; n -= 1) { + for (let n = path.length; n >= Math.min(1, path.length); n -= 1) { const candidate = path.slice(0, n); const key = candidate.join(" "); let result = helpCache.get(key); @@ -90,7 +99,7 @@ describe("the skill matches the CLI", () => { const help = await helpFor(commandPath(command)); expect(help, `no \`--help\` answers for: ${command}`).toBeDefined(); const missing = longFlags(command).filter( - (flag) => flag !== "--help" && !help?.out.includes(flag), + (flag) => flag !== "--help" && !listsFlag(help?.out ?? "", flag), ); expect( missing, @@ -130,4 +139,12 @@ describe("reading the skill", () => { "--locale", ]); }); + + it("finds a flag in help only as a whole token", () => { + const help = " --page-size Results per page\n --types "; + expect(listsFlag(help, "--page")).toBe(false); + expect(listsFlag(help, "--type")).toBe(false); + expect(listsFlag(help, "--page-size")).toBe(true); + expect(listsFlag("--sort=price", "--sort")).toBe(true); + }); }); diff --git a/scripts/next-report.test.ts b/scripts/next-report.test.ts index b349c21..79f0d07 100644 --- a/scripts/next-report.test.ts +++ b/scripts/next-report.test.ts @@ -278,6 +278,18 @@ describe("the release lane's wait", () => { ); }); + it("keeps a writer's title on one line, so it cannot start a workflow command", () => { + const titled = check({ + output: { title: "ok\n::stop-commands::x", summary: null, text: null }, + }); + expect(pollLine({ check: titled }, 60_000, false)).not.toContain("\n"); + const running = check({ + status: "in_progress", + details_url: "https://example.test/1\n::warning::x", + }); + expect(pollLine({ check: running }, 60_000, true)).not.toContain("\n"); + }); + it("does not look when the receiver is switched off (404)", async () => { const api = fakeApi(() => []); const clock = fakeClock(); diff --git a/scripts/next-report.ts b/scripts/next-report.ts index 03612a2..8074766 100644 --- a/scripts/next-report.ts +++ b/scripts/next-report.ts @@ -168,6 +168,8 @@ const EVALS: Record = { not_configured: "not configured", }; +/** One GitHub API request, well under the gap between two looks. */ +const REQUEST_TIMEOUT_MS = 30_000; const RELEASE_TAG = /^v\d+\.\d+\.\d+$/; const FULL_SHA = /^[0-9a-f]{40}$/; const FENCE = /```json[^\S\n]*\n([\s\S]*?)```/g; @@ -528,10 +530,14 @@ export function pollLine( const check = found.check; if (!check) return `${at}: not started yet`; if (check.status === "completed") { - return `${at}: completed: ${check.output?.title ?? check.conclusion ?? "no title"}`; + // The log is stdout, which the runner reads for workflow commands, so the + // writer's text is kept to one line (`cell`), as everywhere else it shows. + return `${at}: completed: ${cell(check.output?.title ?? check.conclusion ?? "no title")}`; } const link = - firstSeen && check.details_url ? `: wego-ai run ${check.details_url}` : ""; + firstSeen && check.details_url + ? `: wego-ai run ${cell(check.details_url)}` + : ""; return `${at}: in progress${link}`; } @@ -704,8 +710,13 @@ function github(env: Env, fetcher: Fetch) { "User-Agent": "wego-cli-next-report", "X-GitHub-Api-Version": "2022-11-28", }; + // A hung connection would otherwise hold one look past every deadline the + // wait keeps; timed out, it is one more failed look. const get = async (path: string): Promise => { - const res = await fetcher(`${base}${path}`, { headers }); + const res = await fetcher(`${base}${path}`, { + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); if (!res.ok) throw new Error(`GET ${path} answered HTTP ${res.status}`); return res.json(); }; diff --git a/scripts/unit-tier-guard.test.ts b/scripts/unit-tier-guard.test.ts index 442621a..9e2f2e6 100644 --- a/scripts/unit-tier-guard.test.ts +++ b/scripts/unit-tier-guard.test.ts @@ -17,7 +17,7 @@ import { describe, expect, it } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; -import { join, relative } from "node:path"; +import { join, posix, relative } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = fileURLToPath(new URL("../", import.meta.url)); @@ -58,26 +58,64 @@ function unitTestFiles(): string[] { return out.sort(); } -/** `import { a, type B, c as d } from ""` → the imported names. */ -export function importedNames(source: string, spec: RegExp): string[] { +/** Every module a test file pulls in: the static clause (`undefined` for a + * dynamic `import()` or `require`, which can reach any export) and the specifier. */ +export function imports( + source: string, +): { clause: string | undefined; spec: string }[] { + const found: { clause: string | undefined; spec: string }[] = []; + const staticRe = + /\b(?:import|export)\s+([^;"'`]*?)\s*from\s*["']([^"']+)["']/g; + for (const m of source.matchAll(staticRe)) { + found.push({ clause: (m[1] ?? "").trim(), spec: m[2] ?? "" }); + } + const bareRe = /\bimport\s*["']([^"']+)["']/g; + for (const m of source.matchAll(bareRe)) { + found.push({ clause: "", spec: m[1] ?? "" }); + } + const dynamicRe = /\b(?:import|require)\s*\(\s*["']([^"']+)["']\s*\)/g; + for (const m of source.matchAll(dynamicRe)) { + found.push({ clause: undefined, spec: m[1] ?? "" }); + } + return found; +} + +/** `{ a, type B, c as d }` → the value names it imports. */ +function namedImports(clause: string): string[] { const names: string[] = []; - const re = /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+"([^"]+)"/g; - for (const m of source.matchAll(re)) { - if (!spec.test(m[2] ?? "")) continue; - for (const raw of (m[1] ?? "").split(",")) { - const name = raw.trim(); - if (!name || name.startsWith("type ")) continue; - names.push(name.split(/\s+as\s+/)[0]?.trim() ?? name); - } + for (const raw of (/\{([^}]*)\}/.exec(clause)?.[1] ?? "").split(",")) { + const name = raw.trim(); + if (!name || name.startsWith("type ")) continue; + names.push(name.split(/\s+as\s+/)[0]?.trim() ?? name); } return names; } +/** The `src/` module a specifier names from `file`, whatever the directory depth + * and extension: `../commands` from `src/testing/x.test.ts` is `commands`. */ +function srcModule(file: string, spec: string): string | undefined { + if (!spec.startsWith(".")) return undefined; + const resolved = posix + .normalize(posix.join(posix.dirname(file.replaceAll("\\", "/")), spec)) + .replace(/\.(ts|js)$/, ""); + return resolved.startsWith("src/") + ? resolved.slice("src/".length) + : undefined; +} + +/** `path` is relative to the repository root, so a specifier resolves from it. */ export function violations(path: string, source: string): string[] { const found: string[] = []; - for (const [module, entries] of Object.entries(ENTRY_POINTS)) { - const spec = new RegExp(`^(\\./|\\.\\./src/)${module}$`); - for (const name of importedNames(source, spec)) { + for (const { clause, spec } of imports(source)) { + const module = srcModule(path, spec); + const entries = module ? ENTRY_POINTS[module] : undefined; + if (!module || !entries || clause?.startsWith("type ")) continue; + // A namespace, default, bare or dynamic import reaches every export. + if (clause === undefined || !/^\{[^}]*\}$/.test(clause)) { + found.push(`${path} imports all of ${module}`); + continue; + } + for (const name of namedImports(clause)) { if (entries.includes(name)) found.push(`${path} imports ${name} from ${module}`); } @@ -102,27 +140,50 @@ describe("unit tests stay below the command boundary", () => { it("catches each way in", () => { expect( violations( - "x.test.ts", + "src/x.test.ts", [ 'import { run } from "./index";', 'import { parseLoginArgs, login as doLogin } from "./commands";', 'import { config } from "./config-command";', 'import { runCli } from "./testing/cli-runner";', + 'import * as cmd from "./commands";', + 'const t = await import("./telemetry-command");', + 'import { hotels } from "./commands.ts";', ].join("\n"), ), ).toEqual([ - "x.test.ts imports run from index", - "x.test.ts imports login from commands", - "x.test.ts imports config from config-command", - "x.test.ts imports the in-process CLI runner", + "src/x.test.ts imports run from index", + "src/x.test.ts imports login from commands", + "src/x.test.ts imports config from config-command", + "src/x.test.ts imports all of commands", + "src/x.test.ts imports hotels from commands", + "src/x.test.ts imports all of telemetry-command", + "src/x.test.ts imports the in-process CLI runner", ]); + // From any directory depth. + expect( + violations( + "src/testing/x.test.ts", + 'import { whoami } from "../commands";', + ), + ).toEqual(["src/testing/x.test.ts imports whoami from commands"]); + expect( + violations( + "scripts/sub/x.test.ts", + 'import { run } from "../../src/index";', + ), + ).toEqual(["scripts/sub/x.test.ts imports run from index"]); }); it("lets a parser and a type through", () => { expect( violations( - "x.test.ts", - 'import { type FlightsDeps, parseFlightSearchArgs } from "./commands";', + "src/x.test.ts", + [ + 'import { type FlightsDeps, parseFlightSearchArgs } from "./commands";', + 'import type * as Commands from "./commands";', + 'import { run } from "./other/index";', + ].join("\n"), ), ).toEqual([]); }); diff --git a/src/error-report.test.ts b/src/error-report.test.ts index 8487032..b9ea2a0 100644 --- a/src/error-report.test.ts +++ b/src/error-report.test.ts @@ -114,6 +114,18 @@ describe("isTimeoutError", () => { }); describe("formatCliError (actionable stderr line)", () => { + it("names the auth server and why it could not be reached", () => { + const msg = formatCliError( + new TokenEndpointUnreachableError( + "https://auth.example.test/token", + new Error("certificate has expired"), + ), + "wego", + ); + expect(msg).toContain("https://auth.example.test/token"); + expect(msg).toContain("(certificate has expired)"); + }); + it("includes code, detail, trace_id, and Retry-After for an API error", () => { const msg = formatCliError( new ApiHttpError(503, "GET /v1/flights/searches/:id/results", { diff --git a/src/error-report.ts b/src/error-report.ts index 262376a..4e9b69a 100644 --- a/src/error-report.ts +++ b/src/error-report.ts @@ -181,7 +181,10 @@ export function formatCliError(err: unknown, prog: string): string { return `Could not reach the Wego API at ${err.url} – is the local \`apps/api\` running (\`bun dev\`), or check WEGO_API_URL and your network connection.`; } if (err instanceof TokenEndpointUnreachableError) { - return `Could not reach the auth server at ${err.url} – check WEGO_AUTH_TOKEN_URL and your network connection.`; + // The cause tells a refused connection from DNS, TLS, a proxy or the deadline. + const cause = + err.cause instanceof Error ? err.cause.message : String(err.cause); + return `Could not reach the auth server at ${err.url} – check WEGO_AUTH_TOKEN_URL and your network connection. (${cause})`; } if (isTimeoutError(err)) { return "Request timed out. The API did not respond within the deadline – retry the command (re-poll with the same searchId)."; From 6c2d94e5d2c72466b77a45094f1ead5459cea4be Mon Sep 17 00:00:00 2001 From: sunny-wego Date: Thu, 24 Sep 2026 01:44:32 +0800 Subject: [PATCH 4/5] fix(release): gate promote on the publishing jobs, not the report jobs A red notify-verify (a receiver outage, or a 401 before wego-ai deploys) made the release run's conclusion failure, and next-report held the run in progress for up to 45 minutes. promote-cli.yml required a successful run, so either one held back a stable promote, a fix-forward included. The promote gate now reads the release run job by job. Every job must have succeeded and the publishing job must be present, except the two report-only jobs, which are named and pinned to release-cli.yml by workflow-lanes.test.ts. Checked against the v1.3.1 run's job names. Also from the joint audit with wego-ai: - next-report runs on !cancelled(), not always(), so a cancelled release does not keep it waiting. - notify-verify's comment on curl --retry was wrong: it retries 5xx too. It is safe once the receiver spends the jti only after a dispatch. - A failed token refresh names its cause again: the cause is in TokenEndpointUnreachableError's own message. - update, uninstall and skill answer --help, and skill list runs, with no backend configured. - Remove the smoke table's dead evals row code, and fix the doc comment order in oauth.ts. - docs/release.md: the promote gate, notify-verify's refusal row, and the integration matrix's lack of an override. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016LEU9d3f2jvK3SFUew6iCp --- .github/workflows/promote-cli.yml | 47 +++++++++++++++++++++++++------ .github/workflows/release-cli.yml | 23 ++++++++------- docs/release.md | 6 ++-- integration/cli.test.ts | 10 +++++++ scripts/next-report.ts | 16 ----------- scripts/workflow-lanes.test.ts | 39 +++++++++++++++++++++++++ scripts/workflow-shape.test.ts | 10 ++++--- src/error-report.ts | 6 ++-- src/oauth.test.ts | 14 +++++++++ src/oauth.ts | 34 +++++++++++++--------- 10 files changed, 147 insertions(+), 58 deletions(-) diff --git a/.github/workflows/promote-cli.yml b/.github/workflows/promote-cli.yml index d992cdb..9ed9fbd 100644 --- a/.github/workflows/promote-cli.yml +++ b/.github/workflows/promote-cli.yml @@ -246,14 +246,26 @@ jobs: echo "rollback target: $tag via $lane" # `cli//` can exist half-written if the release lane did not finish. - # Require one completed, successful `release-cli.yml` run for this tag. - # `branch=` is how the Actions API filters a tag-push run. - # `github-script`, not `gh`: one shape across the lanes. + # Require one `release-cli.yml` run for this tag in which the publishing job + # ran and every job succeeded, apart from the two report-only jobs. + # + # Job by job, not the run's conclusion: `notify-verify` goes red when wego-ai's + # receiver refuses or is down, and `next-report` keeps the run in progress for + # up to 45 min. Neither says anything about the bytes, and gating on the run + # would let a receiver outage hold back a fix-forward promote. Any other job, + # including one added later, is gated by default. `scripts/workflow-lanes.test.ts` + # pins both names below to the jobs in `release-cli.yml`. + # + # `branch=` is how the Actions API filters a tag-push run. `filter: + # latest` reads a re-run's latest attempt. `github-script`, not `gh`: one + # shape across the lanes. - name: Require a completed, successful release run for the tag uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const tag = process.env.TAG; + const REPORT_ONLY = new Set(["Request release verification", "next report"]); + const PUBLISH = "Publish CLI binaries to cli/next"; const { data } = await github.rest.actions.listWorkflowRuns({ owner: context.repo.owner, repo: context.repo.repo, @@ -261,16 +273,35 @@ jobs: branch: tag, per_page: 100, }); - const ok = data.workflow_runs.filter( - (r) => r.status === "completed" && r.conclusion === "success", - ).length; + let ok = 0; + for (const run of data.workflow_runs) { + const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, { + owner: context.repo.owner, + repo: context.repo.repo, + run_id: run.id, + filter: "latest", + per_page: 100, + }); + const gated = jobs.filter((j) => !REPORT_ONLY.has(j.name)); + const published = gated.some((j) => j.name === PUBLISH); + const failing = gated.filter( + (j) => j.status !== "completed" || j.conclusion !== "success", + ); + if (published && failing.length === 0) { + ok += 1; + } else { + core.info( + `run ${run.id}: ${published ? "" : `no "${PUBLISH}" job; `}${failing.map((j) => `${j.name}=${j.conclusion ?? j.status}`).join(", ")}`, + ); + } + } if (ok < 1) { core.setFailed( - `No completed, successful release-cli.yml run for ${tag}. cli/stable serves every install, so it is only ever advanced onto bytes a release run finished and verified. Re-run or fix the release for ${tag} first.`, + `No release-cli.yml run for ${tag} in which every publishing job completed successfully. cli/stable serves every install, so it is only ever advanced onto bytes a release run finished and verified. Re-run or fix the release for ${tag} first.`, ); return; } - core.info(`ok: release-cli.yml completed successfully for ${tag} (${ok} run(s)).`); + core.info(`ok: release-cli.yml published and verified ${tag} (${ok} run(s)). Report-only jobs are not gated.`); env: TAG: ${{ inputs.tag }} diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 8e715bb..504fdaf 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -33,7 +33,9 @@ name: Release (cli) # bytes are published and `cli/next` has moved by the time it runs. It carries no # `continue-on-error` on purpose: a request that silently did not happen is worse # than a red job, because the promote banner would then show "No report" for a -# reason nobody saw. +# reason nobody saw. A red here makes the run's conclusion `failure`, so +# `promote-cli.yml` reads this run job by job and leaves out `notify-verify` and +# `next-report`: a receiver outage must not hold back a promote. # # `next-report` IS NEVER RED. It waits for wego-ai's `cli-next-smoke` check, looks # once at its `cli-next-evals` check, and writes both into this run's summary, for @@ -697,14 +699,15 @@ jobs: # dispatched and is answered 409. Failing on that would report "verification # not requested" for a verification that was requested and is running. # - # `--retry` therefore covers transport only: without `--fail`, curl exits 0 - # on any HTTP status, so nothing but a connection-level error is retried. + # `--retry` retries a connection error, a timeout, and an HTTP 408, 429 or + # 5xx answer, reusing the same JWT and so the same `jti`. That is safe + # because the receiver spends the `jti` only once the dispatch succeeded: a + # 5xx left it unspent, so the retry is a fresh attempt, and a 409 still + # means a first attempt dispatched and its answer was lost. # - # The root cause is that `--retry` REUSES the JWT, so every attempt carries - # the same `jti` and a 409 cannot be told from a first attempt's. Minting a - # fresh token per attempt would remove the 409 entirely and make one worth - # failing on. Not done: it buys a distinction nobody acts on, and the - # receiver's guard is the thing keeping a replay from dispatching twice. + # Minting a fresh token per attempt would remove the 409 entirely and make + # one worth failing on. Not done: it buys a distinction nobody acts on, and + # the receiver's guard is the thing keeping a replay from dispatching twice. # # `jq -c` prints exactly `{"tag":"vX.Y.Z","sha":"<40 hex>"}`, and `--arg` # makes both values jq data rather than jq program text. @@ -721,7 +724,7 @@ jobs: # Switched off is a state, not a fault: a notice, so it reads as # "not verified" without a red release. 404) echo "::notice::the receiver is switched off (404), so $TAG was not verified. Re-run this job once it is on." ;; - *) echo "::error::the receiver refused the request for $TAG (HTTP $CODE). $TAG is published and cli/next has moved. This job asks for verification and nothing that publishes depends on it. Re-run this job once the reason above is fixed." + *) echo "::error::the receiver refused the request for $TAG (HTTP $CODE). $TAG is published and cli/next has moved. promote-cli.yml does not wait on this job, so $TAG can still be promoted. Re-run this job once the reason above is fixed to get its report." exit 1 ;; esac @@ -748,7 +751,7 @@ jobs: next-report: name: next report needs: [prepare, notify-verify] - if: always() && (needs.notify-verify.result == 'success' || needs.notify-verify.result == 'failure') + if: ${{ !cancelled() && (needs.notify-verify.result == 'success' || needs.notify-verify.result == 'failure') }} continue-on-error: true runs-on: ubuntu-latest timeout-minutes: 55 diff --git a/docs/release.md b/docs/release.md index 84fc3f9..e143571 100644 --- a/docs/release.md +++ b/docs/release.md @@ -55,7 +55,7 @@ Fires on `push: tags: ['v*']`. Ten jobs: |---|---|---| | `prepare` | ubuntu | Refuses a tag that is not on `main`, then lint, format, typecheck and unit tests | | `build` | ubuntu | One job, not a matrix: `bun build --compile` cross-compiles all five targets in it. **No `environment:`**, deliberately, so it cannot reach the store token | -| `integration ()` | one per target | The integration tier (`integration/`) against the binary `build` produced for that target, on that target's own runner: linux x64 and arm64, macOS arm64 and Intel, Windows. `release` **needs** all five, so a binary that fails its scenarios is never published | +| `integration ()` | one per target | The integration tier (`integration/`) against the binary `build` produced for that target, on that target's own runner: linux x64 and arm64, macOS arm64 and Intel, Windows. `release` **needs** all five, so a binary that fails its scenarios is never published. There is no override, by design: a flaky scenario or a stuck runner holds `cli/next` until **Re-run failed jobs** on the run passes it, and a real failure is fixed and re-tagged | | `sign` | ubuntu | Signs `SHA256SUMS.txt` with keyless cosign. **No `environment:`** either. The job that can sign cannot write the store, and the job that writes the store cannot sign | | `leave-macos` | macOS | Runs the pre-publication checks against `wego-darwin-arm64`. `release` **needs** it, so darwin gates publication rather than reporting after it | | `release` | ubuntu | `environment: release`, `concurrency: group: ring-next` with `cancel-in-progress: false`, because a cancel mid-copy is a half-moved ring. The only job that advances a ring. **`contents: read`** – it holds the store token, so it must not also hold repository write | @@ -172,7 +172,7 @@ wego-ai; the check carries only the verdict and a part table. | `202` | Started | passes | | `409` | A retry replayed a token the receiver had already spent, so the first attempt started it | passes, with a notice | | `404` | The receiver is switched off | passes, with a notice: **this release was not verified** | -| anything else | Refused | **fails**. Nothing depends on it, the release is already published, but a refused request is a broken pipeline and should be loud | +| anything else | Refused | **fails**, because a refused request is a broken pipeline and should be loud. The release is already published, and nothing waits on this job: the promote gate reads the release run job by job and leaves out `notify-verify` and `next-report`, so a receiver outage never holds back a promote, a fix-forward included | ### Reading it @@ -316,7 +316,7 @@ Before the move: |---|---| | Validate the tag | A tag that is not a plain release version | | Record the rollback target | Nothing, but it reads what `cli/stable` serves **now**, because the move is what destroys that answer. Every failure message after this point names the tag to put back, and the lane it belongs to: `1.0.x` and `1.1.0` are `cli-vX.Y.Z` in wego-ai's lane, `1.2.0` and later are `vX.Y.Z` through `rollback-cli.yml` here | -| Require a completed, successful release run | A tag whose release lane never finished, so `cli//` may be half-written | +| Require a completed, successful release run | A tag whose release lane never finished, so `cli//` may be half-written. Read job by job: every job must have succeeded, the publishing job included, except the report-only `notify-verify` and `next-report`, so a receiver outage or a report still being waited for does not hold a promote back | | Refuse a tag whose tree cannot publish the plugin | A promote that succeeds having silently published nothing. Checked before the ring moves, for that reason | | Require the legacy bridge pin to be live | Moving `cli/stable` while the pin is down, which strands every 1.0.x install permanently | | Walk the pre-relay route | The pin *pointing* somewhere without anything being there. The check above reads one header; this one walks the whole 1.0.x route, pin then frozen prefix then live pointer, against the ring as it stands. A collected object or an expired certificate passes the header probe and strands the same installs | diff --git a/integration/cli.test.ts b/integration/cli.test.ts index 6186699..d60b5a6 100644 --- a/integration/cli.test.ts +++ b/integration/cli.test.ts @@ -100,6 +100,10 @@ describe("the entrypoint", () => { ["info", "holidays", "help"], ["places", "--help"], ["feedback", "--help"], + // The ways out of a bad install must answer even when nothing else can. + ["update", "--help"], + ["uninstall", "--help"], + ["skill", "--help"], ]) { const result = await s.run(args, { env: NO_BACKEND }); expect({ args, code: result.code }).toEqual({ args, code: 0 }); @@ -107,6 +111,12 @@ describe("the entrypoint", () => { } }); + it("lists the skill's installs with no backend configured", async () => { + const result = await s.run(["skill", "list"], { env: NO_BACKEND }); + expect(result.code).toBe(0); + expect(result.err).not.toMatch(/is required for source usage/); + }); + it.skipIf(baked)( "reads a flag value that happens to be `help` as a value, not a help request", async () => { diff --git a/scripts/next-report.ts b/scripts/next-report.ts index 8074766..4679b55 100644 --- a/scripts/next-report.ts +++ b/scripts/next-report.ts @@ -158,14 +158,6 @@ const LABELS: Record = { errors: "Error responses", search: "Search round trips", startup_ms: "Startup", - evals: "Skill evals", -}; - -const EVALS: Record = { - ok: "ok", - below: "below", - not_run: "not run", - not_configured: "not configured", }; /** One GitHub API request, well under the gap between two looks. */ @@ -306,14 +298,6 @@ function partCells(part: Part): [string, string] { v === undefined ? "–" : cell(`${v} ms`); return [ms(part.previous), ms(part.value)]; } - if (part.id === "evals") { - return [ - part.previous === undefined - ? "–" - : cell(EVALS[String(part.previous)] ?? String(part.previous)), - cell(EVALS[part.result] ?? part.result), - ]; - } return [stepCell(part, "previous"), stepCell(part, "current")]; } diff --git a/scripts/workflow-lanes.test.ts b/scripts/workflow-lanes.test.ts index a373b8e..7b99fdb 100644 --- a/scripts/workflow-lanes.test.ts +++ b/scripts/workflow-lanes.test.ts @@ -636,3 +636,42 @@ describe("release-cli.yml: integration runs every built target before publicatio expect(ancestryOf("integration", wf.jobs)).not.toContain("release"); }); }); + +/** + * PROMOTE GATES ON THE PUBLISHING JOBS, NOT ON THE REPORTS. + * + * `promote-cli.yml` reads the release run job by job and leaves out two jobs by + * their display names, because `notify-verify` goes red on a receiver outage and + * `next-report` keeps the run in progress for up to 45 min. The names are strings + * in a script, so a rename in `release-cli.yml` would silently gate on the reports + * again (the old failure) or, worse, find no publishing job and refuse every + * promote. Both directions are pinned here. + */ +describe("promote-cli.yml: the release gate names real jobs", () => { + const release = workflow("release-cli.yml") as { + jobs: Record; + }; + const promote = workflow("promote-cli.yml") as { + jobs: Record< + string, + { steps?: { name?: string; with?: { script?: string } }[] } + >; + }; + const gate = + Object.values(promote.jobs) + .flatMap((j) => j.steps ?? []) + .find( + (s) => + s.name === "Require a completed, successful release run for the tag", + )?.with?.script ?? ""; + + it.each([ + ["notify-verify", "report-only"], + ["next-report", "report-only"], + ["release", "the publishing job"], + ])("%s's display name is the one the gate uses (%s)", (id) => { + const name = release.jobs[id]?.name; + expect(name).toBeDefined(); + expect(gate).toContain(`"${name}"`); + }); +}); diff --git a/scripts/workflow-shape.test.ts b/scripts/workflow-shape.test.ts index 13708a6..f47295a 100644 --- a/scripts/workflow-shape.test.ts +++ b/scripts/workflow-shape.test.ts @@ -502,12 +502,14 @@ describe("release-cli.yml: next-report runs whenever notify-verify ran", () => { const job = lane("release-cli.yml").jobs["next-report"] as Job | undefined; it("waits for notify-verify, red included, and only when it ran", () => { - // Without `always()` a refused request (a red notify-verify) would skip the - // one job that says why there is no report. Without the result check it - // would also run on a release that never published, and report on nothing. + // Without `!cancelled()` a refused request (a red notify-verify) would skip + // the one job that says why there is no report; `always()` would also keep it + // waiting up to 55 min after someone cancelled the run. Without the result + // check it would run on a release that never published, and report on nothing. expect(job?.needs).toContain("notify-verify"); const cond = (job?.if ?? "").replace(/\s+/g, " "); - expect(cond).toContain("always()"); + expect(cond).toContain("!cancelled()"); + expect(cond).not.toContain("always()"); expect(cond).toContain("needs.notify-verify.result == 'failure'"); expect(cond).toContain("needs.notify-verify.result == 'success'"); }); diff --git a/src/error-report.ts b/src/error-report.ts index 4e9b69a..5c53c26 100644 --- a/src/error-report.ts +++ b/src/error-report.ts @@ -5,7 +5,7 @@ import { UnauthorizedError, } from "./api"; import type { ProblemCode } from "./api-wire"; -import { TokenEndpointUnreachableError } from "./oauth"; +import { causeText, TokenEndpointUnreachableError } from "./oauth"; import { SettingsFileError } from "./settings"; /** @@ -182,9 +182,7 @@ export function formatCliError(err: unknown, prog: string): string { } if (err instanceof TokenEndpointUnreachableError) { // The cause tells a refused connection from DNS, TLS, a proxy or the deadline. - const cause = - err.cause instanceof Error ? err.cause.message : String(err.cause); - return `Could not reach the auth server at ${err.url} – check WEGO_AUTH_TOKEN_URL and your network connection. (${cause})`; + return `Could not reach the auth server at ${err.url} – check WEGO_AUTH_TOKEN_URL and your network connection. (${causeText(err.cause)})`; } if (isTimeoutError(err)) { return "Request timed out. The API did not respond within the deadline – retry the command (re-poll with the same searchId)."; diff --git a/src/oauth.test.ts b/src/oauth.test.ts index 326c89c..62d9194 100644 --- a/src/oauth.test.ts +++ b/src/oauth.test.ts @@ -9,6 +9,7 @@ import { redactSecrets, refreshTokens, TokenEndpointError, + TokenEndpointUnreachableError, } from "./oauth"; import { loadTestCliConfig } from "./test-config"; @@ -116,6 +117,19 @@ describe("token endpoint calls", () => { globalThis.fetch = realFetch; }); + it("a refresh that never reaches the endpoint says why, in its message", async () => { + // A failed refresh prints only the message, so the cause must be in it. + globalThis.fetch = (() => + Promise.reject( + new Error("unable to get local issuer certificate"), + )) as unknown as typeof fetch; + const err = await refreshTokens(config, "rt").catch((e) => e); + expect(err).toBeInstanceOf(TokenEndpointUnreachableError); + expect(err.message).toBe( + "could not reach the auth server at https://auth.wego.com/user-auth/v2/users/oauth/token (unable to get local issuer certificate)", + ); + }); + it("exchangeCode posts the authorization_code grant form-encoded", async () => { let captured: { url: string; body: string } | undefined; globalThis.fetch = ((url: string, init: RequestInit) => { diff --git a/src/oauth.ts b/src/oauth.ts index 6f5769e..d70fa31 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -116,18 +116,6 @@ export function parseTokenResponse(json: unknown, now = Date.now()): TokenSet { }; } -/** - * A non-2xx response from the token endpoint, carrying the auth server's own - * OAuth2 error rather than collapsing every distinct cause to one status line. - * Before this, a failed refresh reached the user as `token endpoint failed: 400` - * and nothing said whether the token was revoked, expired, or rotated away, so - * a lost session was undiagnosable after the fact (investigation #1360, H5). - * - * The refresh token is NEVER captured here. Only the non-secret RFC 6749 §5.2 - * error fields, and a bounded snippet of the raw body for the rare non-OAuth2 - * failure (a captive portal, a 5xx HTML page) — and the snippet stays off - * stderr, reached only by the local failure record. - */ /** * The token endpoint was never reached: connection refused, DNS, TLS, reset, or * the deadline. Typed here rather than left as whatever `fetch` threw, because @@ -136,17 +124,37 @@ export function parseTokenResponse(json: unknown, now = Date.now()): TokenSet { * failure, so the same unreachable auth server exited 7 on one and 2 on the other * (found by `integration/login-more.test.ts` on the Linux runner). The api * client wraps its own `fetch` the same way (`ApiUnreachableError`). + * + * The message carries the cause, because a failed refresh prints only the + * message: it is what tells a refused connection from DNS, TLS or a proxy. */ export class TokenEndpointUnreachableError extends Error { constructor( readonly url: string, readonly cause: unknown, ) { - super(`could not reach the auth server at ${url}`); + super(`could not reach the auth server at ${url} (${causeText(cause)})`); this.name = "TokenEndpointUnreachableError"; } } +/** A thrown value's own words, for a message. */ +export function causeText(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** + * A non-2xx response from the token endpoint, carrying the auth server's own + * OAuth2 error rather than collapsing every distinct cause to one status line. + * Before this, a failed refresh reached the user as `token endpoint failed: 400` + * and nothing said whether the token was revoked, expired, or rotated away, so + * a lost session was undiagnosable after the fact (investigation #1360, H5). + * + * The refresh token is NEVER captured here. Only the non-secret RFC 6749 §5.2 + * error fields, and a bounded snippet of the raw body for the rare non-OAuth2 + * failure (a captive portal, a 5xx HTML page) — and the snippet stays off + * stderr, reached only by the local failure record. + */ export class TokenEndpointError extends Error { readonly status: number; readonly statusText: string; From f556a342363402d64d7a73f7324b43be926a3e56 Mon Sep 17 00:00:00 2001 From: sunny-wego Date: Thu, 24 Sep 2026 12:11:27 +0800 Subject: [PATCH 5/5] fix(release): break legacy ##[ commands in next-report log lines The runner finds a legacy ##[ workflow command anywhere in a stdout line, not only at its start, so a check title carrying one could act on the log even after cell() made it one line. cell() now splits the ## with a zero-width space, which covers the poll lines and the summary echoed to the log. Also drop the receiver's owner from a promote-cli.yml comment. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016LEU9d3f2jvK3SFUew6iCp --- .github/workflows/promote-cli.yml | 13 +++++++------ scripts/next-report.test.ts | 9 +++++++++ scripts/next-report.ts | 13 +++++++++++-- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/.github/workflows/promote-cli.yml b/.github/workflows/promote-cli.yml index 9ed9fbd..1de1745 100644 --- a/.github/workflows/promote-cli.yml +++ b/.github/workflows/promote-cli.yml @@ -249,12 +249,13 @@ jobs: # Require one `release-cli.yml` run for this tag in which the publishing job # ran and every job succeeded, apart from the two report-only jobs. # - # Job by job, not the run's conclusion: `notify-verify` goes red when wego-ai's - # receiver refuses or is down, and `next-report` keeps the run in progress for - # up to 45 min. Neither says anything about the bytes, and gating on the run - # would let a receiver outage hold back a fix-forward promote. Any other job, - # including one added later, is gated by default. `scripts/workflow-lanes.test.ts` - # pins both names below to the jobs in `release-cli.yml`. + # Job by job, not the run's conclusion: `notify-verify` goes red when the + # verification receiver refuses or is down, and `next-report` keeps the run + # in progress for up to 45 min. Neither says anything about the bytes, and + # gating on the run would let a receiver outage hold back a fix-forward + # promote. Any other job, including one added later, is gated by default. + # `scripts/workflow-lanes.test.ts` pins both names below to the jobs in + # `release-cli.yml`. # # `branch=` is how the Actions API filters a tag-push run. `filter: # latest` reads a re-run's latest attempt. `github-script`, not `gh`: one diff --git a/scripts/next-report.test.ts b/scripts/next-report.test.ts index 79f0d07..b9c0c91 100644 --- a/scripts/next-report.test.ts +++ b/scripts/next-report.test.ts @@ -290,6 +290,15 @@ describe("the release lane's wait", () => { expect(pollLine({ check: running }, 60_000, true)).not.toContain("\n"); }); + it("breaks a legacy ##[ command in a writer's title, which the runner finds mid-line", () => { + const titled = check({ + output: { title: "ok ##[stop-commands]x", summary: null, text: null }, + }); + const line = pollLine({ check: titled }, 60_000, false); + expect(line).not.toContain("##["); + expect(line).toContain("stop-commands]x"); + }); + it("does not look when the receiver is switched off (404)", async () => { const api = fakeApi(() => []); const clock = fakeClock(); diff --git a/scripts/next-report.ts b/scripts/next-report.ts index 4679b55..1684223 100644 --- a/scripts/next-report.ts +++ b/scripts/next-report.ts @@ -175,9 +175,18 @@ export function commandMessage(text: string): string { return text.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); } -/** A table cell: one line, and no `|` to split the row. */ +/** + * A table cell: one line, and no `|` to split the row. Also printed to stdout, + * which the runner reads for workflow commands: one line stops a `::` command, + * which must start the line, but the legacy `##[` form is found anywhere in it, + * so a zero-width space splits its `##`. + */ function cell(text: string): string { - return text.replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim(); + return text + .replace(/\r?\n/g, " ") + .replace(/\|/g, "\\|") + .replace(/##\[/g, "#​#[") + .trim(); } /**