diff --git a/.agents/skills/cli-tests/SKILL.md b/.agents/skills/cli-tests/SKILL.md new file mode 100644 index 0000000..183c634 --- /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" }`, `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 + 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..790904c 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, 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..1de1745 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 @@ -198,14 +246,27 @@ 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 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 + # 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, @@ -213,16 +274,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 4de0a13..504fdaf 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -20,9 +20,27 @@ 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. 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 +# 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 +61,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 +322,97 @@ 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 + + # 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' + 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 }} + WEGO_INTEGRATION_MANIFEST: ${{ github.workspace }}/dist/SHA256SUMS.txt + 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 +628,160 @@ 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` 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. + # + # 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. 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 + + # 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: ${{ !cancelled() && (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 +893,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..9c2a926 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -245,9 +245,18 @@ 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 ``` +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..e143571 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. 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 | | `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 + 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 + 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**, 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 + +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 @@ -205,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/README.md b/integration/README.md new file mode 100644 index 0000000..2fb9af0 --- /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. 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..d60b5a6 --- /dev/null +++ b/integration/cli.test.ts @@ -0,0 +1,149 @@ +/** + * 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"], + // 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 }); + expect(result.out).toMatch(/Usage:/); + } + }); + + 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 () => { + // `--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..a85a60d --- /dev/null +++ b/integration/contract.test.ts @@ -0,0 +1,150 @@ +/** + * The contract check can fail. A validator that passes everything would make every + * 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, + 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"', + ]); + // 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 () => { + 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(); + } + }); +}); + +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 new file mode 100644 index 0000000..d53edda --- /dev/null +++ b/integration/errors.test.ts @@ -0,0 +1,132 @@ +/** + * 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(); + } + }); + + 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 + // 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..f120e0f --- /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. + 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 () => { + // 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")] }); + 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. + 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..b61baac --- /dev/null +++ b/integration/harness/binary.ts @@ -0,0 +1,145 @@ +/** + * 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. 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. + * + * 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, + readFileSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +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 { + 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`); + } + } + 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 = 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. + 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 = binaryDir(); + 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..b52c2e4 --- /dev/null +++ b/integration/harness/contract.ts @@ -0,0 +1,349 @@ +/** + * 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. 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 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`); + } + 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..11f3672 --- /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 } + /** 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 { + /** 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" }, + }); +} + +/** 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(); + }, + }, + }); + 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) { + return new Response("Bad gateway", { + status: 200, + headers: { "content-type": "text/html" }, + }); + } + 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..a8570cc --- /dev/null +++ b/integration/harness/login.ts @@ -0,0 +1,69 @@ +/** + * 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 }; + /** Login's arguments: `--no-browser` unless a scenario says otherwise. */ + args?: string[]; + env?: Record; + }, +): Promise { + const running = spawnWego(["login", ...(opts.args ?? ["--no-browser"])], { + fake, + home, + env: opts.env, + }); + const escaped = fake.authorizeUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + 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({ + 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..0e6de2c --- /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 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. + 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, + // 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", 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..ae16fe7 --- /dev/null +++ b/integration/login-more.test.ts @@ -0,0 +1,338 @@ +/** + * 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 } 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) }); + +/** 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, + }); + +/** 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..6b9e97e --- /dev/null +++ b/integration/skill-matches-cli.test.ts @@ -0,0 +1,150 @@ +/** + * 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`). 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 >= Math.min(1, path.length); 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" && !listsFlag(help?.out ?? "", 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", + ]); + }); + + 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/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..b9c0c91 --- /dev/null +++ b/scripts/next-report.test.ts @@ -0,0 +1,581 @@ +/** + * 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("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("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(); + 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..1684223 --- /dev/null +++ b/scripts/next-report.ts @@ -0,0 +1,837 @@ +/** + * 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", +}; + +/** 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; + +/** + * 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. 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, "\\|") + .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)]; + } + 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") { + // 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 ${cell(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", + }; + // 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, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + 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..9e2f2e6 --- /dev/null +++ b/scripts/unit-tier-guard.test.ts @@ -0,0 +1,190 @@ +/** + * 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, posix, 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(); +} + +/** 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[] = []; + 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 { 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}`); + } + } + 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( + "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([ + "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( + "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/scripts/workflow-lanes.test.ts b/scripts/workflow-lanes.test.ts index 977dd8a..7b99fdb 100644 --- a/scripts/workflow-lanes.test.ts +++ b/scripts/workflow-lanes.test.ts @@ -487,3 +487,191 @@ 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"); + }); +}); + +/** + * 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 741d3c2..f47295a 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,283 @@ 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 `!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("!cancelled()"); + expect(cond).not.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/error-report.test.ts b/src/error-report.test.ts index dc74520..b9ea2a0 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", () => { @@ -103,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 a7c9e55..5c53c26 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 { causeText, 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,10 @@ 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) { + // The cause tells a refused connection from DNS, TLS, a proxy or the deadline. + 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/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/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 2cb9964..d70fa31 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -116,6 +116,33 @@ export function parseTokenResponse(json: unknown, now = Date.now()): TokenSet { }; } +/** + * 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`). + * + * 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} (${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. @@ -327,12 +354,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 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"] }