From d7d0b56221d14cf25d59d3cdde2bc84ee38f15c2 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 16 May 2026 18:21:49 +0000 Subject: [PATCH] Add optional live E2E smoke tests --- .github/workflows/live-e2e.yml | 32 ++++ README.md | 29 ++- package.json | 1 + tests/live-e2e.test.ts | 315 +++++++++++++++++++++++++++++++++ 4 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/live-e2e.yml create mode 100644 tests/live-e2e.test.ts diff --git a/.github/workflows/live-e2e.yml b/.github/workflows/live-e2e.yml new file mode 100644 index 0000000..03cfc1e --- /dev/null +++ b/.github/workflows/live-e2e.yml @@ -0,0 +1,32 @@ +name: Live E2E + +on: + workflow_dispatch: + +jobs: + live-e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: yarn + - run: yarn install --frozen-lockfile + - name: Verify required live E2E secrets + env: + VOOSH_E2E_BASE_URL: ${{ secrets.VOOSH_E2E_BASE_URL }} + VOOSH_E2E_CHRIS_TOKEN: ${{ secrets.VOOSH_E2E_CHRIS_TOKEN }} + run: | + test -n "$VOOSH_E2E_BASE_URL" || { echo "Missing required secret VOOSH_E2E_BASE_URL" >&2; exit 1; } + test -n "$VOOSH_E2E_CHRIS_TOKEN" || { echo "Missing required secret VOOSH_E2E_CHRIS_TOKEN" >&2; exit 1; } + - name: Run live E2E smoke tests + env: + VOOSH_RUN_LIVE_E2E: '1' + VOOSH_E2E_BASE_URL: ${{ secrets.VOOSH_E2E_BASE_URL }} + VOOSH_E2E_CHRIS_TOKEN: ${{ secrets.VOOSH_E2E_CHRIS_TOKEN }} + VOOSH_E2E_OTTO_TOKEN: ${{ secrets.VOOSH_E2E_OTTO_TOKEN }} + VOOSH_E2E_ORG_ID: ${{ secrets.VOOSH_E2E_ORG_ID }} + VOOSH_E2E_CALENDAR_ID: ${{ secrets.VOOSH_E2E_CALENDAR_ID }} + VOOSH_E2E_BOOKMARK_CALENDAR_ID: ${{ secrets.VOOSH_E2E_BOOKMARK_CALENDAR_ID }} + run: yarn run test:e2e:live diff --git a/README.md b/README.md index 2745133..bbe97d7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ yarn install yarn run check ``` -For local development, the package uses only this repository's internal schema/client sources; there is no sibling SDK dependency. `yarn run check` builds a bundled CLI artifact and runs both unit-style command tests and the built-entrypoint integration tests. To run only the integration tests after a build, use `yarn run test:integration`. +For local development, the package uses only this repository's internal schema/client sources; there is no sibling SDK dependency. `yarn run check` builds a bundled CLI artifact and runs both unit-style command tests and the built-entrypoint integration tests against a local mock HTTP server. To run only the offline integration tests after a build, use `yarn run test:integration`. Before publishing, run: @@ -31,6 +31,31 @@ npm pack --dry-run The pack output should contain only package metadata/README plus built `dist` files; source, tests, and `node_modules` are excluded by the package `files` whitelist. +### Optional live E2E smoke tests + +Live E2E tests are intentionally opt-in and are not part of `yarn run check` or the default CI workflow. They build the CLI and run `dist/index.js` against a real voosh API using explicit live-test environment variables: + +```bash +VOOSH_RUN_LIVE_E2E=1 \ +VOOSH_E2E_BASE_URL=https://api.example.test \ +VOOSH_E2E_CHRIS_TOKEN=... \ +yarn run test:e2e:live +``` + +Required variables: + +- `VOOSH_RUN_LIVE_E2E=1` enables the live suite. Without it, the suite is skipped with a reason. +- `VOOSH_E2E_BASE_URL` is the live API base URL. Do not include secrets in this URL. +- `VOOSH_E2E_CHRIS_TOKEN` is the API token used by the built CLI as `VOOSH_API_TOKEN`. + +The current live suite performs an authenticated smoke check plus calendar, event, and slot create/read/update/list/delete lifecycles using resources with a `voosh-cli-live-e2e-` prefix so cleanup is scoped to resources created by the test. It also exercises optional flows when fixture secrets are present: + +- `VOOSH_E2E_OTTO_TOKEN` enables slot registration/withdrawal as a second user. +- `VOOSH_E2E_ORG_ID` enables organization detail and membership checks. +- `VOOSH_E2E_BOOKMARK_CALENDAR_ID` or `VOOSH_E2E_CALENDAR_ID` enables bookmark add/list/remove checks. + +A manual GitHub Actions workflow, `Live E2E`, is available through `workflow_dispatch` only. Configure required repository secrets (`VOOSH_E2E_BASE_URL`, `VOOSH_E2E_CHRIS_TOKEN`) before running it; the workflow fails fast when required secrets are missing. Optional fixture secrets enable the broader smoke checks listed above. + ## Usage ```bash @@ -146,4 +171,4 @@ The Python CLI remains the parity reference. The current Node CLI covers the v1 - Natural date parsing is deliberately bounded to UTC and the four supported forms above; broader locale/timezone parsing is not implemented yet. - Organization/member management remains read-only in the Node CLI because the current parity slice only exposed organization and membership listing/retrieval commands. -- This package's integration tests use a local mock HTTP server rather than a live Django instance; live end-to-end smoke testing remains a release/deployment concern. +- Live E2E smoke coverage is opt-in via `yarn run test:e2e:live` and the manual GitHub Actions workflow, so default checks remain offline and deterministic. diff --git a/package.json b/package.json index c528912..4c152ea 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsup src/index.ts --format esm --platform node --target node20 --dts --sourcemap --no-splitting --external commander", "test": "yarn build && vitest run tests/cli.test.ts tests/cli.integration.test.ts", "test:integration": "yarn build && vitest run tests/cli.integration.test.ts", + "test:e2e:live": "yarn build && vitest run tests/live-e2e.test.ts", "check": "yarn test" }, "dependencies": { diff --git a/tests/live-e2e.test.ts b/tests/live-e2e.test.ts new file mode 100644 index 0000000..71961de --- /dev/null +++ b/tests/live-e2e.test.ts @@ -0,0 +1,315 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, describe, expect, it } from "vitest"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = resolve(__dirname, ".."); +const CLI_ENTRYPOINT = join(PACKAGE_ROOT, "dist", "index.js"); +const COMMAND_TIMEOUT_MS = 20_000; + +const liveEnabled = process.env.VOOSH_RUN_LIVE_E2E === "1"; +const missingRequiredEnv = ["VOOSH_E2E_BASE_URL", "VOOSH_E2E_CHRIS_TOKEN"].filter((name) => !process.env[name]?.trim()); +const skipReason = !liveEnabled + ? "set VOOSH_RUN_LIVE_E2E=1 to run live E2E tests" + : missingRequiredEnv.length > 0 + ? `missing required live E2E env: ${missingRequiredEnv.join(", ")}` + : undefined; + +if (skipReason) { + console.warn(`Skipping voosh live E2E: ${skipReason}.`); +} + +const describeLive = skipReason ? describe.skip : describe; +const createdCalendarIds: string[] = []; +const createdEventIds: string[] = []; +const createdSlotIds: string[] = []; +const bookmarkCalendarIdsToRemove: string[] = []; +let tempConfigDir: string | undefined; + +interface LiveResources { + prefix: string; + calendarId: string; + eventId: string; + slotId: string; +} + +let resources: LiveResources | undefined; + +afterAll(async () => { + if (!liveEnabled || missingRequiredEnv.length > 0) { + return; + } + + for (const calendarId of [...bookmarkCalendarIdsToRemove].reverse()) { + await runCli(["--json", "--quiet", "bookmarks", "remove", calendarId], { allowFailure: true }); + } + for (const slotId of [...createdSlotIds].reverse()) { + await runCli(["--json", "--quiet", "slots", "delete", slotId], { allowFailure: true }); + } + for (const eventId of [...createdEventIds].reverse()) { + await runCli(["--json", "--quiet", "events", "delete", eventId], { allowFailure: true }); + } + for (const calendarId of [...createdCalendarIds].reverse()) { + await runCli(["--json", "--quiet", "calendars", "delete", calendarId], { allowFailure: true }); + } + if (tempConfigDir) { + await rm(tempConfigDir, { recursive: true, force: true }); + } +}); + +describeLive(`voosh live E2E (${skipReason ?? "enabled"})`, () => { + it("shows the authenticated user and lists calendars against the live API", async () => { + const me = await runCli(["--json", "me", "show"]); + const calendars = await runCli(["--json", "calendars", "list", "--scope", "accessible"]); + + expect(me.status).toBe(0); + expect(calendars.status).toBe(0); + expect(JSON.parse(me.stdout)).toMatchObject({ username: expect.any(String) }); + expect(JSON.parse(calendars.stdout)).toMatchObject({ results: expect.any(Array) }); + expect(me.stderr).toBe(""); + expect(calendars.stderr).toBe(""); + }, 30_000); + + it("creates, reads, updates, lists, and deletes calendar/event/slot resources", async () => { + const prefix = `voosh-cli-live-e2e-${Date.now()}`; + const eventStart = futureIsoDate(14, 18); + const eventEnd = futureIsoDate(14, 20); + const slotStart = futureIsoDate(14, 18); + const slotEnd = futureIsoDate(14, 19); + + const createdCalendar = await runCli([ + "--json", + "calendars", + "create", + "--title", + `${prefix} calendar`, + "--description", + "Created by voosh-cli live E2E; safe to delete.", + ]); + expect(createdCalendar.status).toBe(0); + const calendarCreate = JSON.parse(createdCalendar.stdout) as { calendar?: { calendar_id?: string; title?: string } }; + expect(calendarCreate).toMatchObject({ calendar: { calendar_id: expect.any(String), title: `${prefix} calendar` } }); + const calendarId = calendarCreate.calendar!.calendar_id!; + createdCalendarIds.push(calendarId); + + const readCalendar = await runCli(["--json", "calendars", "get", calendarId, "--with-events", "--days", "30"]); + expect(readCalendar.status).toBe(0); + expect(JSON.parse(readCalendar.stdout)).toMatchObject({ calendar_id: calendarId }); + + const updatedCalendar = await runCli([ + "--json", + "calendars", + "update", + calendarId, + "--title", + `${prefix} calendar updated`, + ]); + expect(updatedCalendar.status).toBe(0); + expect(JSON.parse(updatedCalendar.stdout)).toMatchObject({ calendar: { calendar_id: calendarId, title: `${prefix} calendar updated` } }); + + const createdEvent = await runCli([ + "--json", + "events", + "create", + "--calendar", + calendarId, + "--summary", + `${prefix} event`, + "--start", + eventStart, + "--end", + eventEnd, + "--slots-enabled", + ]); + expect(createdEvent.status).toBe(0); + const event = JSON.parse(createdEvent.stdout) as { event_id?: string; summary?: string; owner_calendar_id?: string }; + expect(event).toMatchObject({ event_id: expect.any(String), summary: `${prefix} event` }); + createdEventIds.push(event.event_id!); + + const listedEvents = await runCli(["--json", "events", "list", "--calendar", calendarId]); + expect(listedEvents.status).toBe(0); + expect(JSON.parse(listedEvents.stdout)).toMatchObject({ results: expect.any(Array) }); + + const updatedEvent = await runCli([ + "--json", + "events", + "update", + event.event_id!, + "--summary", + `${prefix} event updated`, + ]); + expect(updatedEvent.status).toBe(0); + expect(JSON.parse(updatedEvent.stdout)).toMatchObject({ event_id: event.event_id, summary: `${prefix} event updated` }); + + const readEvent = await runCli(["--json", "events", "get", event.event_id!]); + expect(readEvent.status).toBe(0); + expect(JSON.parse(readEvent.stdout)).toMatchObject({ event_id: event.event_id, summary: `${prefix} event updated` }); + + const createdSlot = await runCli([ + "--json", + "slots", + "create", + "--event", + event.event_id!, + "--resource", + `${prefix} slot`, + "--start", + slotStart, + "--end", + slotEnd, + "--max-attendees", + "2", + "--confirmation-type", + "C", + "--show-attendees", + ]); + expect(createdSlot.status).toBe(0); + const slot = JSON.parse(createdSlot.stdout) as { slot_id?: string; resource?: string; event_id?: string }; + expect(slot).toMatchObject({ slot_id: expect.any(String), resource: `${prefix} slot` }); + createdSlotIds.push(slot.slot_id!); + + const listedSlots = await runCli(["--json", "slots", "list", "--event", event.event_id!]); + expect(listedSlots.status).toBe(0); + expect(JSON.parse(listedSlots.stdout)).toMatchObject({ results: expect.any(Array) }); + + const readSlot = await runCli(["--json", "slots", "get", slot.slot_id!]); + expect(readSlot.status).toBe(0); + expect(JSON.parse(readSlot.stdout)).toMatchObject({ slot_id: slot.slot_id, resource: `${prefix} slot` }); + + const updatedSlot = await runCli(["--json", "slots", "update", slot.slot_id!, "--resource", `${prefix} slot updated`]); + expect(updatedSlot.status).toBe(0); + expect(JSON.parse(updatedSlot.stdout)).toMatchObject({ slot_id: slot.slot_id, resource: `${prefix} slot updated` }); + + resources = { prefix, calendarId: calendarId, eventId: event.event_id!, slotId: slot.slot_id! }; + }, 90_000); + + it("registers and withdraws from the created slot when an attendee token is configured", async () => { + if (!process.env.VOOSH_E2E_OTTO_TOKEN?.trim()) { + console.warn("Skipping slot registration live E2E: set VOOSH_E2E_OTTO_TOKEN to exercise attendee flows."); + return; + } + expect(resources).toBeDefined(); + + const registration = await runCli(["--json", "slots", "register", resources!.slotId], { token: process.env.VOOSH_E2E_OTTO_TOKEN }); + expect(registration.status).toBe(0); + expect(JSON.parse(registration.stdout)).toMatchObject({ status: expect.any(String) }); + + const withdrawn = await runCli(["--json", "slots", "withdraw", resources!.slotId], { token: process.env.VOOSH_E2E_OTTO_TOKEN }); + expect(withdrawn.status).toBe(0); + }, 45_000); + + it("adds, lists, and removes a bookmark when a bookmark fixture calendar is configured", async () => { + const calendarId = process.env.VOOSH_E2E_BOOKMARK_CALENDAR_ID?.trim() || process.env.VOOSH_E2E_CALENDAR_ID?.trim(); + if (!calendarId) { + console.warn("Skipping bookmark live E2E: set VOOSH_E2E_BOOKMARK_CALENDAR_ID or VOOSH_E2E_CALENDAR_ID."); + return; + } + + const beforeList = await runCli(["--json", "bookmarks", "list"]); + expect(beforeList.status).toBe(0); + if (JSON.stringify(JSON.parse(beforeList.stdout)).includes(calendarId)) { + console.warn(`Skipping bookmark mutation live E2E: fixture calendar ${calendarId} is already bookmarked.`); + return; + } + + const added = await runCli(["--json", "bookmarks", "add", calendarId]); + expect(added.status).toBe(0); + bookmarkCalendarIdsToRemove.push(calendarId); + + const listed = await runCli(["--json", "bookmarks", "list"]); + expect(listed.status).toBe(0); + expect(JSON.stringify(JSON.parse(listed.stdout))).toContain(calendarId); + + const removed = await runCli(["--json", "bookmarks", "remove", calendarId]); + expect(removed.status).toBe(0); + bookmarkCalendarIdsToRemove.pop(); + }, 45_000); + + it("lists organizations and fixture memberships when an organization fixture is configured", async () => { + const organizations = await runCli(["--json", "organizations", "list"]); + expect(organizations.status).toBe(0); + expect(JSON.parse(organizations.stdout)).toMatchObject({ results: expect.any(Array) }); + + const orgId = process.env.VOOSH_E2E_ORG_ID?.trim(); + if (!orgId) { + console.warn("Skipping organization detail live E2E: set VOOSH_E2E_ORG_ID."); + return; + } + + const org = await runCli(["--json", "organizations", "get", orgId]); + expect(org.status).toBe(0); + expect(JSON.parse(org.stdout)).toMatchObject({ organization_id: orgId }); + + const memberships = await runCli(["--json", "organizations", "memberships", orgId]); + expect(memberships.status).toBe(0); + expect(JSON.parse(memberships.stdout)).toMatchObject({ results: expect.any(Array) }); + }, 45_000); + + it("cleans up the created resources", async () => { + expect(resources).toBeDefined(); + + const deletedSlot = await runCli(["--json", "slots", "delete", resources!.slotId]); + expect(deletedSlot.status).toBe(0); + createdSlotIds.pop(); + + const deletedEvent = await runCli(["--json", "events", "delete", resources!.eventId]); + expect(deletedEvent.status).toBe(0); + createdEventIds.pop(); + + const deletedCalendar = await runCli(["--json", "calendars", "delete", resources!.calendarId]); + expect(deletedCalendar.status).toBe(0); + createdCalendarIds.pop(); + }, 60_000); +}); + +interface RunCliOptions { + allowFailure?: boolean; + token?: string; +} + +async function runCli(args: string[], options: RunCliOptions = {}): Promise<{ status: number; stdout: string; stderr: string }> { + if (!tempConfigDir) { + tempConfigDir = await mkdtemp(join(tmpdir(), "voosh-cli-live-e2e-")); + } + + return new Promise((resolveRun, rejectRun) => { + execFile( + process.execPath, + [CLI_ENTRYPOINT, ...args], + { + cwd: PACKAGE_ROOT, + timeout: COMMAND_TIMEOUT_MS, + env: { + ...process.env, + VOOSH_API_URL: process.env.VOOSH_E2E_BASE_URL, + VOOSH_API_TOKEN: options.token ?? process.env.VOOSH_E2E_CHRIS_TOKEN, + VOOSH_CONFIG_PATH: join(tempConfigDir!, "config.json"), + }, + }, + (error, stdout, stderr) => { + const status = typeof (error as NodeJS.ErrnoException | null)?.code === "number" + ? ((error as NodeJS.ErrnoException).code as number) + : 0; + if (error && typeof (error as NodeJS.ErrnoException).code !== "number") { + rejectRun(error); + return; + } + if (!options.allowFailure && status !== 0) { + rejectRun(new Error(`voosh ${args.join(" ")} exited ${status}; stderr=${stderr}; stdout=${stdout}`)); + return; + } + resolveRun({ status, stdout, stderr }); + }, + ); + }); +} + +function futureIsoDate(daysFromNow: number, hourUtc: number): string { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + daysFromNow); + date.setUTCHours(hourUtc, 0, 0, 0); + return date.toISOString().replace(".000Z", "Z"); +}