fix: running tests and in ci (#536)

* fix: running tests and in ci

* cr

* cr

* cr

* exit 0 when no tests

* cr
This commit is contained in:
Brace Sproul 2025-07-27 12:11:34 -07:00 • committed by GitHub
parent 576d2553e3
commit 0923c8b47e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 86 additions and 34 deletions

40
.github/workflows/unit-tests.yml vendored Normal file
View file

@ -0,0 +1,40 @@
# This workflow will run unit tests for the current project
name: Unit Tests
permissions:
contents: read
on:
push:
branches: ["main"]
pull_request:
workflow_dispatch: # Allows triggering the workflow manually in GitHub UI
# If another push to the same PR or branch happens while this workflow is still running,
# cancel the earlier run in favor of the next run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
unit-tests:
name: Unit Tests
strategy:
matrix:
os: [ubuntu-latest]
node-version: [18.x, 20.x]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Enable Corepack
run: corepack enable
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
- name: Install dependencies
run: yarn install --immutable --mode=skip-build
- name: Build project
run: yarn build
- name: Run tests
run: yarn test

View file

@ -12,7 +12,7 @@
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 0",
"dev": "tsx src/index.tsx" "dev": "tsx src/index.tsx"
}, },
"dependencies": { "dependencies": {

View file

@ -2,6 +2,8 @@ export default {
preset: "ts-jest/presets/default-esm", preset: "ts-jest/presets/default-esm",
moduleNameMapper: { moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1", "^(\\.{1,2}/.*)\\.js$": "$1",
"^@open-swe/shared$": "<rootDir>/../../packages/shared/src/index.ts",
"^@open-swe/shared/(.*)$": "<rootDir>/../../packages/shared/src/$1",
}, },
transform: { transform: {
"^.+\\.tsx?$": [ "^.+\\.tsx?$": [
@ -15,4 +17,5 @@ export default {
setupFiles: ["dotenv/config"], setupFiles: ["dotenv/config"],
passWithNoTests: true, passWithNoTests: true,
testTimeout: 20_000, testTimeout: 20_000,
testMatch: ["<rootDir>/src/**/*.test.ts"],
}; };

View file

@ -16,8 +16,8 @@
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.test\\.ts$ --testPathIgnorePatterns=\\.int\\.test\\.ts$", "test": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.js --testPathIgnorePatterns=int.test.ts",
"test:int": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathPattern=\\.int\\.test\\.ts$", "test:int": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config jest.config.js --testPathPattern=int.test.ts",
"test:single": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.js --testTimeout 100000", "test:single": "NODE_OPTIONS=--experimental-vm-modules yarn run jest --config jest.config.js --testTimeout 100000",
"eval:single": "NODE_OPTIONS=--experimental-vm-modules yarn run vitest --config ls.vitest.config.ts --run", "eval:single": "NODE_OPTIONS=--experimental-vm-modules yarn run vitest --config ls.vitest.config.ts --run",
"postinstall": "turbo build" "postinstall": "turbo build"

View file

@ -31,7 +31,9 @@ describe("withRetry", () => {
.fn<() => Promise<string>>() .fn<() => Promise<string>>()
.mockRejectedValue(new Error("always fails")); .mockRejectedValue(new Error("always fails"));
await expect(withRetry(mockFn)).rejects.toThrow("always fails"); const result = await withRetry(mockFn);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe("always fails");
expect(mockFn).toHaveBeenCalledTimes(4); // 1 initial + 3 retries expect(mockFn).toHaveBeenCalledTimes(4); // 1 initial + 3 retries
}); });
@ -40,9 +42,9 @@ describe("withRetry", () => {
.fn<() => Promise<string>>() .fn<() => Promise<string>>()
.mockRejectedValue(new Error("always fails")); .mockRejectedValue(new Error("always fails"));
await expect(withRetry(mockFn, { retries: 2 })).rejects.toThrow( const result = await withRetry(mockFn, { retries: 2 });
"always fails", expect(result).toBeInstanceOf(Error);
); expect((result as Error).message).toBe("always fails");
expect(mockFn).toHaveBeenCalledTimes(3); // 1 initial + 2 retries expect(mockFn).toHaveBeenCalledTimes(3); // 1 initial + 2 retries
}); });
@ -52,11 +54,11 @@ describe("withRetry", () => {
.mockRejectedValue(new Error("always fails")); .mockRejectedValue(new Error("always fails"));
const startTime = Date.now(); const startTime = Date.now();
await expect(withRetry(mockFn, { retries: 2, delay: 100 })).rejects.toThrow( const result = await withRetry(mockFn, { retries: 2, delay: 100 });
"always fails",
);
const endTime = Date.now(); const endTime = Date.now();
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe("always fails");
expect(mockFn).toHaveBeenCalledTimes(3); expect(mockFn).toHaveBeenCalledTimes(3);
expect(endTime - startTime).toBeGreaterThanOrEqual(200); // 2 delays of 100ms each expect(endTime - startTime).toBeGreaterThanOrEqual(200); // 2 delays of 100ms each
}); });
@ -67,11 +69,11 @@ describe("withRetry", () => {
.mockRejectedValue(new Error("always fails")); .mockRejectedValue(new Error("always fails"));
const startTime = Date.now(); const startTime = Date.now();
await expect(withRetry(mockFn, { retries: 2 })).rejects.toThrow( const result = await withRetry(mockFn, { retries: 2 });
"always fails",
);
const endTime = Date.now(); const endTime = Date.now();
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe("always fails");
expect(mockFn).toHaveBeenCalledTimes(3); expect(mockFn).toHaveBeenCalledTimes(3);
expect(endTime - startTime).toBeLessThan(50); // Should be very fast with no delay expect(endTime - startTime).toBeLessThan(50); // Should be very fast with no delay
}); });
@ -81,13 +83,13 @@ describe("withRetry", () => {
.fn<() => Promise<string>>() .fn<() => Promise<string>>()
.mockRejectedValue("string error"); .mockRejectedValue("string error");
await expect(withRetry(mockFn, { retries: 1 })).rejects.toThrow( const result = await withRetry(mockFn, { retries: 1 });
"string error", expect(result).toBeInstanceOf(Error);
); expect((result as Error).message).toBe("string error");
expect(mockFn).toHaveBeenCalledTimes(2); expect(mockFn).toHaveBeenCalledTimes(2);
}); });
it("should throw the last error after all retries", async () => { it("should return the last error after all retries", async () => {
const error1 = new Error("first error"); const error1 = new Error("first error");
const error2 = new Error("second error"); const error2 = new Error("second error");
const lastError = new Error("last error"); const lastError = new Error("last error");
@ -98,9 +100,9 @@ describe("withRetry", () => {
.mockRejectedValueOnce(error2) .mockRejectedValueOnce(error2)
.mockRejectedValue(lastError); .mockRejectedValue(lastError);
await expect(withRetry(mockFn, { retries: 2 })).rejects.toThrow( const result = await withRetry(mockFn, { retries: 2 });
"last error", expect(result).toBeInstanceOf(Error);
); expect((result as Error).message).toBe("last error");
expect(mockFn).toHaveBeenCalledTimes(3); expect(mockFn).toHaveBeenCalledTimes(3);
}); });
@ -140,7 +142,9 @@ describe("createRetryWrapper", () => {
const wrappedFn = createRetryWrapper(originalFn, { retries: 1 }); const wrappedFn = createRetryWrapper(originalFn, { retries: 1 });
await expect(wrappedFn()).rejects.toThrow("always fails"); const result = await wrappedFn();
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe("always fails");
expect(originalFn).toHaveBeenCalledTimes(2); // 1 initial + 1 retry expect(originalFn).toHaveBeenCalledTimes(2); // 1 initial + 1 retry
}); });

View file

@ -6,7 +6,7 @@ import {
MAX_INTERNAL_TOKENS, MAX_INTERNAL_TOKENS,
} from "../utils/tokens.js"; } from "../utils/tokens.js";
describe("calculateConversationHistoryTokenCount", async () => { describe("calculateConversationHistoryTokenCount", () => {
it("should return 0 for empty messages array", async () => { it("should return 0 for empty messages array", async () => {
const result = calculateConversationHistoryTokenCount([]); const result = calculateConversationHistoryTokenCount([]);
expect(result).toBe(0); expect(result).toBe(0);
@ -124,8 +124,8 @@ describe("calculateConversationHistoryTokenCount", async () => {
}); });
expect(resultWithoutOption).toBeGreaterThan(resultWithOption); expect(resultWithoutOption).toBeGreaterThan(resultWithOption);
// First two messages should be ~7 tokens // First two messages should be ~8 tokens
expect(resultWithOption).toBe(7); expect(resultWithOption).toBe(8);
}); });
it("should not separate AI messages with tool calls from their tool messages when excluding from end", async () => { it("should not separate AI messages with tool calls from their tool messages when excluding from end", async () => {
@ -214,7 +214,7 @@ describe("calculateConversationHistoryTokenCount", async () => {
}); });
}); });
describe("getMessagesSinceLastSummary", async () => { describe("getMessagesSinceLastSummary", () => {
it("should return all messages when there is no summary message", async () => { it("should return all messages when there is no summary message", async () => {
const messages = [ const messages = [
new HumanMessage({ content: "Message 1" }), new HumanMessage({ content: "Message 1" }),
@ -632,7 +632,7 @@ describe("getMessagesSinceLastSummary", async () => {
}); });
}); });
describe("MAX_INTERNAL_TOKENS constant", async () => { describe("MAX_INTERNAL_TOKENS constant", () => {
it("should be defined as 60,000", async () => { it("should be defined as 60,000", async () => {
expect(MAX_INTERNAL_TOKENS).toBe(60_000); expect(MAX_INTERNAL_TOKENS).toBe(60_000);
}); });

View file

@ -17,7 +17,7 @@ export function calculateConversationHistoryTokenCount(
excludeCountFromEnd?: number; excludeCountFromEnd?: number;
}, },
) { ) {
let totalChars = 0; let totalTokens = 0;
let messagesToCount = messages; let messagesToCount = messages;
if (options?.excludeCountFromEnd && options.excludeCountFromEnd > 0) { if (options?.excludeCountFromEnd && options.excludeCountFromEnd > 0) {
@ -33,25 +33,25 @@ export function calculateConversationHistoryTokenCount(
if (isHumanMessage(m) || isToolMessage(m)) { if (isHumanMessage(m) || isToolMessage(m)) {
const contentString = getMessageContentString(m.content); const contentString = getMessageContentString(m.content);
// Divide each char by 4 as it's roughly one token per 4 characters. // Divide each char by 4 as it's roughly one token per 4 characters.
totalChars += contentString.length / 4; totalTokens += Math.ceil(contentString.length / 4);
} }
if (isAIMessage(m)) { if (isAIMessage(m)) {
const usageMetadata = m.usage_metadata; const usageMetadata = m.usage_metadata;
if (usageMetadata) { if (usageMetadata) {
totalChars += usageMetadata.output_tokens; totalTokens += usageMetadata.total_tokens;
} else { } else {
const contentString = getMessageContentString(m.content); const contentString = getMessageContentString(m.content);
totalChars += contentString.length / 4; totalTokens += Math.ceil(contentString.length / 4);
m.tool_calls?.forEach((tc) => { m.tool_calls?.forEach((tc) => {
const nameAndArgs = tc.name + JSON.stringify(tc.args); const nameAndArgs = tc.name + JSON.stringify(tc.args);
totalChars += nameAndArgs.length / 4; totalTokens += Math.ceil(nameAndArgs.length / 4);
}); });
} }
} }
}); });
return totalChars; return totalTokens;
} }
/** /**

View file

@ -18,7 +18,8 @@
"strictFunctionTypes": false, "strictFunctionTypes": false,
"outDir": "dist", "outDir": "dist",
"types": ["jest", "node"], "types": ["jest", "node"],
"resolveJsonModule": true "resolveJsonModule": true,
"isolatedModules": true
}, },
"include": ["**/*.ts", "**/*.js", "jest.setup.cjs"], "include": ["**/*.ts", "**/*.js", "jest.setup.cjs"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]

View file

@ -13,7 +13,8 @@
"format": "turbo format", "format": "turbo format",
"format:check": "turbo format:check", "format:check": "turbo format:check",
"lint": "turbo lint", "lint": "turbo lint",
"lint:fix": "turbo lint:fix" "lint:fix": "turbo lint:fix",
"test": "turbo test"
}, },
"devDependencies": { "devDependencies": {
"turbo": "^2.5.0", "turbo": "^2.5.0",

View file

@ -20,6 +20,9 @@
}, },
"dev": { "dev": {
"dependsOn": ["^dev"] "dependsOn": ["^dev"]
},
"test": {
"dependsOn": ["^test"]
} }
} }
} }