Skip to content

Commit fcfc36d

Browse files
committed
feat: add launcher profiling and build-managed test routing
1 parent 7c0a8b9 commit fcfc36d

46 files changed

Lines changed: 773 additions & 279 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/labkit-test-planner/SKILL.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ prefer the app-level GUI folder, for example a `Suites` value such as
3939
`gui/apps/image_measurement/batch_crop` with `IncludeGui=true` and
4040
`GuiMode="hidden"`.
4141

42-
For long focused suites, `runLabKitTests` can split a selected suite with
43-
zero-based `ShardCount` and `ShardIndex`. Use shards only when every shard is
44-
run with a distinct `RunName` and the combined shards cover the same selected
45-
suite; keep the conservative changed-file or relevant broad build task as the
46-
handoff gate for substantive changes.
42+
For broad validation, prefer public buildfile tasks and let the buildfile own
43+
whether a large selected test set should run serially or through internal
44+
worker shards. Use runner-level shard arguments only when developing or
45+
debugging the runner itself; every shard must have a distinct `RunName` and the
46+
combined shards must cover the same selected suite.
4747

4848
For a dirty worktree, route through the changed-file validation planner before
4949
manually choosing tests. The focused planner maps the current diff to the
@@ -62,18 +62,19 @@ or the user explicitly asks for a broader/release gate.
6262

6363
```text
6464
project startup, architecture, package surface, sample-data hygiene
65-
labkit/dta DTA parser, facade, session, item, pulse behavior
66-
labkit/image image file IO, RGB normalization, resizing, mean filtering, basic enhancement primitives
67-
labkit/biosignal biosignal import, processing, ECG peaks, segments, measurements
68-
labkit/ui reusable UI helpers; include GUI coverage for layout/callback/shell/debug/tool checks
65+
labkit_framework/dta DTA parser, facade, session, item, pulse behavior
66+
labkit_framework/image image file IO, RGB normalization, resizing, mean filtering, basic enhancement primitives
67+
labkit_framework/biosignal biosignal import, processing, ECG peaks, segments, measurements
68+
labkit_framework/ui reusable UI helpers; include GUI coverage for layout/callback/shell/debug/tool checks
6969
apps/electrochem electrochem app-owned calculations, exports, layout
7070
apps/dic DIC app layout
7171
apps/image_measurement image measurement calculations, exports, layout
7272
apps/wearable wearable app layout
7373
gui/apps app GUI launch, layout, callback wiring, and workflow checks
7474
gui/apps/<family>/<app_slug>
7575
one app GUI layout, callback wiring, and workflow checks
76-
gui/labkit/launcher launcher discovery and layout checks
76+
gui/project/launcher launcher discovery and layout checks
77+
gui/labkit_framework/ui reusable UI GUI, workflow, and gesture checks
7778
```
7879

7980
Pair reusable changes with downstream apps when the app-facing contract could

.github/workflows/matlab-tests.yml

Lines changed: 34 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,12 @@ concurrency:
1919
cancel-in-progress: true
2020

2121
jobs:
22-
headless-shards:
23-
name: ${{ matrix.label }}
22+
headless:
23+
name: Headless Tests
2424
runs-on: ubuntu-latest
2525
timeout-minutes: 25
2626
env:
2727
MLM_LICENSE_TOKEN: ${{ secrets.MLM_LICENSE_TOKEN }}
28-
strategy:
29-
fail-fast: false
30-
matrix:
31-
include:
32-
- label: Unit Tests - LabKit
33-
run_name: unit-labkit
34-
task: ciUnitLabKit
35-
- label: Unit Tests - Apps
36-
run_name: unit-apps
37-
task: ciUnitApps
38-
- label: Unit Tests - Project
39-
run_name: unit-project
40-
task: ciUnitProject
41-
- label: Integration Tests - Apps
42-
run_name: integration-apps
43-
task: ciIntegrationApps
44-
- label: Integration Tests - Project
45-
run_name: integration-project
46-
task: ciIntegrationProject
4728

4829
steps:
4930
- name: Check out repository
@@ -57,34 +38,49 @@ jobs:
5738
release: ${{ env.MATLAB_RELEASE }}
5839

5940
- name: Prepare artifact directories
60-
run: mkdir -p artifacts/logs/${{ matrix.run_name }}
41+
run: mkdir -p artifacts/logs/headless
6142

62-
- name: Run ${{ matrix.label }}
43+
- name: Run headless tests
6344
uses: matlab-actions/run-build@v3
6445
with:
65-
tasks: ${{ matrix.task }}
66-
startup-options: -logfile artifacts/logs/${{ matrix.run_name }}/matlab.log
46+
tasks: headless
47+
startup-options: -logfile artifacts/logs/headless/matlab.log
6748

68-
- name: Summarize ${{ matrix.label }}
49+
- name: Summarize headless tests
6950
if: always()
70-
run: >
71-
python scripts/summarize_junit.py
72-
artifacts/test-results/${{ matrix.run_name }}/junit.xml
73-
--run-name ${{ matrix.run_name }}
74-
--html artifacts/test-results/${{ matrix.run_name }}/html/index.html
75-
--log artifacts/logs/${{ matrix.run_name }}/matlab.log
76-
77-
- name: Upload ${{ matrix.label }} artifacts
51+
run: |
52+
found=0
53+
for junit in artifacts/test-results/*/junit.xml; do
54+
[ -f "$junit" ] || continue
55+
found=1
56+
run_name="$(basename "$(dirname "$junit")")"
57+
log_path="artifacts/logs/${run_name}/matlab.log"
58+
if [ ! -f "$log_path" ] && [ -f "artifacts/logs/headless-orchestrator/${run_name}.log" ]; then
59+
log_path="artifacts/logs/headless-orchestrator/${run_name}.log"
60+
fi
61+
python scripts/summarize_junit.py "$junit" \
62+
--run-name "$run_name" \
63+
--html "artifacts/test-results/${run_name}/html/index.html" \
64+
--log "$log_path"
65+
done
66+
if [ "$found" -eq 0 ]; then
67+
python scripts/summarize_junit.py artifacts/test-results/headless/junit.xml \
68+
--run-name headless \
69+
--html artifacts/test-results/headless/html/index.html \
70+
--log artifacts/logs/headless/matlab.log
71+
fi
72+
73+
- name: Upload headless artifacts
7874
if: always()
7975
uses: actions/upload-artifact@v4
8076
with:
81-
name: matlab-${{ matrix.run_name }}
77+
name: matlab-headless
8278
if-no-files-found: warn
8379
retention-days: 14
8480
path: |
85-
artifacts/test-results/${{ matrix.run_name }}/**
86-
artifacts/debug/${{ matrix.run_name }}/**
87-
artifacts/logs/${{ matrix.run_name }}/**
81+
artifacts/test-results/**
82+
artifacts/debug/**
83+
artifacts/logs/**
8884
8985
coverage:
9086
name: Coverage Report

buildfile.m

Lines changed: 148 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
function plan = buildfile
22
%BUILDFILE LabKit build and validation entry points.
3+
%
4+
% User-facing commands:
5+
% buildtool changed conservative validation routed from the git diff
6+
% buildtool changedFast faster local iteration routed from the git diff
7+
% buildtool headless full non-GUI validation
8+
% buildtool gui full automated GUI validation with hidden figures
9+
% buildtool coverage coverage report for manual or scheduled runs
10+
% buildtool listTasks print the public task catalog
11+
%
12+
% The buildfile deliberately stays thin. It exposes stable tasks and passes
13+
% suite/tag choices to tests/runLabKitTests.m. Ownership-specific routing
14+
% lives in the runner and changed-file planner, derived from
15+
% tests/cases/<kind>/<owner>/<area>/ paths.
316

417
plan = buildplan(localfunctions);
518
plan.DefaultTasks = "headless";
@@ -30,26 +43,6 @@ function coverageTask(~)
3043
runCatalogTask("coverage");
3144
end
3245

33-
function ciUnitLabKitTask(~)
34-
runCatalogTask("ciUnitLabKit");
35-
end
36-
37-
function ciUnitAppsTask(~)
38-
runCatalogTask("ciUnitApps");
39-
end
40-
41-
function ciUnitProjectTask(~)
42-
runCatalogTask("ciUnitProject");
43-
end
44-
45-
function ciIntegrationAppsTask(~)
46-
runCatalogTask("ciIntegrationApps");
47-
end
48-
49-
function ciIntegrationProjectTask(~)
50-
runCatalogTask("ciIntegrationProject");
51-
end
52-
5346
function listTasksTask(~)
5447
printTaskCatalog(taskCatalog());
5548
end
@@ -61,11 +54,6 @@ function listTasksTask(~)
6154
taskSpec("headless", "Run the full non-GUI validation set.", "IncludeGui", false), ...
6255
taskSpec("gui", "Run noninteractive GUI launch, layout, and gesture checks.", "Suites", "gui", "IncludeGui", true, "GuiMode", "hidden"), ...
6356
taskSpec("coverage", "Run official tests with coverage artifacts.", "Tags", ["Unit", "Integration"], "IncludeCoverage", true), ...
64-
taskSpec("ciUnitLabKit", "Run CI LabKit unit shard.", "Visibility", "ci", "Suites", "labkit", "Tags", "Unit", "IncludeGui", false, "HtmlReport", false), ...
65-
taskSpec("ciUnitApps", "Run CI app unit shard.", "Visibility", "ci", "Suites", "apps", "Tags", "Unit", "IncludeGui", false, "HtmlReport", false), ...
66-
taskSpec("ciUnitProject", "Run CI project unit shard.", "Visibility", "ci", "Suites", "project", "Tags", "Unit", "IncludeGui", false, "HtmlReport", false), ...
67-
taskSpec("ciIntegrationApps", "Run CI app integration shard.", "Visibility", "ci", "Suites", "apps", "Tags", "Integration", "IncludeGui", false, "HtmlReport", false), ...
68-
taskSpec("ciIntegrationProject", "Run CI project integration shard.", "Visibility", "ci", "Suites", "project", "Tags", "Integration", "IncludeGui", false, "HtmlReport", false), ...
6957
taskSpec("listTasks", "List official LabKit build tasks.", "RunTests", false)];
7058
end
7159

@@ -108,6 +96,9 @@ function runCatalogTask(runName)
10896
end
10997

11098
args = taskRunArguments(spec);
99+
if runWithInternalShards(spec, args)
100+
return;
101+
end
111102
runBuildTests(spec.Name, args{:});
112103
end
113104

@@ -154,6 +145,138 @@ function runBuildTests(runName, varargin)
154145
"ArtifactsRoot", fullfile(root, "artifacts"));
155146
end
156147

148+
function handled = runWithInternalShards(spec, args)
149+
handled = false;
150+
if spec.Name ~= "headless" || isInternalShardWorker() || ispc
151+
return;
152+
end
153+
154+
root = fileparts(mfilename("fullpath"));
155+
addpath(fullfile(root, "tests"));
156+
try
157+
probe = runLabKitTests(args{:}, ...
158+
"ListOnly", true, ...
159+
"FailIfNoTests", false, ...
160+
"RunName", spec.Name + "_probe", ...
161+
"ArtifactsRoot", fullfile(root, "artifacts"));
162+
catch
163+
return;
164+
end
165+
166+
shardCount = recommendedShardCount(probe.count);
167+
if shardCount <= 1
168+
return;
169+
end
170+
171+
fprintf("LabKit shard probe: %d test(s) matched; running %d internal headless shard(s).\n", ...
172+
probe.count, shardCount);
173+
runInternalShardWorkers(root, spec.Name, args, shardCount);
174+
handled = true;
175+
end
176+
177+
function tf = isInternalShardWorker()
178+
tf = string(getenv("LABKIT_INTERNAL_SHARD_WORKER")) == "1";
179+
end
180+
181+
function shardCount = recommendedShardCount(testCount)
182+
if testCount >= 300
183+
shardCount = 3;
184+
elseif testCount >= 80
185+
shardCount = 2;
186+
else
187+
shardCount = 1;
188+
end
189+
end
190+
191+
function runInternalShardWorkers(root, runName, args, shardCount)
192+
logsRoot = fullfile(root, "artifacts", "logs", runName + "-orchestrator");
193+
ensureFolder(logsRoot);
194+
scriptPath = fullfile(logsRoot, "run_shards.sh");
195+
fid = fopen(scriptPath, "w");
196+
if fid < 0
197+
error("LabKit:Build:ShardScript", "Could not create shard script.");
198+
end
199+
cleanup = onCleanup(@() fclose(fid));
200+
201+
fprintf(fid, "#!/bin/sh\n");
202+
fprintf(fid, "status=0\n");
203+
matlabExe = fullfile(matlabroot, "bin", "matlab");
204+
for k = 0:(shardCount - 1)
205+
shardName = sprintf("%s-shard-%d", runName, k);
206+
workerLog = fullfile(logsRoot, shardName + ".log");
207+
batch = shardBatchCommand(root, args, shardName, shardCount, k);
208+
fprintf(fid, "LABKIT_INTERNAL_SHARD_WORKER=1 %s -batch %s > %s 2>&1 &\n", ...
209+
shellQuote(matlabExe), shellQuote(batch), shellQuote(workerLog));
210+
fprintf(fid, "pids_%d=$!\n", k + 1);
211+
end
212+
for k = 1:shardCount
213+
fprintf(fid, "wait $pids_%d || status=1\n", k);
214+
end
215+
fprintf(fid, 'if [ "$status" -ne 0 ]; then\n');
216+
fprintf(fid, " cat %s/*.log\n", shellQuote(logsRoot));
217+
fprintf(fid, "fi\n");
218+
fprintf(fid, "exit $status\n");
219+
clear cleanup;
220+
fileattrib(scriptPath, "+x");
221+
222+
[status, output] = system(shellQuote(scriptPath));
223+
if strlength(string(output)) > 0
224+
fprintf("%s", output);
225+
end
226+
if status ~= 0
227+
error("LabKit:Build:ShardFailure", ...
228+
"One or more internal test shards failed. Logs: %s", logsRoot);
229+
end
230+
end
231+
232+
function batch = shardBatchCommand(root, args, runName, shardCount, shardIndex)
233+
shardArgs = [args, { ...
234+
"ShardCount", shardCount, ...
235+
"ShardIndex", shardIndex, ...
236+
"RunName", string(runName), ...
237+
"ArtifactsRoot", fullfile(root, "artifacts")}];
238+
batch = "addpath(" + matlabLiteral(fullfile(root, "tests")) + "); " + ...
239+
"runLabKitTests(" + matlabArgumentList(shardArgs) + ");";
240+
end
241+
242+
function text = matlabArgumentList(args)
243+
parts = strings(1, numel(args));
244+
for k = 1:numel(args)
245+
parts(k) = matlabLiteral(args{k});
246+
end
247+
text = strjoin(parts, ", ");
248+
end
249+
250+
function text = matlabLiteral(value)
251+
if islogical(value)
252+
if value
253+
text = "true";
254+
else
255+
text = "false";
256+
end
257+
elseif isnumeric(value)
258+
text = string(value);
259+
else
260+
value = string(value);
261+
if isscalar(value)
262+
text = """" + replace(value, """", """""") + """";
263+
else
264+
quoted = """" + replace(value, """", """""") + """";
265+
text = "[" + strjoin(quoted, ", ") + "]";
266+
end
267+
end
268+
end
269+
270+
function text = shellQuote(value)
271+
text = "'" + replace(string(value), "'", "'\''") + "'";
272+
end
273+
274+
function ensureFolder(folder)
275+
if exist(folder, "dir") ~= 7
276+
mkdir(folder);
277+
end
278+
end
279+
157280
function printTaskCatalog(catalog)
158281
fprintf("LabKit build tasks:\n");
159282
for k = 1:numel(catalog)

0 commit comments

Comments
 (0)