Skip to content

Commit 878f787

Browse files
QVAC-23125 infra: shard ASR mobile Device Farm runs by model (#3714)
* infra: shard ASR mobile Device Farm runs by model * fix: address ASR mobile sharding review findings
1 parent 7ef29bd commit 878f787

8 files changed

Lines changed: 752 additions & 104 deletions

File tree

.github/scripts/test/ci-trust-policy.test.mjs

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,26 @@ test('tts-ggml Android per-test wait remains below its Mocha ceiling', () => {
13011301
)
13021302
})
13031303

1304+
test('asr-ggml per-test wait remains below its Mocha ceiling', () => {
1305+
const workflow = read('.github/workflows/integration-mobile-test-asr-ggml.yml')
1306+
1307+
function integerValue(key) {
1308+
const match = workflow.match(new RegExp(`^\\s*${key}:\\s*['"]?(\\d+)['"]?\\s*$`, 'm'))
1309+
assert.ok(match, `${key} must be a literal integer`)
1310+
return Number(match[1])
1311+
}
1312+
1313+
const perTestWaitMs =
1314+
integerValue('per-test-timeout-minutes') * MILLISECONDS_PER_MINUTE
1315+
const mochaTimeoutMs = integerValue('mocha-timeout-ms')
1316+
1317+
assert.ok(
1318+
perTestWaitMs < mochaTimeoutMs,
1319+
`ASR per-test wait (${perTestWaitMs} ms) must remain below ` +
1320+
`Mocha timeout (${mochaTimeoutMs} ms)`,
1321+
)
1322+
})
1323+
13041324
test('mobile scheduler preserves automatic sharding and supports explicit multi-spec dual flagship', () => {
13051325
const action = read(
13061326
'.github/actions/run-mobile-integration-tests/schedule-test-run/action.yml',
@@ -1437,3 +1457,70 @@ test('tts-ggml functional mobile workflow opts into dual flagship per shard', ()
14371457
/max-wait-time-seconds:\s*\$\{\{ !inputs\.run_rtf_benchmarks && '9000' \|\| '7200' \}\}/,
14381458
)
14391459
})
1460+
1461+
test('asr-ggml functional mobile workflow opts into dual flagship per engine shard', () => {
1462+
const workflow = read('.github/workflows/integration-mobile-test-asr-ggml.yml')
1463+
const matrices = workflow.match(
1464+
/fromJSON\(inputs\.run_rtf_benchmarks && '([^']+)' \|\| '([^']+)'\)/,
1465+
)
1466+
1467+
assert.ok(matrices, 'benchmark and functional matrices must be literal JSON objects')
1468+
const benchmarkMatrix = JSON.parse(matrices[1])
1469+
const functionalMatrix = JSON.parse(matrices[2])
1470+
assert.equal(benchmarkMatrix.include.length, 16)
1471+
assert.deepEqual(
1472+
functionalMatrix.include.map((entry) => entry.platform),
1473+
['Android', 'iOS'],
1474+
)
1475+
assert.match(
1476+
workflow,
1477+
/concurrency:\s*\n\s+group:[\s\S]*?\n\s+cancel-in-progress:\s*true/,
1478+
)
1479+
assert.match(
1480+
workflow,
1481+
/group:.*inputs\.repository \|\| github\.repository.*inputs\.package_spec \|\| inputs\.prebuild_package \|\| 'artifact'/,
1482+
)
1483+
assert.match(
1484+
workflow,
1485+
/steps:\s*\n\s+- name: Harden runner\s*\n\s+uses: step-security\/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2\.20\.0/,
1486+
)
1487+
assert.match(workflow, /egress-policy:\s*audit/)
1488+
assert.match(
1489+
workflow,
1490+
/name: Manual Workspace Cleanup[\s\S]*?if: runner\.environment != 'github-hosted'[\s\S]*?working-directory: \./,
1491+
)
1492+
assert.match(workflow, /release environment authorizes GitHub OIDC/)
1493+
assert.match(
1494+
workflow,
1495+
/PACKAGE_SPEC:\s*\$\{\{ github\.event\.inputs\.package_spec \}\}[\s\S]*?if \[\[ ! "\$PACKAGE_SPEC"/,
1496+
)
1497+
assert.doesNotMatch(
1498+
workflow,
1499+
/if \[\[ ! "\$\{\{ github\.event\.inputs\.package_spec \}\}"/,
1500+
)
1501+
assert.match(
1502+
workflow,
1503+
/test-groups:\s*\$\{\{ steps\.perf_groups\.outputs\.groups \}\}/,
1504+
)
1505+
assert.match(workflow, /scheduling-mode:\s*dual-flagship/)
1506+
assert.match(
1507+
workflow,
1508+
/multi-spec-dual-flagship:\s*\$\{\{ !inputs\.run_rtf_benchmarks && 'true' \|\| 'false' \}\}/,
1509+
)
1510+
assert.match(
1511+
workflow,
1512+
/package-version:\s*\$\{\{ inputs\.prebuild_package \|\| inputs\.package_spec \}\}/,
1513+
)
1514+
assert.match(
1515+
workflow,
1516+
/force-npm-prebuild:\s*\$\{\{ \(inputs\.prebuild_package != '' \|\| inputs\.package_spec != ''\) && 'true' \|\| 'false' \}\}/,
1517+
)
1518+
assert.match(
1519+
workflow,
1520+
/timeout-minutes:\s*\$\{\{ !inputs\.run_rtf_benchmarks && 210 \|\| 180 \}\}/,
1521+
)
1522+
assert.match(
1523+
workflow,
1524+
/max-wait-time-seconds:\s*\$\{\{ !inputs\.run_rtf_benchmarks && '9000' \|\| '7200' \}\}/,
1525+
)
1526+
})

.github/workflows/integration-mobile-test-asr-ggml.yml

Lines changed: 87 additions & 55 deletions
Large diffs are not rendered by default.

packages/asr-ggml/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"test:dts": "tsc --noEmit -p tsconfig.dts.json",
2323
"test:types": "npm run typecheck && npm run test:dts && npm run check:generated",
2424
"test:unit": "npm run build:ts && brittle-bare test/unit/**/*.test.js && npm run test:prestage",
25-
"test:prestage": "node --test scripts/__tests__/generate-prestage-block.test.js",
25+
"test:prestage": "node --test scripts/__tests__/*.test.js",
2626
"test:integration": "npm run test:integration:whisper && npm run test:integration:parakeet",
2727
"test:integration:whisper": "npm run build:ts && brittle-bare test/integration/addon.test.js",
2828
"test:integration:parakeet:generate": "brittle -r test/integration/all.js test/integration/parakeet-*.test.js",

packages/asr-ggml/scripts/__tests__/generate-prestage-block.test.js

Lines changed: 224 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@ const path = require('node:path')
1717

1818
const {
1919
WHISPER_MODELS,
20+
WHISPER_TEST_MODEL_NAMES,
21+
buildWhisperManifest,
2022
buildWhisperStageBlock,
23+
buildSelectionCode,
2124
buildScript
2225
} = require('../generate-prestage-block')
26+
const { TEST_MODELS } = require('../generate-mobile-model-manifest')
2327

2428
// Wrap a bare whisper stage block in the same setup preamble the real script
2529
// emits, so it is runnable in isolation with adb/curl stubbed out.
2630
function wrapWhisperBlock(block) {
27-
return `set -e\nPRESTAGE_DIR=/data/local/tmp/prestaged-models\nmkdir -p /tmp/prestage\n${block}\n`
31+
return `set -e\nPRESTAGE_DIR=/data/local/tmp/prestaged-models\nHOST_PRESTAGE_DIR=/tmp/prestage\nmkdir -p "$HOST_PRESTAGE_DIR"\n${block}\n`
2832
}
2933

3034
function runWithStubs(script, { adbExit = 0, curlExit = 0 }) {
@@ -44,6 +48,85 @@ function runWithStubs(script, { adbExit = 0, curlExit = 0 }) {
4448
}
4549
}
4650

51+
function writeExecutable(filePath, contents) {
52+
fs.writeFileSync(filePath, contents, { mode: 0o755 })
53+
}
54+
55+
function runCompleteScript({
56+
grep = '',
57+
manifest = {},
58+
curlFailMatch = '',
59+
adbFailMatch = ''
60+
} = {}) {
61+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'asr-prestage-complete-'))
62+
const binDir = path.join(dir, 'bin')
63+
const tmpDir = path.join(dir, 'tmp')
64+
const logDir = path.join(dir, 'logs')
65+
fs.mkdirSync(binDir)
66+
fs.mkdirSync(tmpDir)
67+
fs.mkdirSync(logDir)
68+
fs.writeFileSync(path.join(tmpDir, 'qvacShardGrep.txt'), grep)
69+
writeExecutable(
70+
path.join(binDir, 'curl'),
71+
`#!/usr/bin/env bash
72+
set -euo pipefail
73+
printf '%s\\n' "$*" >> "$STUB_LOG_DIR/curl.log"
74+
if [ -n "\${CURL_FAIL_MATCH:-}" ] && [[ "$*" == *"$CURL_FAIL_MATCH"* ]]; then
75+
exit 22
76+
fi
77+
OUTPUT=""
78+
while [ "$#" -gt 0 ]; do
79+
case "$1" in
80+
-o)
81+
OUTPUT="$2"
82+
shift 2
83+
;;
84+
*)
85+
shift
86+
;;
87+
esac
88+
done
89+
printf 'model-data' > "$OUTPUT"
90+
`
91+
)
92+
writeExecutable(
93+
path.join(binDir, 'adb'),
94+
`#!/usr/bin/env bash
95+
set -euo pipefail
96+
printf '%s\\n' "$*" >> "$STUB_LOG_DIR/adb.log"
97+
if [ "$1" = "push" ] && [ -n "\${ADB_FAIL_MATCH:-}" ] && [[ "$2" == *"$ADB_FAIL_MATCH"* ]]; then
98+
exit 1
99+
fi
100+
exit 0
101+
`
102+
)
103+
104+
const manifestB64 = Buffer.from(JSON.stringify(manifest), 'utf8').toString('base64')
105+
const result = childProcess.spawnSync('bash', ['-c', buildScript(manifestB64)], {
106+
cwd: dir,
107+
env: {
108+
...process.env,
109+
PATH: `${binDir}:${process.env.PATH}`,
110+
QVAC_PRESTAGE_TMP_DIR: tmpDir,
111+
STUB_LOG_DIR: logDir,
112+
CURL_FAIL_MATCH: curlFailMatch,
113+
ADB_FAIL_MATCH: adbFailMatch
114+
},
115+
encoding: 'utf8'
116+
})
117+
const readLog = (name) => {
118+
const logPath = path.join(logDir, name)
119+
return fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''
120+
}
121+
const outcome = {
122+
...result,
123+
curlLog: readLog('curl.log'),
124+
adbLog: readLog('adb.log')
125+
}
126+
fs.rmSync(dir, { recursive: true, force: true })
127+
return outcome
128+
}
129+
47130
test('WHISPER_MODELS covers the full mobile set: functional + perf-sweep quants', () => {
48131
const names = WHISPER_MODELS.map((m) => m.name)
49132
// tiny + VAD (functional) plus the base/small q5_1/q8_0 perf-sweep quants.
@@ -61,6 +144,35 @@ test('WHISPER_MODELS covers the full mobile set: functional + perf-sweep quants'
61144
}
62145
})
63146

147+
test('Whisper manifest selects only the models required by each test runner', () => {
148+
const manifest = buildWhisperManifest()
149+
150+
assert.deepEqual(Object.keys(manifest), Object.keys(WHISPER_TEST_MODEL_NAMES))
151+
assert.deepEqual(
152+
manifest.runMobilePerfTinyCpuTest.map((model) => model.name),
153+
['ggml-tiny.bin']
154+
)
155+
assert.deepEqual(
156+
manifest.runMobilePerfSweepGpuTest.map((model) => model.name),
157+
[
158+
'ggml-base-q5_1.bin',
159+
'ggml-base-q8_0.bin',
160+
'ggml-small-q5_1.bin',
161+
'ggml-small-q8_0.bin'
162+
]
163+
)
164+
assert.deepEqual(manifest.runLiveStreamSimulationTest, [])
165+
})
166+
167+
test('Parakeet manifest makes model-free runners explicit and stages validation models', () => {
168+
assert.deepEqual(TEST_MODELS.runParakeetCorruptedModelTest, [])
169+
assert.deepEqual(TEST_MODELS.runParakeetSortformerStreamingAliasTest, [])
170+
assert.deepEqual(
171+
TEST_MODELS.runParakeetModelFileValidationTest.map((model) => model.name),
172+
['parakeet-tdt-0.6b-v3.q4_0.gguf']
173+
)
174+
})
175+
64176
test('buildWhisperStageBlock stages every model with a .size sidecar and degrades gracefully', () => {
65177
const block = buildWhisperStageBlock([
66178
{ name: 'a.bin', url: 'https://example.com/a.bin' },
@@ -84,22 +196,124 @@ test('buildWhisperStageBlock stages every model with a .size sidecar and degrade
84196
assert.match(failedDownload.stdout, /device will use network fallback/)
85197
})
86198

87-
test('buildScript emits the parakeet manifest block and the whisper block together', () => {
199+
test('buildScript selects Parakeet and Whisper models from the explicit shard grep', () => {
88200
const script = buildScript('QkFTRTY0')
201+
const selectionCode = buildSelectionCode()
89202
// Parakeet (fail-hard, manifest-driven).
203+
assert.ok(script.startsWith('set -euo pipefail\n'))
90204
assert.match(script, /PRESTAGE_DIR=\/data\/local\/tmp\/prestaged-models/)
91-
assert.match(script, /base64 -d > \/tmp\/model-manifest\.json/)
205+
assert.match(script, /base64 -d > "\$TMP_ROOT\/model-manifest\.json"/)
206+
assert.match(script, /base64 -d > "\$TMP_ROOT\/whisper-manifest\.json"/)
207+
assert.match(script, /cat "\$TMP_ROOT\/qvacShardGrep\.txt"/)
208+
assert.doesNotMatch(script, /wdio\.config\.devicefarm\.js/)
209+
assert.match(selectionCode, /missing model mapping for runner/)
210+
assert.match(selectionCode, /invalid .* model mapping for runner/)
211+
assert.match(selectionCode, /seen\[kind\]\.get\(model\.name\)/)
212+
assert.match(script, /parakeet-prestage-list\.tsv/)
213+
assert.match(script, /whisper-prestage-list\.tsv/)
92214
assert.match(script, /adb shell test -s/)
93215
assert.match(script, /FATAL/)
94216
// Whisper (graceful).
95-
assert.match(script, /stage "ggml-tiny\.bin"/)
96-
assert.match(script, /stage "ggml-silero-v5\.1\.2\.bin"/)
97-
assert.match(script, /stage "ggml-base-q5_1\.bin"/)
98-
assert.match(script, /stage "ggml-base-q8_0\.bin"/)
99-
assert.match(script, /stage "ggml-small-q5_1\.bin"/)
100-
assert.match(script, /stage "ggml-small-q8_0\.bin"/)
217+
assert.match(script, /stage "\$NAME" "\$URL"/)
218+
assert.match(script, /device will use network fallback/)
101219
assert.match(script, /\[prestage\] done/)
102220

103-
const syntax = childProcess.spawnSync('sh', ['-n'], { input: script, encoding: 'utf8' })
221+
const syntax = childProcess.spawnSync('bash', ['-n'], { input: script, encoding: 'utf8' })
104222
assert.equal(syntax.status, 0, syntax.stderr)
105223
})
224+
225+
test('complete prestage script deduplicates selected Parakeet models', () => {
226+
const model = { name: 'shared.gguf', url: 'https://example.com/shared.gguf' }
227+
const result = runCompleteScript({
228+
grep: 'runParakeetOneTest|runParakeetTwoTest',
229+
manifest: {
230+
runParakeetOneTest: [model],
231+
runParakeetTwoTest: [model]
232+
}
233+
})
234+
235+
assert.equal(result.status, 0, result.stderr)
236+
assert.equal(result.curlLog.trim().split('\n').length, 1)
237+
assert.match(result.stderr, /1 parakeet \+ 0 whisper model\(s\) for 2 test\(s\)/)
238+
})
239+
240+
test('complete prestage script accepts an explicitly model-free runner', () => {
241+
const result = runCompleteScript({
242+
grep: 'runParakeetModelFreeTest',
243+
manifest: { runParakeetModelFreeTest: [] }
244+
})
245+
246+
assert.equal(result.status, 0, result.stderr)
247+
assert.equal(result.curlLog, '')
248+
assert.match(result.stderr, /0 parakeet \+ 0 whisper model\(s\) for 1 test\(s\)/)
249+
})
250+
251+
test('complete prestage script rejects missing grep and unknown mappings', () => {
252+
const missingGrep = runCompleteScript()
253+
assert.notEqual(missingGrep.status, 0)
254+
assert.match(missingGrep.stdout, /FATAL: shard grep is required/)
255+
256+
const unknownMapping = runCompleteScript({ grep: 'runRenamedParakeetTest' })
257+
assert.notEqual(unknownMapping.status, 0)
258+
assert.match(unknownMapping.stderr, /missing model mapping for runner: runRenamedParakeetTest/)
259+
})
260+
261+
test('complete prestage script rejects malformed manifest entries', () => {
262+
const result = runCompleteScript({
263+
grep: 'runMalformedParakeetTest',
264+
manifest: {
265+
runMalformedParakeetTest: [{ name: 'broken.gguf' }]
266+
}
267+
})
268+
269+
assert.notEqual(result.status, 0)
270+
assert.match(
271+
result.stderr,
272+
/invalid parakeet model mapping for runner runMalformedParakeetTest at index 0/
273+
)
274+
})
275+
276+
test('complete prestage script keeps Parakeet staging fail-hard', () => {
277+
const result = runCompleteScript({
278+
grep: 'runRequiredParakeetTest',
279+
manifest: {
280+
runRequiredParakeetTest: [
281+
{ name: 'required.gguf', url: 'https://example.com/required.gguf' }
282+
]
283+
},
284+
curlFailMatch: 'required.gguf'
285+
})
286+
287+
assert.notEqual(result.status, 0)
288+
assert.match(result.curlLog, /required\.gguf/)
289+
})
290+
291+
for (const fallback of [
292+
{
293+
name: 'download failure',
294+
curlFailMatch: 'ggml-tiny.bin',
295+
expected: /host download failed for ggml-tiny\.bin/
296+
},
297+
{
298+
name: 'model push failure',
299+
adbFailMatch: 'ggml-tiny.bin',
300+
expected: /adb push failed for ggml-tiny\.bin/
301+
},
302+
{
303+
name: 'size sidecar push failure',
304+
adbFailMatch: 'ggml-tiny.bin.size',
305+
expected: /size metadata push failed for ggml-tiny\.bin/
306+
}
307+
]) {
308+
test(`complete prestage script preserves Whisper fallback after ${fallback.name}`, () => {
309+
const result = runCompleteScript({
310+
grep: 'runMobilePerfTinyCpuTest',
311+
curlFailMatch: fallback.curlFailMatch,
312+
adbFailMatch: fallback.adbFailMatch
313+
})
314+
315+
assert.equal(result.status, 0, result.stderr)
316+
assert.match(result.stdout, fallback.expected)
317+
assert.match(result.stdout, /device will use network fallback/)
318+
})
319+
}

0 commit comments

Comments
 (0)