Skip to content

Commit e5ce14d

Browse files
committed
feat: add upload cache preflight
1 parent f4d90a3 commit e5ce14d

2 files changed

Lines changed: 367 additions & 0 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { test, afterEach } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import { createHash, randomUUID } from 'node:crypto';
4+
import fs from 'node:fs';
5+
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
6+
import os from 'node:os';
7+
import path from 'node:path';
8+
import { once } from 'node:events';
9+
import { uploadArtifact } from '../upload-client.ts';
10+
11+
const TEST_TOKEN = 'agent-device-upload-test-token';
12+
const tempDirs: string[] = [];
13+
14+
afterEach(async () => {
15+
for (const dir of tempDirs) {
16+
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
17+
}
18+
tempDirs.length = 0;
19+
});
20+
21+
test('uploadArtifact returns preflight uploadId without uploading bytes on cache hit', async () => {
22+
const content = 'cached-apk-payload';
23+
const artifactPath = createTempFile('app.apk', content);
24+
const expectedHash = sha256(content);
25+
let uploadCalled = false;
26+
27+
const server = await startServer(async (req, res) => {
28+
if (req.method === 'POST' && req.url === '/upload/preflight') {
29+
assert.equal(req.headers.authorization, `Bearer ${TEST_TOKEN}`);
30+
assert.equal(req.headers['x-agent-device-token'], TEST_TOKEN);
31+
const body = JSON.parse((await readRequestBody(req)).toString('utf8')) as {
32+
hash: string;
33+
hashAlgorithm: string;
34+
fileName: string;
35+
sizeBytes: number;
36+
artifactType: string;
37+
};
38+
assert.equal(body.hash, expectedHash);
39+
assert.equal(body.hashAlgorithm, 'sha256');
40+
assert.equal(body.fileName, 'app.apk');
41+
assert.equal(body.sizeBytes, Buffer.byteLength(content));
42+
assert.equal(body.artifactType, 'file');
43+
sendJson(res, { ok: true, cacheHit: true, uploadId: 'upload-cached' });
44+
return;
45+
}
46+
if (req.method === 'POST' && req.url === '/upload') {
47+
uploadCalled = true;
48+
await readRequestBody(req);
49+
sendJson(res, { ok: true, uploadId: 'upload-unexpected' });
50+
return;
51+
}
52+
res.statusCode = 404;
53+
res.end('not found');
54+
});
55+
56+
try {
57+
const uploadId = await uploadArtifact({
58+
localPath: artifactPath,
59+
baseUrl: server.baseUrl,
60+
token: TEST_TOKEN,
61+
});
62+
assert.equal(uploadId, 'upload-cached');
63+
assert.equal(uploadCalled, false);
64+
} finally {
65+
await server.close();
66+
}
67+
});
68+
69+
test('uploadArtifact uploads with hash headers after preflight cache miss', async () => {
70+
const content = 'fresh-apk-payload';
71+
const artifactPath = createTempFile('app.apk', content);
72+
const expectedHash = sha256(content);
73+
const requests: string[] = [];
74+
75+
const server = await startServer(async (req, res) => {
76+
requests.push(`${req.method} ${req.url}`);
77+
if (req.method === 'POST' && req.url === '/upload/preflight') {
78+
const body = JSON.parse((await readRequestBody(req)).toString('utf8')) as {
79+
hash: string;
80+
};
81+
assert.equal(body.hash, expectedHash);
82+
sendJson(res, { ok: true, cacheHit: false });
83+
return;
84+
}
85+
if (req.method === 'POST' && req.url === '/upload') {
86+
assert.equal(req.headers['x-artifact-type'], 'file');
87+
assert.equal(req.headers['x-artifact-filename'], 'app.apk');
88+
assert.equal(req.headers['x-artifact-hash'], expectedHash);
89+
assert.equal(req.headers['x-artifact-hash-algorithm'], 'sha256');
90+
assert.equal((await readRequestBody(req)).toString('utf8'), content);
91+
sendJson(res, { ok: true, uploadId: 'upload-miss' });
92+
return;
93+
}
94+
res.statusCode = 404;
95+
res.end('not found');
96+
});
97+
98+
try {
99+
const uploadId = await uploadArtifact({
100+
localPath: artifactPath,
101+
baseUrl: server.baseUrl,
102+
token: TEST_TOKEN,
103+
});
104+
assert.equal(uploadId, 'upload-miss');
105+
assert.deepEqual(requests, ['POST /upload/preflight', 'POST /upload']);
106+
} finally {
107+
await server.close();
108+
}
109+
});
110+
111+
test('uploadArtifact falls back to upload when preflight is unsupported', async () => {
112+
const content = 'legacy-daemon-payload';
113+
const artifactPath = createTempFile('app.apk', content);
114+
const expectedHash = sha256(content);
115+
const requests: string[] = [];
116+
117+
const server = await startServer(async (req, res) => {
118+
requests.push(`${req.method} ${req.url}`);
119+
if (req.method === 'POST' && req.url === '/upload/preflight') {
120+
await readRequestBody(req);
121+
res.statusCode = 404;
122+
res.end('not found');
123+
return;
124+
}
125+
if (req.method === 'POST' && req.url === '/upload') {
126+
assert.equal(req.headers['x-artifact-hash'], expectedHash);
127+
assert.equal(req.headers['x-artifact-hash-algorithm'], 'sha256');
128+
assert.equal((await readRequestBody(req)).toString('utf8'), content);
129+
sendJson(res, { ok: true, uploadId: 'upload-legacy' });
130+
return;
131+
}
132+
res.statusCode = 404;
133+
res.end('not found');
134+
});
135+
136+
try {
137+
const uploadId = await uploadArtifact({
138+
localPath: artifactPath,
139+
baseUrl: server.baseUrl,
140+
token: TEST_TOKEN,
141+
});
142+
assert.equal(uploadId, 'upload-legacy');
143+
assert.deepEqual(requests, ['POST /upload/preflight', 'POST /upload']);
144+
} finally {
145+
await server.close();
146+
}
147+
});
148+
149+
test('uploadArtifact skips preflight and hash headers for app bundle directories', async () => {
150+
const tempRoot = createTempDir();
151+
const appPath = path.join(tempRoot, 'Sample.app');
152+
fs.mkdirSync(appPath, { recursive: true });
153+
fs.writeFileSync(path.join(appPath, 'payload.txt'), 'app-bundle-payload');
154+
const requests: string[] = [];
155+
156+
const server = await startServer(async (req, res) => {
157+
requests.push(`${req.method} ${req.url}`);
158+
if (req.method === 'POST' && req.url === '/upload') {
159+
assert.equal(req.headers['x-artifact-type'], 'app-bundle');
160+
assert.equal(req.headers['x-artifact-filename'], 'Sample.app');
161+
assert.equal(req.headers['x-artifact-hash'], undefined);
162+
assert.equal(req.headers['x-artifact-hash-algorithm'], undefined);
163+
const body = await readRequestBody(req);
164+
assert.ok(body.length > 0);
165+
sendJson(res, { ok: true, uploadId: 'upload-app-bundle' });
166+
return;
167+
}
168+
res.statusCode = 500;
169+
res.end('unexpected request');
170+
});
171+
172+
try {
173+
const uploadId = await uploadArtifact({
174+
localPath: appPath,
175+
baseUrl: server.baseUrl,
176+
token: TEST_TOKEN,
177+
});
178+
assert.equal(uploadId, 'upload-app-bundle');
179+
assert.deepEqual(requests, ['POST /upload']);
180+
} finally {
181+
await server.close();
182+
}
183+
});
184+
185+
function createTempDir(): string {
186+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-upload-client-${randomUUID()}-`));
187+
tempDirs.push(dir);
188+
return dir;
189+
}
190+
191+
function createTempFile(filename: string, content: string): string {
192+
const dir = createTempDir();
193+
const filePath = path.join(dir, filename);
194+
fs.writeFileSync(filePath, content);
195+
return filePath;
196+
}
197+
198+
function sha256(content: string): string {
199+
return createHash('sha256').update(content).digest('hex');
200+
}
201+
202+
async function startServer(
203+
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>,
204+
): Promise<{ baseUrl: string; close: () => Promise<void> }> {
205+
const server = http.createServer((req, res) => {
206+
void handler(req, res).catch((error) => {
207+
res.statusCode = 500;
208+
res.end(error instanceof Error ? error.message : String(error));
209+
});
210+
});
211+
server.listen(0, '127.0.0.1');
212+
server.unref();
213+
await once(server, 'listening');
214+
const address = server.address();
215+
assert.ok(address && typeof address === 'object');
216+
return {
217+
baseUrl: `http://127.0.0.1:${address.port}`,
218+
close: async () => {
219+
await new Promise<void>((resolve, reject) => {
220+
server.close((error) => {
221+
if (error) reject(error);
222+
else resolve();
223+
});
224+
});
225+
},
226+
};
227+
}
228+
229+
async function readRequestBody(req: IncomingMessage): Promise<Buffer> {
230+
const chunks: Buffer[] = [];
231+
for await (const chunk of req) {
232+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
233+
}
234+
return Buffer.concat(chunks);
235+
}
236+
237+
function sendJson(res: ServerResponse, body: unknown): void {
238+
res.statusCode = 200;
239+
res.setHeader('content-type', 'application/json');
240+
res.end(JSON.stringify(body));
241+
}

0 commit comments

Comments
 (0)