Skip to content

Commit 16216c4

Browse files
authored
Merge pull request #64 from reductoai/devin/1774573372-add-e2e-tests
Add basic E2E tests for SDK endpoints with GitHub Actions workflow
2 parents ac664a5 + 086bf00 commit 16216c4

File tree

3 files changed

+208
-1
lines changed

3 files changed

+208
-1
lines changed

.github/workflows/e2e.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: E2E Tests
2+
on:
3+
pull_request:
4+
branches:
5+
- main
6+
- next
7+
8+
jobs:
9+
e2e:
10+
timeout-minutes: 15
11+
name: e2e
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v6
15+
16+
- name: Set up Node
17+
uses: actions/setup-node@v4
18+
with:
19+
node-version: '20'
20+
21+
- name: Install dependencies
22+
run: yarn install
23+
24+
- name: Run E2E tests
25+
env:
26+
REDUCTO_API_KEY: ${{ secrets.REDUCTO_API_KEY }}
27+
run: npx jest tests/e2e/ --verbose --bail --testPathIgnorePatterns='scripts'

jest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const config: JestConfigWithTsJest = {
1717
'<rootDir>/deno/',
1818
'<rootDir>/deno_tests/',
1919
],
20-
testPathIgnorePatterns: ['scripts'],
20+
testPathIgnorePatterns: ['scripts', 'tests/e2e'],
2121
};
2222

2323
export default config;

tests/e2e/sdk.e2e.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* End-to-end tests for the Reducto Node SDK.
3+
*
4+
* These tests exercise the SDK against the live Reducto API to verify
5+
* that the SDK contract is working correctly. They are not testing the
6+
* actual parsing/extraction quality, just that the endpoints respond
7+
* with the expected structure.
8+
*
9+
* Required environment variable: REDUCTO_API_KEY
10+
*/
11+
12+
import Reducto from 'reductoai';
13+
14+
const DOCUMENT_URL = 'https://ci.reducto.ai/onepager.pdf';
15+
16+
const TRIVIAL_SCHEMA = {
17+
type: 'object',
18+
properties: {
19+
title: {
20+
type: 'string',
21+
description: 'The title of the document.',
22+
},
23+
},
24+
required: ['title'],
25+
};
26+
27+
const apiKey = process.env['REDUCTO_API_KEY'];
28+
29+
if (!apiKey) {
30+
throw new Error('REDUCTO_API_KEY environment variable is not set. E2E tests require a valid API key.');
31+
}
32+
33+
const client = new Reducto({ apiKey });
34+
35+
// Increase Jest timeout for E2E tests that hit the live API
36+
jest.setTimeout(180_000);
37+
38+
function sleep(ms: number): Promise<void> {
39+
return new Promise((resolve) => setTimeout(resolve, ms));
40+
}
41+
42+
describe('Parse', () => {
43+
test('parse sync returns response with chunks', async () => {
44+
const response = await client.parse.create({ input: DOCUMENT_URL });
45+
expect(response).toHaveProperty('job_id');
46+
expect(response).toHaveProperty('duration');
47+
expect(response).toHaveProperty('result');
48+
49+
const result = (response as Reducto.ParseResponse).result;
50+
if ('type' in result && result.type === 'full') {
51+
expect(result.chunks.length).toBeGreaterThan(0);
52+
}
53+
});
54+
});
55+
56+
describe('ParseAsync', () => {
57+
test('parse async returns job_id', async () => {
58+
const response = await client.parseAsync.create({ input: DOCUMENT_URL });
59+
expect(response).toHaveProperty('job_id');
60+
expect(typeof response.job_id).toBe('string');
61+
});
62+
63+
test('parse async job completes', async () => {
64+
const response = await client.parseAsync.create({ input: DOCUMENT_URL });
65+
const jobId = response.job_id;
66+
67+
for (let i = 0; i < 60; i++) {
68+
const job = await client.job.retrieve(jobId);
69+
expect(['Pending', 'Completed', 'Failed', 'Idle']).toContain(job.status);
70+
71+
if (job.status === 'Completed') {
72+
expect(job.result).not.toBeNull();
73+
return;
74+
}
75+
if (job.status === 'Failed') {
76+
throw new Error(`Parse async job failed: ${job.reason}`);
77+
}
78+
await sleep(2000);
79+
}
80+
81+
throw new Error('Parse async job did not complete within timeout');
82+
});
83+
});
84+
85+
describe('Extract', () => {
86+
test('extract sync returns result', async () => {
87+
const response = await client.extract.create({
88+
input: DOCUMENT_URL,
89+
instructions: { schema: TRIVIAL_SCHEMA },
90+
});
91+
expect(response).toHaveProperty('result');
92+
93+
const result = (response as Reducto.V3Extract).result;
94+
expect(result).toBeDefined();
95+
if (Array.isArray(result)) {
96+
expect(result.length).toBeGreaterThan(0);
97+
}
98+
});
99+
});
100+
101+
describe('ExtractAsync', () => {
102+
test('extract async returns job_id', async () => {
103+
const response = await client.extractAsync.create({
104+
input: DOCUMENT_URL,
105+
instructions: { schema: TRIVIAL_SCHEMA },
106+
});
107+
expect(response).toHaveProperty('job_id');
108+
expect(typeof response.job_id).toBe('string');
109+
});
110+
111+
test('extract async job completes', async () => {
112+
const response = await client.extractAsync.create({
113+
input: DOCUMENT_URL,
114+
instructions: { schema: TRIVIAL_SCHEMA },
115+
});
116+
const jobId = response.job_id;
117+
118+
for (let i = 0; i < 60; i++) {
119+
const job = await client.job.retrieve(jobId);
120+
expect(['Pending', 'Completed', 'Failed', 'Idle']).toContain(job.status);
121+
122+
if (job.status === 'Completed') {
123+
expect(job.result).not.toBeNull();
124+
return;
125+
}
126+
if (job.status === 'Failed') {
127+
throw new Error(`Extract async job failed: ${job.reason}`);
128+
}
129+
await sleep(2000);
130+
}
131+
132+
throw new Error('Extract async job did not complete within timeout');
133+
});
134+
});
135+
136+
describe('Upload', () => {
137+
test('upload returns file_id and presigned_url', async () => {
138+
const response = await client.upload.create();
139+
expect(response).toHaveProperty('file_id');
140+
expect(response).toHaveProperty('presigned_url');
141+
});
142+
143+
test('upload with extension returns reducto:// URI', async () => {
144+
const response = await client.upload.create({ extension: 'pdf' });
145+
expect(response).toHaveProperty('file_id');
146+
expect(response).toHaveProperty('presigned_url');
147+
expect(response.file_id.startsWith('reducto://')).toBe(true);
148+
});
149+
});
150+
151+
describe('Job', () => {
152+
test('job retrieve returns status for async parse job', async () => {
153+
const asyncResponse = await client.parseAsync.create({ input: DOCUMENT_URL });
154+
const jobId = asyncResponse.job_id;
155+
156+
const job = await client.job.retrieve(jobId);
157+
expect(['Pending', 'Completed', 'Failed', 'Idle']).toContain(job.status);
158+
});
159+
160+
test('job retrieve returns completed result', async () => {
161+
const asyncResponse = await client.parseAsync.create({ input: DOCUMENT_URL });
162+
const jobId = asyncResponse.job_id;
163+
164+
for (let i = 0; i < 60; i++) {
165+
const job = await client.job.retrieve(jobId);
166+
expect(['Pending', 'Completed', 'Failed', 'Idle']).toContain(job.status);
167+
168+
if (job.status === 'Completed') {
169+
expect(job.result).not.toBeNull();
170+
return;
171+
}
172+
if (job.status === 'Failed') {
173+
throw new Error(`Job failed: ${job.reason}`);
174+
}
175+
await sleep(2000);
176+
}
177+
178+
throw new Error('Job did not complete within timeout');
179+
});
180+
});

0 commit comments

Comments
 (0)