@devicecloud.dev/dcd 5.7.0 → 5.8.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/gateways/api-gateway.d.ts +30 -2
- package/dist/gateways/api-gateway.js +59 -6
- package/dist/services/metadata-extractor.service.js +5 -10
- package/dist/services/results-polling.service.d.ts +1 -0
- package/dist/services/results-polling.service.js +14 -4
- package/dist/utils/android-manifest.d.ts +6 -0
- package/dist/utils/android-manifest.js +97 -0
- package/package.json +5 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { TAppMetadata } from '../types.js';
|
|
2
2
|
import type { AuthContext } from '../types/domain/auth.types.js';
|
|
3
3
|
import type { LiveCommandStatus, LiveExecResult, LiveSession, LiveSessionSummary } from '../types/domain/live.types.js';
|
|
4
|
-
import { components } from '../types/generated/schema.types.js';
|
|
4
|
+
import { components, paths } from '../types/generated/schema.types.js';
|
|
5
5
|
/**
|
|
6
6
|
* Error thrown for non-OK API responses, carrying the HTTP status so callers
|
|
7
7
|
* can branch on auth failures etc. without string matching.
|
|
@@ -10,6 +10,17 @@ export declare class ApiError extends Error {
|
|
|
10
10
|
status: number;
|
|
11
11
|
constructor(message: string, status: number);
|
|
12
12
|
}
|
|
13
|
+
type ResultsResponse = paths['/results/{uploadId}']['get']['responses']['200']['content']['application/json'];
|
|
14
|
+
/**
|
|
15
|
+
* The last results body a poll loop received and the ETag it came with, so
|
|
16
|
+
* the next poll can revalidate with `If-None-Match` and get an empty 304 back
|
|
17
|
+
* when nothing changed. Create one per poll loop; `getResultsForUpload` fills
|
|
18
|
+
* and clears it.
|
|
19
|
+
*/
|
|
20
|
+
export interface ResultsPollCache {
|
|
21
|
+
body?: ResultsResponse;
|
|
22
|
+
etag?: string;
|
|
23
|
+
}
|
|
13
24
|
export declare const ApiGateway: {
|
|
14
25
|
/**
|
|
15
26
|
* Enhances generic "fetch failed" errors with more specific diagnostic information
|
|
@@ -98,7 +109,23 @@ export declare const ApiGateway: {
|
|
|
98
109
|
token?: string;
|
|
99
110
|
}>;
|
|
100
111
|
finishLargeFile(baseUrl: string, auth: AuthContext, fileId: string, partSha1Array: string[]): Promise<unknown>;
|
|
101
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Fetch the result rows for an upload, for the `dcd cloud` poll loop.
|
|
114
|
+
*
|
|
115
|
+
* Asks for `?view=summary`, which leaves out `result_files` and the rest of
|
|
116
|
+
* the columns the CLI never reads. An API that predates the view ignores
|
|
117
|
+
* the parameter and returns the full rows, a superset, so this works
|
|
118
|
+
* against either.
|
|
119
|
+
*
|
|
120
|
+
* With a `cache`, a repeat poll revalidates with `If-None-Match` and a 304
|
|
121
|
+
* hands back the cached body.
|
|
122
|
+
* @param baseUrl API base URL
|
|
123
|
+
* @param auth AuthContext carrying request headers
|
|
124
|
+
* @param uploadId Upload ID to fetch results for
|
|
125
|
+
* @param cache Optional per-poll-loop cache, filled and cleared here
|
|
126
|
+
* @returns The results response body
|
|
127
|
+
*/
|
|
128
|
+
getResultsForUpload(baseUrl: string, auth: AuthContext, uploadId: string, cache?: ResultsPollCache): Promise<{
|
|
102
129
|
statusCode?: number;
|
|
103
130
|
results?: components["schemas"]["TResultResponse"][];
|
|
104
131
|
}>;
|
|
@@ -209,3 +236,4 @@ export declare const ApiGateway: {
|
|
|
209
236
|
stopLiveSession(baseUrl: string, auth: AuthContext, sessionName: string): Promise<void>;
|
|
210
237
|
getLiveSession(baseUrl: string, auth: AuthContext, sessionName: string): Promise<LiveSession>;
|
|
211
238
|
};
|
|
239
|
+
export {};
|
|
@@ -347,20 +347,73 @@ export const ApiGateway = {
|
|
|
347
347
|
throw error;
|
|
348
348
|
}
|
|
349
349
|
},
|
|
350
|
-
|
|
350
|
+
/**
|
|
351
|
+
* Fetch the result rows for an upload, for the `dcd cloud` poll loop.
|
|
352
|
+
*
|
|
353
|
+
* Asks for `?view=summary`, which leaves out `result_files` and the rest of
|
|
354
|
+
* the columns the CLI never reads. An API that predates the view ignores
|
|
355
|
+
* the parameter and returns the full rows, a superset, so this works
|
|
356
|
+
* against either.
|
|
357
|
+
*
|
|
358
|
+
* With a `cache`, a repeat poll revalidates with `If-None-Match` and a 304
|
|
359
|
+
* hands back the cached body.
|
|
360
|
+
* @param baseUrl API base URL
|
|
361
|
+
* @param auth AuthContext carrying request headers
|
|
362
|
+
* @param uploadId Upload ID to fetch results for
|
|
363
|
+
* @param cache Optional per-poll-loop cache, filled and cleared here
|
|
364
|
+
* @returns The results response body
|
|
365
|
+
*/
|
|
366
|
+
async getResultsForUpload(baseUrl, auth, uploadId, cache) {
|
|
351
367
|
// TODO: merge with getUploadStatus
|
|
368
|
+
const url = `${baseUrl}/results/${uploadId}?view=summary`;
|
|
352
369
|
try {
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
370
|
+
const headers = { ...auth.headers };
|
|
371
|
+
if (cache?.body && cache.etag) {
|
|
372
|
+
headers['if-none-match'] = cache.etag;
|
|
373
|
+
// Per the Fetch spec, undici appends `Cache-Control: no-cache` to any
|
|
374
|
+
// request that carries a conditional header and no Cache-Control of
|
|
375
|
+
// its own, and Express's `fresh` never answers 304 to a no-cache
|
|
376
|
+
// request. So every poll would get a full 200. max-age=0 still asks
|
|
377
|
+
// for revalidation, and keeps undici from adding no-cache.
|
|
378
|
+
headers['cache-control'] = 'max-age=0';
|
|
379
|
+
}
|
|
380
|
+
const res = await fetch(url, { headers });
|
|
381
|
+
// Before the `!res.ok` check, since a 304 is not `ok`.
|
|
382
|
+
if (res.status === 304) {
|
|
383
|
+
if (cache?.body) {
|
|
384
|
+
return cache.body;
|
|
385
|
+
}
|
|
386
|
+
// We sent no If-None-Match, so there is nothing to reuse. Start over
|
|
387
|
+
// with an unconditional request on the next poll.
|
|
388
|
+
if (cache) {
|
|
389
|
+
delete cache.body;
|
|
390
|
+
delete cache.etag;
|
|
391
|
+
}
|
|
392
|
+
throw new Error('Failed to get results: API returned 304 Not Modified to an unconditional request');
|
|
393
|
+
}
|
|
356
394
|
if (!res.ok) {
|
|
357
395
|
await this.handleApiError(res, 'Failed to get results');
|
|
358
396
|
}
|
|
359
|
-
|
|
397
|
+
const body = await parseJsonResponse(res, 'Failed to get results');
|
|
398
|
+
if (cache) {
|
|
399
|
+
// Only pin a real result set. The API reports some errors as a 200
|
|
400
|
+
// with a `{ statusCode, message }` envelope, and an empty set is
|
|
401
|
+
// rejected by the poll loop; caching either would replay it on 304.
|
|
402
|
+
const etag = res.headers.get('etag');
|
|
403
|
+
if (etag && Array.isArray(body.results) && body.results.length > 0) {
|
|
404
|
+
cache.body = body;
|
|
405
|
+
cache.etag = etag;
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
delete cache.body;
|
|
409
|
+
delete cache.etag;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return body;
|
|
360
413
|
}
|
|
361
414
|
catch (error) {
|
|
362
415
|
if (error instanceof TypeError && error.message === 'fetch failed') {
|
|
363
|
-
throw this.enhanceFetchError(error,
|
|
416
|
+
throw this.enhanceFetchError(error, url);
|
|
364
417
|
}
|
|
365
418
|
throw error;
|
|
366
419
|
}
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import { parseBuffer } from 'bplist-parser';
|
|
2
|
-
import nodeApk from 'node-apk';
|
|
3
2
|
import { readFile, rm } from 'node:fs/promises';
|
|
4
3
|
import * as path from 'node:path';
|
|
5
4
|
import StreamZip from 'node-stream-zip';
|
|
6
5
|
import { parse } from 'plist';
|
|
7
|
-
|
|
8
|
-
// (cjs-module-lexer) is version-dependent, so destructure off the default import
|
|
9
|
-
// instead — that interop is guaranteed on every Node version. bplist-parser 0.5
|
|
10
|
-
// is a real ESM/CJS dual package with named exports, so it imports directly.
|
|
11
|
-
const { Apk } = nodeApk;
|
|
6
|
+
import { readManifestPackage } from '../utils/android-manifest.js';
|
|
12
7
|
/**
|
|
13
8
|
* Parses an Info.plist buffer (XML, UTF-8 BOM'd XML, or binary bplist).
|
|
14
9
|
* Shared by the .app and .zip extractors.
|
|
@@ -36,13 +31,13 @@ export class AndroidMetadataExtractor {
|
|
|
36
31
|
return filePath.endsWith('.apk');
|
|
37
32
|
}
|
|
38
33
|
async extract(filePath) {
|
|
39
|
-
const
|
|
34
|
+
const zip = new StreamZip.async({ file: filePath });
|
|
40
35
|
try {
|
|
41
|
-
const manifest = await
|
|
42
|
-
return { appId: manifest
|
|
36
|
+
const manifest = await zip.entryData('AndroidManifest.xml');
|
|
37
|
+
return { appId: readManifestPackage(manifest), platform: 'android' };
|
|
43
38
|
}
|
|
44
39
|
finally {
|
|
45
|
-
|
|
40
|
+
await zip.close().catch(() => { });
|
|
46
41
|
}
|
|
47
42
|
}
|
|
48
43
|
}
|
|
@@ -118,6 +118,7 @@ export declare class ResultsPollingService {
|
|
|
118
118
|
* @param apiUrl API base URL
|
|
119
119
|
* @param auth AuthContext carrying request headers
|
|
120
120
|
* @param uploadId Upload ID to fetch results for
|
|
121
|
+
* @param cache The poll loop's results cache, for ETag revalidation
|
|
121
122
|
* @param debug Whether debug logging is enabled
|
|
122
123
|
* @param logger Optional logger function
|
|
123
124
|
* @returns Promise resolving to test results
|
|
@@ -133,6 +133,9 @@ export class ResultsPollingService {
|
|
|
133
133
|
let { auth } = options;
|
|
134
134
|
this.initializePollingDisplay(json, logger);
|
|
135
135
|
let sequentialPollFailures = 0;
|
|
136
|
+
// The last result set and its ETag, so a poll that finds nothing changed
|
|
137
|
+
// gets an empty 304 instead of the whole set again.
|
|
138
|
+
const resultsCache = {};
|
|
136
139
|
const pollIntervalMs = auth.mode === 'bearer'
|
|
137
140
|
? this.BEARER_POLL_INTERVAL_MS
|
|
138
141
|
: this.APIKEY_POLL_INTERVAL_MS;
|
|
@@ -229,7 +232,7 @@ export class ResultsPollingService {
|
|
|
229
232
|
nextPollAt = null;
|
|
230
233
|
renderStatus();
|
|
231
234
|
auth = await this.refreshPollingAuth(auth, uploadId, subscription, debug, logger);
|
|
232
|
-
const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, debug, logger);
|
|
235
|
+
const updatedResults = await this.fetchAndLogResults(apiUrl, auth, uploadId, resultsCache, debug, logger);
|
|
233
236
|
const { summary } = this.calculateStatusSummary(updatedResults);
|
|
234
237
|
if (!json) {
|
|
235
238
|
statusBody = this.buildStatusBody(updatedResults, quiet, summary);
|
|
@@ -395,21 +398,28 @@ export class ResultsPollingService {
|
|
|
395
398
|
* @param apiUrl API base URL
|
|
396
399
|
* @param auth AuthContext carrying request headers
|
|
397
400
|
* @param uploadId Upload ID to fetch results for
|
|
401
|
+
* @param cache The poll loop's results cache, for ETag revalidation
|
|
398
402
|
* @param debug Whether debug logging is enabled
|
|
399
403
|
* @param logger Optional logger function
|
|
400
404
|
* @returns Promise resolving to test results
|
|
401
405
|
*/
|
|
402
|
-
async fetchAndLogResults(apiUrl, auth, uploadId, debug, logger) {
|
|
406
|
+
async fetchAndLogResults(apiUrl, auth, uploadId, cache, debug, logger) {
|
|
403
407
|
if (debug && logger) {
|
|
404
408
|
logger(`[DEBUG] Polling for results: ${uploadId}`);
|
|
405
409
|
}
|
|
406
|
-
|
|
410
|
+
// A 304 hands back the cached body itself; a 200 is always a new object.
|
|
411
|
+
const cachedBody = cache.body;
|
|
412
|
+
const response = await ApiGateway.getResultsForUpload(apiUrl, auth, uploadId, cache);
|
|
413
|
+
const notModified = cachedBody !== undefined && response === cachedBody;
|
|
414
|
+
const { results: updatedResults } = response;
|
|
407
415
|
// An empty array would otherwise read as "all complete, all passed".
|
|
408
416
|
if (!updatedResults || updatedResults.length === 0) {
|
|
409
417
|
throw new Error('no results');
|
|
410
418
|
}
|
|
411
419
|
if (debug && logger) {
|
|
412
|
-
logger(
|
|
420
|
+
logger(notModified
|
|
421
|
+
? `[DEBUG] Poll not modified (304), reusing ${updatedResults.length} cached results`
|
|
422
|
+
: `[DEBUG] Poll received ${updatedResults.length} results`);
|
|
413
423
|
for (const result of updatedResults) {
|
|
414
424
|
logger(`[DEBUG] Result status: ${result.test_file_name} - ${result.status}`);
|
|
415
425
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Reads the package name out of an APK's compiled AndroidManifest.xml (Android
|
|
2
|
+
// binary XML). This replaced node-apk, whose node-forge dependency carried an
|
|
3
|
+
// unpatched advisory for certificate code the CLI never used. Layouts follow
|
|
4
|
+
// the ResChunk_header / ResStringPool_header / ResXMLTree_* structs in AOSP's
|
|
5
|
+
// libandroidfw ResourceTypes.h; every integer is little-endian.
|
|
6
|
+
const RES_STRING_POOL_TYPE = 0x0001;
|
|
7
|
+
const RES_XML_TYPE = 0x0003;
|
|
8
|
+
const RES_XML_START_ELEMENT_TYPE = 0x0102;
|
|
9
|
+
const UTF8_FLAG = 0x0100;
|
|
10
|
+
const TYPE_STRING = 0x03;
|
|
11
|
+
const NO_INDEX = 0xffff_ffff;
|
|
12
|
+
const CHUNK_HEADER_SIZE = 8;
|
|
13
|
+
/**
|
|
14
|
+
* Returns a lookup for the strings in the string pool chunk at `offset`.
|
|
15
|
+
* Strings are decoded on demand, so a malformed entry the manifest never
|
|
16
|
+
* refers to can't fail the parse.
|
|
17
|
+
*/
|
|
18
|
+
function stringPool(xml, offset) {
|
|
19
|
+
const headerSize = xml.readUInt16LE(offset + 2);
|
|
20
|
+
const count = xml.readUInt32LE(offset + 8);
|
|
21
|
+
const utf8 = (xml.readUInt32LE(offset + 16) & UTF8_FLAG) !== 0;
|
|
22
|
+
const stringsStart = offset + xml.readUInt32LE(offset + 20);
|
|
23
|
+
return (index) => {
|
|
24
|
+
if (index >= count) {
|
|
25
|
+
throw new Error(`String index ${index} is out of range`);
|
|
26
|
+
}
|
|
27
|
+
let pos = stringsStart + xml.readUInt32LE(offset + headerSize + index * 4);
|
|
28
|
+
if (utf8) {
|
|
29
|
+
// Two lengths, each 1 byte or 2 when the high bit is set: the UTF-16
|
|
30
|
+
// length (unused here), then the UTF-8 byte length.
|
|
31
|
+
pos += xml[pos] & 0x80 ? 2 : 1;
|
|
32
|
+
let length = xml[pos++];
|
|
33
|
+
if (length & 0x80)
|
|
34
|
+
length = ((length & 0x7f) << 8) | xml[pos++];
|
|
35
|
+
return xml.toString('utf8', pos, pos + length);
|
|
36
|
+
}
|
|
37
|
+
// UTF-16 length in code units: 2 bytes, or 4 when the high bit is set.
|
|
38
|
+
let length = xml.readUInt16LE(pos);
|
|
39
|
+
pos += 2;
|
|
40
|
+
if (length & 0x80_00) {
|
|
41
|
+
length = ((length & 0x7f_ff) << 16) | xml.readUInt16LE(pos);
|
|
42
|
+
pos += 2;
|
|
43
|
+
}
|
|
44
|
+
return xml.toString('utf16le', pos, pos + length * 2);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Reads the `package` attribute of the root `<manifest>` element.
|
|
49
|
+
* @param xml - Contents of the APK's AndroidManifest.xml entry
|
|
50
|
+
* @returns The application id, e.g. `org.wikipedia`
|
|
51
|
+
*/
|
|
52
|
+
export function readManifestPackage(xml) {
|
|
53
|
+
if (xml.length < CHUNK_HEADER_SIZE || xml.readUInt16LE(0) !== RES_XML_TYPE) {
|
|
54
|
+
throw new Error('AndroidManifest.xml is not Android binary XML');
|
|
55
|
+
}
|
|
56
|
+
const end = Math.min(xml.length, xml.readUInt32LE(4));
|
|
57
|
+
let offset = xml.readUInt16LE(2);
|
|
58
|
+
let stringAt;
|
|
59
|
+
while (offset + CHUNK_HEADER_SIZE <= end) {
|
|
60
|
+
const type = xml.readUInt16LE(offset);
|
|
61
|
+
const headerSize = xml.readUInt16LE(offset + 2);
|
|
62
|
+
const size = xml.readUInt32LE(offset + 4);
|
|
63
|
+
if (size < CHUNK_HEADER_SIZE) {
|
|
64
|
+
throw new Error(`Malformed binary XML chunk at offset ${offset}`);
|
|
65
|
+
}
|
|
66
|
+
if (type === RES_STRING_POOL_TYPE) {
|
|
67
|
+
stringAt = stringPool(xml, offset);
|
|
68
|
+
}
|
|
69
|
+
else if (type === RES_XML_START_ELEMENT_TYPE) {
|
|
70
|
+
// The first element is the document root.
|
|
71
|
+
if (!stringAt)
|
|
72
|
+
throw new Error('Binary XML has no string pool');
|
|
73
|
+
const ext = offset + headerSize;
|
|
74
|
+
if (stringAt(xml.readUInt32LE(ext + 4)) !== 'manifest') {
|
|
75
|
+
throw new Error('Root element of AndroidManifest.xml is not <manifest>');
|
|
76
|
+
}
|
|
77
|
+
const attributeStart = ext + xml.readUInt16LE(ext + 8);
|
|
78
|
+
const attributeSize = xml.readUInt16LE(ext + 10);
|
|
79
|
+
const attributeCount = xml.readUInt16LE(ext + 12);
|
|
80
|
+
for (let i = 0; i < attributeCount; i++) {
|
|
81
|
+
const attr = attributeStart + i * attributeSize;
|
|
82
|
+
if (stringAt(xml.readUInt32LE(attr + 4)) !== 'package')
|
|
83
|
+
continue;
|
|
84
|
+
// Use the typed string value when there is one, else the raw string
|
|
85
|
+
// the attribute was compiled from.
|
|
86
|
+
const raw = xml.readUInt32LE(attr + 8);
|
|
87
|
+
const index = xml[attr + 15] === TYPE_STRING ? xml.readUInt32LE(attr + 16) : raw;
|
|
88
|
+
if (index === NO_INDEX)
|
|
89
|
+
break;
|
|
90
|
+
return stringAt(index);
|
|
91
|
+
}
|
|
92
|
+
throw new Error('<manifest> has no package attribute');
|
|
93
|
+
}
|
|
94
|
+
offset += size;
|
|
95
|
+
}
|
|
96
|
+
throw new Error('AndroidManifest.xml has no <manifest> element');
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -6,13 +6,12 @@
|
|
|
6
6
|
},
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@clack/prompts": "^1.6.0",
|
|
9
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
9
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
10
10
|
"@supabase/supabase-js": "^2.108.2",
|
|
11
11
|
"bplist-parser": "^0.5.0",
|
|
12
12
|
"chalk": "^6.0.0",
|
|
13
13
|
"citty": "^0.2.2",
|
|
14
14
|
"js-yaml": "^5.2.2",
|
|
15
|
-
"node-apk": "^1.2.1",
|
|
16
15
|
"node-stream-zip": "^1.15.0",
|
|
17
16
|
"plist": "^5.0.0",
|
|
18
17
|
"tar": "^7.5.16",
|
|
@@ -69,7 +68,7 @@
|
|
|
69
68
|
"test:unit": "node scripts/test-runner.mjs --unit",
|
|
70
69
|
"typecheck": "tsc --noEmit -p tsconfig.test.json"
|
|
71
70
|
},
|
|
72
|
-
"version": "5.
|
|
71
|
+
"version": "5.8.0-beta.1",
|
|
73
72
|
"bugs": {
|
|
74
73
|
"url": "https://discord.gg/gm3mJwcNw8"
|
|
75
74
|
},
|
|
@@ -112,12 +111,14 @@
|
|
|
112
111
|
"qs@<6.16.0": "6.16.0",
|
|
113
112
|
"fast-uri@>=3.0.0 <3.1.8": "3.1.8",
|
|
114
113
|
"hono@>=4.0.0 <4.13.7": "4.13.7",
|
|
114
|
+
"source-map-js@<1.2.2": "1.2.2",
|
|
115
115
|
"micromatch>picomatch": "^2.3.2",
|
|
116
116
|
"tinyglobby>picomatch": "^4.0.4"
|
|
117
117
|
},
|
|
118
118
|
"auditConfig": {
|
|
119
119
|
"ignoreGhsas": [
|
|
120
|
-
"GHSA-frvp-7c67-39w9"
|
|
120
|
+
"GHSA-frvp-7c67-39w9",
|
|
121
|
+
"GHSA-vfj7-8cjw-p6xm"
|
|
121
122
|
]
|
|
122
123
|
}
|
|
123
124
|
}
|