Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch: {}

# Least-privilege GITHUB_TOKEN: every job here only reads the repo (checkout,
# install, lint, typecheck, test, audit). Widen per-job if one ever needs more.
Expand Down Expand Up @@ -48,6 +49,33 @@ jobs:
- name: Test
run: npm test

# Exercises a real Pinecone upsert/query round-trip (see tests/integration).
# Restricted to push/workflow_dispatch — never pull_request — because it's
# credentialed and a PR (including from a fork) must not get access to the
# PINECONE_API_KEY secret. The test itself also self-skips when the secret
# is absent, so a run against a fork or before secrets are configured is a
# clean no-op rather than a failure.
integration-test:
if: github.event_name != 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: 22.x
cache: npm

- name: Install dependencies
run: npm ci

- name: Integration test (self-skips without PINECONE_API_KEY)
run: npm run test:integration
env:
PINECONE_API_KEY: ${{ secrets.PINECONE_API_KEY }}
PINECONE_CLOUD: ${{ secrets.PINECONE_CLOUD }}
PINECONE_REGION: ${{ secrets.PINECONE_REGION }}

# Fails the build on high/critical advisories in production dependencies.
# Scoped to prod deps so a dev-only advisory can't wedge unrelated PRs, and to
# high/critical so low/moderate noise doesn't block work.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run -c vitest.integration.config.ts",
"lint": "eslint src",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --write \"**/*.ts\"",
Expand Down
100 changes: 100 additions & 0 deletions tests/integration/pinecone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Exercises a real upsert/query round-trip against a live Pinecone project —
// the gap that let a v2→v8 response-shape mismatch (see #5) ship undetected,
// since unit tests mock the SDK entirely.
//
// Skips outright when PINECONE_API_KEY is unset so `npm test` and PR runs
// stay green and credential-free; only `npm run test:integration` (wired into
// CI on main/workflow_dispatch, see ci.yml) actually exercises it.
import { randomUUID } from "crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
Pinecone,
type Index,
type PineconeRecord,
} from "@pinecone-database/pinecone";
import { getEnv } from "../../src/utils/env.ts";
import { chunkedUpsert } from "../../src/utils/chunkedUpsert.ts";

const DIMENSION = 384;
const NAMESPACE = "integration-test";

const randomVector = (length: number): number[] =>
Array.from({ length }, () => Math.random());

// Serverless upserts are eventually consistent, so a query issued right after
// an upsert can legitimately return fewer than the expected matches. Poll
// instead of a fixed sleep.
async function waitForMatches(
index: Index,
vector: number[],
expected: number,
{ attempts = 10, delayMs = 3000 } = {}
) {
for (let attempt = 0; attempt < attempts; attempt += 1) {
const result = await index.namespace(NAMESPACE).query({
vector,
topK: expected,
includeMetadata: true,
includeValues: true,
});
if ((result.matches?.length ?? 0) >= expected) {
return result;
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw new Error(`Query never returned ${expected} match(es) in time`);
}

describe.skipIf(!process.env.PINECONE_API_KEY)("Pinecone integration", () => {
const indexName = `recommender-it-${randomUUID().slice(0, 8)}`;
let pinecone: Pinecone;
let index: Index;
let indexCreated = false;

beforeAll(async () => {
pinecone = new Pinecone({ apiKey: getEnv("PINECONE_API_KEY") });
await pinecone.createIndex({
name: indexName,
dimension: DIMENSION,
metric: "cosine",
spec: {
serverless: {
cloud: getEnv("PINECONE_CLOUD"),
region: getEnv("PINECONE_REGION"),
},
},
waitUntilReady: true,
});
indexCreated = true;
index = pinecone.index(indexName);
}, 120_000);

afterAll(async () => {
if (indexCreated) {
await pinecone.deleteIndex(indexName);
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwaway indexes can leak

Medium Severity

indexCreated flips only after createIndex with waitUntilReady resolves, and teardown runs only in afterAll. A hook timeout or a cancelled CI job (this workflow uses cancel-in-progress) leaves the live recommender-it-* index behind, which can exhaust the project index quota and fail later creates.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5b57c29. Configure here.


it("upserts and queries records through the real chunkedUpsert path", async () => {
const records: PineconeRecord[] = Array.from({ length: 5 }, (_, i) => ({
id: `article-${i}`,
values: randomVector(DIMENSION),
metadata: { title: `Article ${i}` },
}));

await chunkedUpsert(index, records, NAMESPACE, 2);

const result = await waitForMatches(
index,
records[0].values as number[],
records.length
);

expect(result.matches?.length).toBeGreaterThan(0);
for (const match of result.matches ?? []) {
expect(match.id).toMatch(/^article-\d+$/);
expect(match.values).toHaveLength(DIMENSION);
expect(match.metadata?.title).toBeDefined();
}
}, 60_000);
});
6 changes: 5 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { defineConfig } from "vitest/config";
import { configDefaults, defineConfig } from "vitest/config";

// Integration tests live under tests/integration and run via the separate
// vitest.integration.config.ts / `npm run test:integration`, never here — they
// hit a real Pinecone project and must not run on untrusted PRs.
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
exclude: [...configDefaults.exclude, "tests/integration/**"],
environment: "node",
},
});
14 changes: 14 additions & 0 deletions vitest.integration.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";

// Separate from vitest.config.ts so a plain `npm test` never touches the
// network: this config's tests hit a real Pinecone project and are run
// explicitly via `npm run test:integration` (see ci.yml's integration-test
// job).
export default defineConfig({
test: {
include: ["tests/integration/**/*.test.ts"],
environment: "node",
testTimeout: 120_000,
hookTimeout: 120_000,
},
});