From a6ca4b39b2eaf2c97f8855ef11a6c9ec2fb613a9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 15:42:54 -0700 Subject: [PATCH 1/2] test: move live Gmail suite to e2e and drop test-only unit tests The live Gmail suites move to e2e/ with their helpers in e2e/helpers.ts. The credential, fixture and OAuth tests only exercised test harness code, so they are deleted. The tool-definition name list and schema-knob checks restated constants and are dropped. bun run test now covers src only, test:e2e runs the live suite, and CI runs the package test script and the e2e typecheck. AGENTS.md now runs bun run test. --- .github/workflows/ci.yml | 3 +- AGENTS.md | 2 +- CONTRIBUTING.md | 11 +- .../live => e2e}/gmail.mutations.e2e.test.ts | 6 +- {tests/live => e2e}/gmail.read.e2e.test.ts | 2 +- e2e/helpers.ts | 527 ++++++++++++++++++ package.json | 6 +- src/tools/create-tools.test.ts | 45 +- tests/live/credential.test.ts | 61 -- tests/live/credential.ts | 84 --- tests/live/fixtures.test.ts | 58 -- tests/live/fixtures.ts | 49 -- tests/live/json.ts | 19 - tests/live/live-harness.ts | 113 ---- tests/live/oauth.test.ts | 54 -- tests/live/oauth.ts | 228 -------- tests/live/token-store.ts | 55 -- tsconfig.live.json => tsconfig.e2e.json | 2 +- 18 files changed, 549 insertions(+), 776 deletions(-) rename {tests/live => e2e}/gmail.mutations.e2e.test.ts (98%) rename {tests/live => e2e}/gmail.read.e2e.test.ts (95%) create mode 100644 e2e/helpers.ts delete mode 100644 tests/live/credential.test.ts delete mode 100644 tests/live/credential.ts delete mode 100644 tests/live/fixtures.test.ts delete mode 100644 tests/live/fixtures.ts delete mode 100644 tests/live/json.ts delete mode 100644 tests/live/live-harness.ts delete mode 100644 tests/live/oauth.test.ts delete mode 100644 tests/live/oauth.ts delete mode 100644 tests/live/token-store.ts rename tsconfig.live.json => tsconfig.e2e.json (75%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 593dd0e..db821a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,8 @@ jobs: - uses: oven-sh/setup-bun@v2 - run: bun install --frozen-lockfile - run: bun run typecheck - - run: bun test + - run: bun run typecheck:e2e + - run: bun run test - run: bun run build - name: node smoke run: | diff --git a/AGENTS.md b/AGENTS.md index 5c4901a..0b58074 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,5 +17,5 @@ Instructions for AI agents working in this repository. ```bash bun install -bun test +bun run test ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38cff30..10ce5da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ ```bash bun install bun run typecheck -bun run typecheck:live +bun run typecheck:e2e bun run test bun run build ``` @@ -23,7 +23,7 @@ GMAIL_LIVE_TEST=1 \ GMAIL_LIVE_CLIENT_ID='...' \ GMAIL_LIVE_CLIENT_SECRET='...' \ GMAIL_LIVE_FIXTURE_QUERY='subject:(interchange-gmail-e2e-fixture)' \ -bun run test:live +bun run test:e2e ``` The full live suite has been verified against a dedicated Gmail fixture account. @@ -45,3 +45,10 @@ deletes its draft, restores the fixture labels, and never sends email. The first run opens Google OAuth and stores the refresh token in the ignored `.local/gmail-live-token.json` file. Set `GMAIL_LIVE_TOKEN_FILE` when using a different token file. Credentials and message contents are not logged. + +## Commit messages + +Commit subjects and PR titles follow [Conventional Commits](https://www.conventionalcommits.org): `feat`, `fix`, `refactor`, `test`, `docs`, `build`, `ci`, `perf`, and `chore(release): x.y.z` for releases. +Add `!` only for public API breaks: removed or renamed exports, changed signatures, newly required params. Peer and dependency range changes are `build(deps):` with no `!`. +Keep subjects imperative, lowercase after the colon, 72 characters or less, and free of ticket IDs. +Every PR links its issue with a `Closes ` line in the PR body. diff --git a/tests/live/gmail.mutations.e2e.test.ts b/e2e/gmail.mutations.e2e.test.ts similarity index 98% rename from tests/live/gmail.mutations.e2e.test.ts rename to e2e/gmail.mutations.e2e.test.ts index 4376455..3aadf8c 100644 --- a/tests/live/gmail.mutations.e2e.test.ts +++ b/e2e/gmail.mutations.e2e.test.ts @@ -1,10 +1,12 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { getArray, getString, isRecord } from "./json.js"; import { createLiveHarness, + getArray, + getString, + isRecord, toolData, -} from "./live-harness.js"; +} from "./helpers.js"; const LIVE_TEST_TIMEOUT_MS = 60_000; const mutationEnabled = diff --git a/tests/live/gmail.read.e2e.test.ts b/e2e/gmail.read.e2e.test.ts similarity index 95% rename from tests/live/gmail.read.e2e.test.ts rename to e2e/gmail.read.e2e.test.ts index cfcc40b..f70da19 100644 --- a/tests/live/gmail.read.e2e.test.ts +++ b/e2e/gmail.read.e2e.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { createLiveHarness, toolData, toolId } from "./live-harness.js"; +import { createLiveHarness, toolData, toolId } from "./helpers.js"; const LIVE_TEST_TIMEOUT_MS = 60_000; diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..2824977 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,527 @@ +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import type { HttpMediatedCredential } from "@intx/types"; +import { createRuntimeCapabilities } from "@intx/types/runtime-capabilities"; + +import type { GmailFetch } from "../src/client/index.js"; +import { createGmailTools, type GmailTools } from "../src/tools/create-tools.js"; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function getString( + value: Record, + key: string, +): string | undefined { + const candidate = value[key]; + return typeof candidate === "string" ? candidate : undefined; +} + +export function getArray( + value: Record, + key: string, +): unknown[] | undefined { + const candidate = value[key]; + return Array.isArray(candidate) ? candidate : undefined; +} + +type StoredGmailToken = { + refreshToken: string; + grantedScopes?: string[]; +}; + +function createFileTokenStore(path: string) { + return { + async read() { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if (isMissingFileError(error)) return undefined; + throw error; + } + try { + return parseStoredGmailToken(JSON.parse(raw)); + } catch (cause) { + throw new Error(`invalid Gmail token file: ${path}`, { cause }); + } + }, + async write(token: StoredGmailToken) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFile(path, `${JSON.stringify(token, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await chmod(path, 0o600); + }, + }; +} + +function parseStoredGmailToken(value: unknown): StoredGmailToken { + if (!isRecord(value)) throw new Error("expected JSON object"); + const refreshToken = getString(value, "refreshToken"); + if (refreshToken === undefined || refreshToken.length === 0) { + throw new Error("refreshToken is required"); + } + const grantedScopes = getArray(value, "grantedScopes"); + const scopes = + grantedScopes?.filter((scope): scope is string => typeof scope === "string") ?? []; + if (grantedScopes !== undefined && scopes.length !== grantedScopes.length) { + throw new Error("grantedScopes must contain strings"); + } + return { refreshToken, ...(grantedScopes === undefined ? {} : { grantedScopes: scopes }) }; +} + +function isMissingFileError(error: unknown): boolean { + return isRecord(error) && error.code === "ENOENT"; +} + +const GMAIL_LIVE_SCOPES: readonly string[] = [ + "https://www.googleapis.com/auth/gmail.modify", +]; + +const AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; + +type GmailOAuthConfig = { + clientId: string; + clientSecret: string; + tokenFile: string; + port: number; + scopes: readonly string[]; +}; + +type GmailAccessToken = { + accessToken: string; + expiresAt: number; +}; + +type GmailLiveLogger = { info(message: string): void }; + +const defaultGmailLiveLogger: GmailLiveLogger = { + info(message) { + process.stdout.write(`${message}\n`); + }, +}; + +function readGmailOAuthConfig( + env: NodeJS.ProcessEnv = process.env, +): GmailOAuthConfig { + const clientId = env.GMAIL_LIVE_CLIENT_ID?.trim(); + const clientSecret = env.GMAIL_LIVE_CLIENT_SECRET?.trim(); + if (clientId === undefined || clientId.length === 0) { + throw new Error("GMAIL_LIVE_CLIENT_ID is required for the live Gmail suite"); + } + if (clientSecret === undefined || clientSecret.length === 0) { + throw new Error("GMAIL_LIVE_CLIENT_SECRET is required for the live Gmail suite"); + } + return { + clientId, + clientSecret, + tokenFile: env.GMAIL_LIVE_TOKEN_FILE?.trim() || ".local/gmail-live-token.json", + port: parseLivePort(env.GMAIL_LIVE_PORT), + scopes: GMAIL_LIVE_SCOPES, + }; +} + +function parseLivePort(value: string | undefined): number { + if (value === undefined || value.trim().length === 0) return 8765; + const port = Number(value); + if (!Number.isInteger(port) || port < 1024 || port > 65_535) { + throw new Error("GMAIL_LIVE_PORT must be an integer between 1024 and 65535"); + } + return port; +} + +async function parseTokenResponse(response: Response): Promise> { + const raw = await response.text(); + let body: Record | undefined; + try { + const parsed: unknown = JSON.parse(raw); + body = isRecord(parsed) ? parsed : undefined; + } catch { + body = undefined; + } + if (!response.ok) { + const errorCode = + (body === undefined ? undefined : getString(body, "error")) ?? + `HTTP ${String(response.status)}`; + throw new Error(`Google OAuth token request failed: ${errorCode}`); + } + if (body === undefined) { + throw new Error("Google OAuth token response was not a JSON object"); + } + return body; +} + +function tokenFromResponse(body: Record): GmailAccessToken { + const accessToken = getString(body, "access_token"); + const expiresIn = body.expires_in; + if (typeof accessToken !== "string" || accessToken.length === 0) { + throw new Error("Google OAuth token response did not include access_token"); + } + if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn)) { + throw new Error("Google OAuth token response did not include expires_in"); + } + return { + accessToken, + expiresAt: Date.now() + expiresIn * 1_000, + }; +} + +async function refreshGmailAccessToken( + config: Pick, + refreshToken: string, + fetchImpl: GmailFetch = globalThis.fetch, +): Promise { + const body = new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }); + const response = await fetchImpl(TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + return tokenFromResponse(await parseTokenResponse(response)); +} + +async function exchangeAuthorizationCode( + config: Pick, + code: string, + redirectUri: string, + fetchImpl: GmailFetch, +): Promise { + const body = new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }); + const response = await fetchImpl(TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const parsed = await parseTokenResponse(response); + const refreshToken = getString(parsed, "refresh_token"); + if (typeof refreshToken !== "string" || refreshToken.length === 0) { + throw new Error( + "Google OAuth did not return a refresh token; revoke the prior grant and authorize again", + ); + } + const scope = getString(parsed, "scope"); + return { + refreshToken, + ...(typeof scope === "string" + ? { grantedScopes: scope.split(" ").filter(Boolean) } + : {}), + }; +} + +async function authorizeGmail( + config: GmailOAuthConfig, + fetchImpl: GmailFetch = globalThis.fetch, + logger: GmailLiveLogger = defaultGmailLiveLogger, +): Promise { + const state = randomUUID(); + let stopServer = (): void => undefined; + const code = Promise.withResolvers(); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: config.port, + fetch(request) { + const requestUrl = new URL(request.url); + if (requestUrl.pathname !== "/oauth2callback") { + return new Response("Not found", { status: 404 }); + } + if (requestUrl.searchParams.get("state") !== state) { + stopServer(); + code.reject(new Error("Google OAuth state mismatch")); + return new Response("Invalid OAuth state", { status: 400 }); + } + const error = requestUrl.searchParams.get("error"); + if (error !== null) { + stopServer(); + code.reject(new Error(`Google OAuth authorization failed: ${error}`)); + return new Response("Google OAuth was denied", { status: 400 }); + } + const authorizationCode = requestUrl.searchParams.get("code"); + if (authorizationCode === null || authorizationCode.length === 0) { + stopServer(); + code.reject(new Error("Google OAuth callback did not include a code")); + return new Response("Missing authorization code", { status: 400 }); + } + stopServer(); + code.resolve(authorizationCode); + return new Response("Authorization complete. You can close this window.", { + headers: { "Content-Type": "text/html" }, + }); + }, + }); + stopServer = () => server.stop(); + + const redirectUri = `http://127.0.0.1:${String(server.port)}/oauth2callback`; + const authorizationUrl = new URL(AUTHORIZATION_ENDPOINT); + authorizationUrl.search = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: config.scopes.join(" "), + access_type: "offline", + prompt: "consent", + state, + }).toString(); + + const timer = setTimeout(() => { + stopServer(); + code.reject(new Error("timed out waiting for Google OAuth callback")); + }, 5 * 60 * 1_000); + logger.info(`Open this URL to authorize the Gmail live test:\n${authorizationUrl}`); + try { + const child = Bun.spawn(["open", authorizationUrl.toString()], { + stdout: "ignore", + stderr: "ignore", + }); + void child.exited; + } catch { + // The URL is already printed for environments without macOS `open`. + } + + const authorizationCode = await code.promise.finally(() => { + clearTimeout(timer); + stopServer(); + }); + + return exchangeAuthorizationCode(config, authorizationCode, redirectUri, fetchImpl); +} + +const GMAIL_API_ORIGIN = "https://gmail.googleapis.com"; + +type StandaloneGmailCredentialOptions = { + config: GmailOAuthConfig; + token: StoredGmailToken; + fetchImpl?: GmailFetch; +}; + +function createStandaloneGmailCredential( + options: StandaloneGmailCredentialOptions, +): HttpMediatedCredential { + const networkFetch = options.fetchImpl ?? globalThis.fetch; + let accessToken: GmailAccessToken | undefined; + let refreshPromise: Promise | undefined; + + async function getAccessToken(force = false): Promise { + if ( + !force && + accessToken !== undefined && + accessToken.expiresAt > Date.now() + 60_000 + ) { + return accessToken.accessToken; + } + refreshPromise ??= refreshGmailAccessToken( + options.config, + options.token.refreshToken, + networkFetch, + ).finally(() => { + refreshPromise = undefined; + }); + accessToken = await refreshPromise; + return accessToken.accessToken; + } + + async function authenticatedFetch( + input: string | URL | Request, + init?: RequestInit, + forceRefresh = false, + ): Promise { + const sourceRequest = input instanceof Request ? input : undefined; + const url = new URL(sourceRequest?.url ?? String(input)); + if (url.origin !== GMAIL_API_ORIGIN) { + throw new Error( + `standalone Gmail credential rejected non-Gmail origin: ${url.origin}`, + ); + } + const headers = new Headers(sourceRequest?.headers); + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); + headers.set("Authorization", `Bearer ${await getAccessToken(forceRefresh)}`); + + return networkFetch( + new Request(sourceRequest ?? url, { + ...init, + headers, + redirect: "manual", + }), + ); + } + + return { + kind: "http", + async fetch(input, init) { + const response = await authenticatedFetch(input, init); + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + if (response.status !== 401 || !["GET", "HEAD", "OPTIONS"].includes(method)) { + return response; + } + return authenticatedFetch(input, init, true); + }, + dispose() { + accessToken = undefined; + }, + }; +} + +type GmailFixture = { threadId: string; messageId: string }; + +async function findGmailFixture( + tools: GmailTools, + query: string, + signal: AbortSignal, +): Promise { + const result = await tools.run( + { + id: "live-fixture-search", + name: "gmail_search_threads", + arguments: { query, pageSize: 10 }, + }, + signal, + ); + if (result.isError) { + throw new Error(`fixture search failed: ${JSON.stringify(result.content)}`); + } + if (!isRecord(result.content) || !isRecord(result.content.data)) { + throw new Error("fixture search did not return structured data"); + } + const threads = getArray(result.content.data, "threads"); + if (threads === undefined || threads.length !== 1) { + throw new Error(`fixture query must match exactly one Gmail thread: ${query}`); + } + const thread = threads[0]; + if (!isRecord(thread)) { + throw new Error(`no Gmail fixture matched query: ${query}`); + } + const threadId = getString(thread, "id"); + const messages = getArray(thread, "messages"); + if (messages === undefined || messages.length !== 1) { + throw new Error(`fixture thread must contain exactly one Gmail message: ${query}`); + } + const firstMessage = messages[0]; + const messageId = isRecord(firstMessage) ? getString(firstMessage, "id") : undefined; + if ( + typeof threadId !== "string" || + threadId.length === 0 || + typeof messageId !== "string" || + messageId.length === 0 + ) { + throw new Error(`no Gmail fixture matched query: ${query}`); + } + return { threadId, messageId }; +} + +type LiveHarness = { + tools: GmailTools; + fixture: GmailFixture; + deleteDraftsForSubject(subject: string): Promise; + dispose(): Promise; +}; + +export async function createLiveHarness(): Promise { + const config = readGmailOAuthConfig(); + const tokenStore = createFileTokenStore(config.tokenFile); + const existingToken = await tokenStore.read(); + const token = existingToken ?? (await authorizeGmail(config)); + if (!token.grantedScopes?.includes(config.scopes[0] ?? "")) { + throw new Error("Gmail OAuth token did not record granted scopes"); + } + if (existingToken === undefined) await tokenStore.write(token); + + const credential = createStandaloneGmailCredential({ + config, + token, + }); + const tools = createGmailTools({ + capabilities: createRuntimeCapabilities({ + credentials: { + async resolve(handle) { + if (handle !== "gmail-api") { + throw new Error(`unknown standalone credential handle: ${handle}`); + } + return credential; + }, + }, + }), + }); + + const query = process.env.GMAIL_LIVE_FIXTURE_QUERY?.trim(); + if (query === undefined || query.length === 0) { + await tools.dispose(); + throw new Error("GMAIL_LIVE_FIXTURE_QUERY is required for the live Gmail suite"); + } + + try { + const fixture = await findGmailFixture(tools, query, new AbortController().signal); + return { + tools, + fixture, + deleteDraftsForSubject: (subject) => deleteDraftsForSubject(tools, credential, subject), + async dispose() { + await tools.dispose(); + }, + }; + } catch (cause) { + await tools.dispose(); + throw cause; + } +} + +async function deleteDraftsForSubject( + tools: GmailTools, + credential: { fetch(input: string, init?: RequestInit): Promise }, + subject: string, +): Promise { + const result = await tools.run( + { + id: "live-cleanup-drafts", + name: "gmail_list_drafts", + arguments: { query: `subject:${subject}` }, + }, + new AbortController().signal, + ); + const data = toolData(result, "gmail_list_drafts"); + const drafts = getArray(data, "drafts") ?? []; + const draftIds = drafts.flatMap((draft) => { + if (!isRecord(draft) || getString(draft, "subject") !== subject) return []; + const id = getString(draft, "id"); + return id === undefined ? [] : [id]; + }); + for (const draftId of draftIds) { + const response = await credential.fetch( + `${GMAIL_API_ORIGIN}/gmail/v1/users/me/drafts/${encodeURIComponent(draftId)}`, + { method: "DELETE" }, + ); + if (!response.ok) { + throw new Error(`failed to delete live test draft: HTTP ${String(response.status)}`); + } + } +} + +export function toolData(result: unknown, toolName: string): Record { + if (!isRecord(result) || result.isError === true || !isRecord(result.content)) { + throw new Error(`${toolName} failed`); + } + const data = result.content.data; + if (!isRecord(data)) throw new Error(`${toolName} did not return structured data`); + return data; +} + +export function toolId(result: unknown, toolName: string): string { + const id = getString(toolData(result, toolName), "id"); + if (id === undefined || id.length === 0) throw new Error(`${toolName} did not return an ID`); + return id; +} diff --git a/package.json b/package.json index fcf8dc6..84b2853 100644 --- a/package.json +++ b/package.json @@ -57,11 +57,11 @@ "sideEffects": false, "scripts": { "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", - "typecheck:live": "tsc -p tsconfig.live.json --noEmit", + "typecheck:e2e": "tsc -p tsconfig.e2e.json --noEmit", "build": "rm -rf dist && tsc -p tsconfig.build.json", "prepack": "bun run build", - "test": "bun test src tests/live/credential.test.ts tests/live/fixtures.test.ts tests/live/oauth.test.ts", - "test:live": "bun test tests/live" + "test": "bun test src", + "test:e2e": "bun test e2e" }, "peerDependencies": { "@intx/agent": "^0.4.0", diff --git a/src/tools/create-tools.test.ts b/src/tools/create-tools.test.ts index 2d56f22..55a92ca 100644 --- a/src/tools/create-tools.test.ts +++ b/src/tools/create-tools.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createRuntimeCapabilities } from "@intx/types/runtime-capabilities"; import type { GmailFetch } from "../client/index.js"; -import { GMAIL_CREDENTIAL_HANDLE, TOOL_DEFINITIONS } from "./definitions.js"; +import { GMAIL_CREDENTIAL_HANDLE } from "./definitions.js"; import { createGmailTools } from "./create-tools.js"; import { createRawDraft } from "./drafts.js"; @@ -50,49 +50,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -describe("Gmail tool definitions", () => { - test("declares the complete Google-compatible Gmail surface", () => { - expect(TOOL_DEFINITIONS.map((definition) => definition.name).sort()).toEqual([ - "gmail_create_draft", - "gmail_get_message", - "gmail_get_thread", - "gmail_label_message", - "gmail_label_thread", - "gmail_list_drafts", - "gmail_list_labels", - "gmail_search_threads", - "gmail_unlabel_message", - "gmail_unlabel_thread", - ]); - }); - - test("publishes strict generated JSON Schemas with Google defaults", () => { - for (const definition of TOOL_DEFINITIONS) { - expect(definition.inputSchema).toMatchObject({ - $schema: "http://json-schema.org/draft-07/schema#", - type: "object", - additionalProperties: false, - }); - } - const searchThreads = TOOL_DEFINITIONS.find( - (definition) => definition.name === "gmail_search_threads", - ); - expect(searchThreads?.inputSchema).toMatchObject({ - properties: { - pageSize: { type: "integer", minimum: 1, maximum: 50, default: 20 }, - includeTrash: { type: "boolean", default: false }, - view: { - default: "THREAD_VIEW_MINIMAL", - anyOf: [ - { const: "THREAD_VIEW_METADATA_ONLY" }, - { const: "THREAD_VIEW_MINIMAL" }, - ], - }, - }, - }); - }); -}); - describe("createGmailTools", () => { test("searches through the mediated credential with Google-compatible defaults", async () => { const fetchImpl = createFetchImpl(async (input, init) => { diff --git a/tests/live/credential.test.ts b/tests/live/credential.test.ts deleted file mode 100644 index 90dead1..0000000 --- a/tests/live/credential.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createStandaloneGmailCredential } from "./credential.js"; -import { type GmailOAuthConfig } from "./oauth.js"; - -const config: GmailOAuthConfig = { - clientId: "client-id", - clientSecret: "client-secret", - tokenFile: ".local/test-token.json", - port: 8765, - scopes: [], -}; - -describe("standalone Gmail credential", () => { - test("refreshes locally and pins requests to Gmail", async () => { - let tokenRequests = 0; - let gmailRequests = 0; - const credential = createStandaloneGmailCredential({ - config, - token: { refreshToken: "refresh-token" }, - fetchImpl: (async (input: string | URL | Request, init?: RequestInit) => { - const url = input instanceof Request ? input.url : String(input); - if (url === "https://oauth2.googleapis.com/token") { - tokenRequests += 1; - expect(init?.method).toBe("POST"); - return new Response( - JSON.stringify({ access_token: `access-${String(tokenRequests)}`, expires_in: 3_600 }), - { status: 200 }, - ); - } - gmailRequests += 1; - expect(url).toBe("https://gmail.googleapis.com/gmail/v1/users/me/labels"); - const headers = input instanceof Request ? input.headers : init?.headers; - expect(new Headers(headers).get("authorization")).toBe("Bearer access-1"); - return new Response(JSON.stringify({ labels: [] }), { status: 200 }); - }), - }); - - await expect( - credential.fetch("https://gmail.googleapis.com/gmail/v1/users/me/labels"), - ).resolves.toBeInstanceOf(Response); - await expect( - credential.fetch("https://gmail.googleapis.com/gmail/v1/users/me/labels"), - ).resolves.toBeInstanceOf(Response); - expect(tokenRequests).toBe(1); - expect(gmailRequests).toBe(2); - }); - - test("rejects non-Gmail origins before network access", async () => { - const credential = createStandaloneGmailCredential({ - config, - token: { refreshToken: "refresh-token" }, - fetchImpl: (async () => { - throw new Error("network should not run"); - }), - }); - await expect(credential.fetch("https://evil.example/steal")).rejects.toThrow( - /non-Gmail origin/, - ); - }); -}); diff --git a/tests/live/credential.ts b/tests/live/credential.ts deleted file mode 100644 index 761164e..0000000 --- a/tests/live/credential.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { HttpMediatedCredential } from "@intx/types"; - -import type { GmailFetch } from "../../src/client/index.js"; -import { - refreshGmailAccessToken, - type GmailAccessToken, - type GmailOAuthConfig, -} from "./oauth.js"; -import type { StoredGmailToken } from "./token-store.js"; - -export const GMAIL_API_ORIGIN = "https://gmail.googleapis.com"; - -export type StandaloneGmailCredentialOptions = { - config: GmailOAuthConfig; - token: StoredGmailToken; - fetchImpl?: GmailFetch; -}; - -export function createStandaloneGmailCredential( - options: StandaloneGmailCredentialOptions, -): HttpMediatedCredential { - const networkFetch = options.fetchImpl ?? globalThis.fetch; - let accessToken: GmailAccessToken | undefined; - let refreshPromise: Promise | undefined; - - async function getAccessToken(force = false): Promise { - if ( - !force && - accessToken !== undefined && - accessToken.expiresAt > Date.now() + 60_000 - ) { - return accessToken.accessToken; - } - refreshPromise ??= refreshGmailAccessToken( - options.config, - options.token.refreshToken, - networkFetch, - ).finally(() => { - refreshPromise = undefined; - }); - accessToken = await refreshPromise; - return accessToken.accessToken; - } - - async function authenticatedFetch( - input: string | URL | Request, - init?: RequestInit, - forceRefresh = false, - ): Promise { - const sourceRequest = input instanceof Request ? input : undefined; - const url = new URL(sourceRequest?.url ?? String(input)); - if (url.origin !== GMAIL_API_ORIGIN) { - throw new Error( - `standalone Gmail credential rejected non-Gmail origin: ${url.origin}`, - ); - } - const headers = new Headers(sourceRequest?.headers); - new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); - headers.set("Authorization", `Bearer ${await getAccessToken(forceRefresh)}`); - - return networkFetch( - new Request(sourceRequest ?? url, { - ...init, - headers, - redirect: "manual", - }), - ); - } - - return { - kind: "http", - async fetch(input, init) { - const response = await authenticatedFetch(input, init); - const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); - if (response.status !== 401 || !["GET", "HEAD", "OPTIONS"].includes(method)) { - return response; - } - return authenticatedFetch(input, init, true); - }, - dispose() { - accessToken = undefined; - }, - }; -} diff --git a/tests/live/fixtures.test.ts b/tests/live/fixtures.test.ts deleted file mode 100644 index 27a815f..0000000 --- a/tests/live/fixtures.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { expect, test } from "bun:test"; - -import type { GmailTools } from "../../src/tools/create-tools.js"; -import { findGmailFixture } from "./fixtures.js"; - -test("findGmailFixture uses the Gmail tool argument contract", async () => { - let call: - | { id: string; name: string; arguments: Record } - | undefined; - const tools: GmailTools = { - definitions: [], - async dispose() {}, - async run(toolCall) { - call = toolCall; - return { - callId: toolCall.id, - content: { - data: { - threads: [{ id: "thread-1", messages: [{ id: "message-1" }] }], - }, - }, - }; - }, - }; - - await expect( - findGmailFixture(tools, "subject:(interchange-gmail-e2e-fixture)", new AbortController().signal), - ).resolves.toEqual({ threadId: "thread-1", messageId: "message-1" }); - expect(call).toEqual({ - id: "live-fixture-search", - name: "gmail_search_threads", - arguments: { query: "subject:(interchange-gmail-e2e-fixture)", pageSize: 10 }, - }); -}); - -test("findGmailFixture rejects ambiguous thread matches", async () => { - const tools: GmailTools = { - definitions: [], - async dispose() {}, - async run() { - return { - callId: "fixture-search", - content: { - data: { - threads: [ - { id: "thread-1", messages: [{ id: "message-1" }] }, - { id: "thread-2", messages: [{ id: "message-2" }] }, - ], - }, - }, - }; - }, - }; - - await expect( - findGmailFixture(tools, "subject:ambiguous", new AbortController().signal), - ).rejects.toThrow("exactly one Gmail thread"); -}); diff --git a/tests/live/fixtures.ts b/tests/live/fixtures.ts deleted file mode 100644 index 8bf3c48..0000000 --- a/tests/live/fixtures.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { GmailTools } from "../../src/tools/create-tools.js"; -import { getArray, getString, isRecord } from "./json.js"; - -export type GmailFixture = { threadId: string; messageId: string }; - -export async function findGmailFixture( - tools: GmailTools, - query: string, - signal: AbortSignal, -): Promise { - const result = await tools.run( - { - id: "live-fixture-search", - name: "gmail_search_threads", - arguments: { query, pageSize: 10 }, - }, - signal, - ); - if (result.isError) { - throw new Error(`fixture search failed: ${JSON.stringify(result.content)}`); - } - if (!isRecord(result.content) || !isRecord(result.content.data)) { - throw new Error("fixture search did not return structured data"); - } - const threads = getArray(result.content.data, "threads"); - if (threads === undefined || threads.length !== 1) { - throw new Error(`fixture query must match exactly one Gmail thread: ${query}`); - } - const thread = threads[0]; - if (!isRecord(thread)) { - throw new Error(`no Gmail fixture matched query: ${query}`); - } - const threadId = getString(thread, "id"); - const messages = getArray(thread, "messages"); - if (messages === undefined || messages.length !== 1) { - throw new Error(`fixture thread must contain exactly one Gmail message: ${query}`); - } - const firstMessage = messages[0]; - const messageId = isRecord(firstMessage) ? getString(firstMessage, "id") : undefined; - if ( - typeof threadId !== "string" || - threadId.length === 0 || - typeof messageId !== "string" || - messageId.length === 0 - ) { - throw new Error(`no Gmail fixture matched query: ${query}`); - } - return { threadId, messageId }; -} diff --git a/tests/live/json.ts b/tests/live/json.ts deleted file mode 100644 index e897140..0000000 --- a/tests/live/json.ts +++ /dev/null @@ -1,19 +0,0 @@ -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export function getString( - value: Record, - key: string, -): string | undefined { - const candidate = value[key]; - return typeof candidate === "string" ? candidate : undefined; -} - -export function getArray( - value: Record, - key: string, -): unknown[] | undefined { - const candidate = value[key]; - return Array.isArray(candidate) ? candidate : undefined; -} diff --git a/tests/live/live-harness.ts b/tests/live/live-harness.ts deleted file mode 100644 index ae7852a..0000000 --- a/tests/live/live-harness.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { createRuntimeCapabilities } from "@intx/types/runtime-capabilities"; - -import { createGmailTools, type GmailTools } from "../../src/tools/create-tools.js"; -import { - createStandaloneGmailCredential, - GMAIL_API_ORIGIN, -} from "./credential.js"; -import { findGmailFixture, type GmailFixture } from "./fixtures.js"; -import { authorizeGmail, readGmailOAuthConfig } from "./oauth.js"; -import { getArray, getString, isRecord } from "./json.js"; -import { createFileTokenStore } from "./token-store.js"; - -export type LiveHarness = { - tools: GmailTools; - fixture: GmailFixture; - deleteDraftsForSubject(subject: string): Promise; - dispose(): Promise; -}; - -export async function createLiveHarness(): Promise { - const config = readGmailOAuthConfig(); - const tokenStore = createFileTokenStore(config.tokenFile); - const existingToken = await tokenStore.read(); - const token = existingToken ?? (await authorizeGmail(config)); - if (!token.grantedScopes?.includes(config.scopes[0] ?? "")) { - throw new Error("Gmail OAuth token did not record granted scopes"); - } - if (existingToken === undefined) await tokenStore.write(token); - - const credential = createStandaloneGmailCredential({ - config, - token, - }); - const tools = createGmailTools({ - capabilities: createRuntimeCapabilities({ - credentials: { - async resolve(handle) { - if (handle !== "gmail-api") { - throw new Error(`unknown standalone credential handle: ${handle}`); - } - return credential; - }, - }, - }), - }); - - const query = process.env.GMAIL_LIVE_FIXTURE_QUERY?.trim(); - if (query === undefined || query.length === 0) { - await tools.dispose(); - throw new Error("GMAIL_LIVE_FIXTURE_QUERY is required for the live Gmail suite"); - } - - try { - const fixture = await findGmailFixture(tools, query, new AbortController().signal); - return { - tools, - fixture, - deleteDraftsForSubject: (subject) => deleteDraftsForSubject(tools, credential, subject), - async dispose() { - await tools.dispose(); - }, - }; - } catch (cause) { - await tools.dispose(); - throw cause; - } -} - -async function deleteDraftsForSubject( - tools: GmailTools, - credential: { fetch(input: string, init?: RequestInit): Promise }, - subject: string, -): Promise { - const result = await tools.run( - { - id: "live-cleanup-drafts", - name: "gmail_list_drafts", - arguments: { query: `subject:${subject}` }, - }, - new AbortController().signal, - ); - const data = toolData(result, "gmail_list_drafts"); - const drafts = getArray(data, "drafts") ?? []; - const draftIds = drafts.flatMap((draft) => { - if (!isRecord(draft) || getString(draft, "subject") !== subject) return []; - const id = getString(draft, "id"); - return id === undefined ? [] : [id]; - }); - for (const draftId of draftIds) { - const response = await credential.fetch( - `${GMAIL_API_ORIGIN}/gmail/v1/users/me/drafts/${encodeURIComponent(draftId)}`, - { method: "DELETE" }, - ); - if (!response.ok) { - throw new Error(`failed to delete live test draft: HTTP ${String(response.status)}`); - } - } -} - -export function toolData(result: unknown, toolName: string): Record { - if (!isRecord(result) || result.isError === true || !isRecord(result.content)) { - throw new Error(`${toolName} failed`); - } - const data = result.content.data; - if (!isRecord(data)) throw new Error(`${toolName} did not return structured data`); - return data; -} - -export function toolId(result: unknown, toolName: string): string { - const id = getString(toolData(result, toolName), "id"); - if (id === undefined || id.length === 0) throw new Error(`${toolName} did not return an ID`); - return id; -} diff --git a/tests/live/oauth.test.ts b/tests/live/oauth.test.ts deleted file mode 100644 index f555516..0000000 --- a/tests/live/oauth.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { readGmailOAuthConfig, refreshGmailAccessToken } from "./oauth.js"; - -describe("Gmail OAuth", () => { - test("normalizes the required configuration", () => { - expect( - readGmailOAuthConfig({ - GMAIL_LIVE_CLIENT_ID: " client-id ", - GMAIL_LIVE_CLIENT_SECRET: " secret ", - }), - ).toMatchObject({ - clientId: "client-id", - clientSecret: "secret", - port: 8765, - scopes: ["https://www.googleapis.com/auth/gmail.modify"], - }); - }); - - test("rejects missing client credentials", () => { - expect(() => readGmailOAuthConfig({})).toThrow("GMAIL_LIVE_CLIENT_ID"); - expect(() => - readGmailOAuthConfig({ GMAIL_LIVE_CLIENT_ID: "client-id" }), - ).toThrow("GMAIL_LIVE_CLIENT_SECRET"); - }); - - test("sends the client secret when refreshing a token", async () => { - let requestBody = ""; - const token = await refreshGmailAccessToken( - { clientId: "client-id", clientSecret: "secret" }, - "refresh-token", - async (_input, init) => { - requestBody = String(init?.body); - return new Response( - JSON.stringify({ access_token: "access-token", expires_in: 3_600 }), - ); - }, - ); - - expect(token.accessToken).toBe("access-token"); - expect(requestBody).toContain("client_id=client-id"); - expect(requestBody).toContain("client_secret=secret"); - }); - - test("preserves Google OAuth error codes", async () => { - await expect( - refreshGmailAccessToken( - { clientId: "client-id", clientSecret: "secret" }, - "refresh-token", - async () => new Response('{"error":"invalid_grant"}', { status: 400 }), - ), - ).rejects.toThrow("invalid_grant"); - }); -}); diff --git a/tests/live/oauth.ts b/tests/live/oauth.ts deleted file mode 100644 index e2a39a6..0000000 --- a/tests/live/oauth.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { randomUUID } from "node:crypto"; - -import type { GmailFetch } from "../../src/client/index.js"; -import { getString, isRecord } from "./json.js"; -import type { StoredGmailToken } from "./token-store.js"; - -export const GMAIL_LIVE_SCOPES: readonly string[] = [ - "https://www.googleapis.com/auth/gmail.modify", -]; - -const AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; - -export type GmailOAuthConfig = { - clientId: string; - clientSecret: string; - tokenFile: string; - port: number; - scopes: readonly string[]; -}; - -export type GmailAccessToken = { - accessToken: string; - expiresAt: number; -}; - -export type GmailLiveLogger = { info(message: string): void }; - -export const defaultGmailLiveLogger: GmailLiveLogger = { - info(message) { - process.stdout.write(`${message}\n`); - }, -}; - -export function readGmailOAuthConfig( - env: NodeJS.ProcessEnv = process.env, -): GmailOAuthConfig { - const clientId = env.GMAIL_LIVE_CLIENT_ID?.trim(); - const clientSecret = env.GMAIL_LIVE_CLIENT_SECRET?.trim(); - if (clientId === undefined || clientId.length === 0) { - throw new Error("GMAIL_LIVE_CLIENT_ID is required for the live Gmail suite"); - } - if (clientSecret === undefined || clientSecret.length === 0) { - throw new Error("GMAIL_LIVE_CLIENT_SECRET is required for the live Gmail suite"); - } - return { - clientId, - clientSecret, - tokenFile: env.GMAIL_LIVE_TOKEN_FILE?.trim() || ".local/gmail-live-token.json", - port: parseLivePort(env.GMAIL_LIVE_PORT), - scopes: GMAIL_LIVE_SCOPES, - }; -} - -function parseLivePort(value: string | undefined): number { - if (value === undefined || value.trim().length === 0) return 8765; - const port = Number(value); - if (!Number.isInteger(port) || port < 1024 || port > 65_535) { - throw new Error("GMAIL_LIVE_PORT must be an integer between 1024 and 65535"); - } - return port; -} - -async function parseTokenResponse(response: Response): Promise> { - const raw = await response.text(); - let body: Record | undefined; - try { - const parsed: unknown = JSON.parse(raw); - body = isRecord(parsed) ? parsed : undefined; - } catch { - body = undefined; - } - if (!response.ok) { - const errorCode = - (body === undefined ? undefined : getString(body, "error")) ?? - `HTTP ${String(response.status)}`; - throw new Error(`Google OAuth token request failed: ${errorCode}`); - } - if (body === undefined) { - throw new Error("Google OAuth token response was not a JSON object"); - } - return body; -} - -function tokenFromResponse(body: Record): GmailAccessToken { - const accessToken = getString(body, "access_token"); - const expiresIn = body.expires_in; - if (typeof accessToken !== "string" || accessToken.length === 0) { - throw new Error("Google OAuth token response did not include access_token"); - } - if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn)) { - throw new Error("Google OAuth token response did not include expires_in"); - } - return { - accessToken, - expiresAt: Date.now() + expiresIn * 1_000, - }; -} - -export async function refreshGmailAccessToken( - config: Pick, - refreshToken: string, - fetchImpl: GmailFetch = globalThis.fetch, -): Promise { - const body = new URLSearchParams({ - client_id: config.clientId, - client_secret: config.clientSecret, - refresh_token: refreshToken, - grant_type: "refresh_token", - }); - const response = await fetchImpl(TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body, - }); - return tokenFromResponse(await parseTokenResponse(response)); -} - -async function exchangeAuthorizationCode( - config: Pick, - code: string, - redirectUri: string, - fetchImpl: GmailFetch, -): Promise { - const body = new URLSearchParams({ - client_id: config.clientId, - client_secret: config.clientSecret, - code, - grant_type: "authorization_code", - redirect_uri: redirectUri, - }); - const response = await fetchImpl(TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body, - }); - const parsed = await parseTokenResponse(response); - const refreshToken = getString(parsed, "refresh_token"); - if (typeof refreshToken !== "string" || refreshToken.length === 0) { - throw new Error( - "Google OAuth did not return a refresh token; revoke the prior grant and authorize again", - ); - } - const scope = getString(parsed, "scope"); - return { - refreshToken, - ...(typeof scope === "string" - ? { grantedScopes: scope.split(" ").filter(Boolean) } - : {}), - }; -} - -export async function authorizeGmail( - config: GmailOAuthConfig, - fetchImpl: GmailFetch = globalThis.fetch, - logger: GmailLiveLogger = defaultGmailLiveLogger, -): Promise { - const state = randomUUID(); - let stopServer = (): void => undefined; - const code = Promise.withResolvers(); - const server = Bun.serve({ - hostname: "127.0.0.1", - port: config.port, - fetch(request) { - const requestUrl = new URL(request.url); - if (requestUrl.pathname !== "/oauth2callback") { - return new Response("Not found", { status: 404 }); - } - if (requestUrl.searchParams.get("state") !== state) { - stopServer(); - code.reject(new Error("Google OAuth state mismatch")); - return new Response("Invalid OAuth state", { status: 400 }); - } - const error = requestUrl.searchParams.get("error"); - if (error !== null) { - stopServer(); - code.reject(new Error(`Google OAuth authorization failed: ${error}`)); - return new Response("Google OAuth was denied", { status: 400 }); - } - const authorizationCode = requestUrl.searchParams.get("code"); - if (authorizationCode === null || authorizationCode.length === 0) { - stopServer(); - code.reject(new Error("Google OAuth callback did not include a code")); - return new Response("Missing authorization code", { status: 400 }); - } - stopServer(); - code.resolve(authorizationCode); - return new Response("Authorization complete. You can close this window.", { - headers: { "Content-Type": "text/html" }, - }); - }, - }); - stopServer = () => server.stop(); - - const redirectUri = `http://127.0.0.1:${String(server.port)}/oauth2callback`; - const authorizationUrl = new URL(AUTHORIZATION_ENDPOINT); - authorizationUrl.search = new URLSearchParams({ - client_id: config.clientId, - redirect_uri: redirectUri, - response_type: "code", - scope: config.scopes.join(" "), - access_type: "offline", - prompt: "consent", - state, - }).toString(); - - const timer = setTimeout(() => { - stopServer(); - code.reject(new Error("timed out waiting for Google OAuth callback")); - }, 5 * 60 * 1_000); - logger.info(`Open this URL to authorize the Gmail live test:\n${authorizationUrl}`); - try { - const child = Bun.spawn(["open", authorizationUrl.toString()], { - stdout: "ignore", - stderr: "ignore", - }); - void child.exited; - } catch { - // The URL is already printed for environments without macOS `open`. - } - - const authorizationCode = await code.promise.finally(() => { - clearTimeout(timer); - stopServer(); - }); - - return exchangeAuthorizationCode(config, authorizationCode, redirectUri, fetchImpl); -} diff --git a/tests/live/token-store.ts b/tests/live/token-store.ts deleted file mode 100644 index c4dd038..0000000 --- a/tests/live/token-store.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname } from "node:path"; - -import { getArray, getString, isRecord } from "./json.js"; - -export type StoredGmailToken = { - refreshToken: string; - grantedScopes?: string[]; -}; - -export function createFileTokenStore(path: string) { - return { - async read() { - let raw: string; - try { - raw = await readFile(path, "utf8"); - } catch (error) { - if (isMissingFileError(error)) return undefined; - throw error; - } - try { - return parseStoredGmailToken(JSON.parse(raw)); - } catch (cause) { - throw new Error(`invalid Gmail token file: ${path}`, { cause }); - } - }, - async write(token: StoredGmailToken) { - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - await writeFile(path, `${JSON.stringify(token, null, 2)}\n`, { - encoding: "utf8", - mode: 0o600, - }); - await chmod(path, 0o600); - }, - }; -} - -function parseStoredGmailToken(value: unknown): StoredGmailToken { - if (!isRecord(value)) throw new Error("expected JSON object"); - const refreshToken = getString(value, "refreshToken"); - if (refreshToken === undefined || refreshToken.length === 0) { - throw new Error("refreshToken is required"); - } - const grantedScopes = getArray(value, "grantedScopes"); - const scopes = - grantedScopes?.filter((scope): scope is string => typeof scope === "string") ?? []; - if (grantedScopes !== undefined && scopes.length !== grantedScopes.length) { - throw new Error("grantedScopes must contain strings"); - } - return { refreshToken, ...(grantedScopes === undefined ? {} : { grantedScopes: scopes }) }; -} - -function isMissingFileError(error: unknown): boolean { - return isRecord(error) && error.code === "ENOENT"; -} diff --git a/tsconfig.live.json b/tsconfig.e2e.json similarity index 75% rename from tsconfig.live.json rename to tsconfig.e2e.json index 05082ea..8af2682 100644 --- a/tsconfig.live.json +++ b/tsconfig.e2e.json @@ -3,5 +3,5 @@ "compilerOptions": { "types": ["node", "@types/bun"] }, - "include": ["src", "tests/live"] + "include": ["src", "e2e"] } From fe84113dfb7eed0cad3a61cc011179e41213bb7c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 15:41:17 -0700 Subject: [PATCH 2/2] chore(release): 0.1.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 84b2853..d5fc27f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@corbits/google-tools", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "license": "LGPL-2.1-only", "description": "Corbits Google tools for Interchange, starting with Gmail.",