@stim-cli/ci 0.0.0 → 1.19.0
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/LICENSE +21 -0
- package/README.md +179 -1
- package/dist/artifacts.d.mts +7 -0
- package/dist/artifacts.mjs +48 -0
- package/dist/cli-RvPAkat7.mjs +80 -0
- package/dist/index.d.mts +57 -0
- package/dist/index.mjs +2 -0
- package/dist/src-BdobdbxB.mjs +291 -0
- package/dist/stim-ci.d.mts +1 -0
- package/dist/stim-ci.mjs +11 -0
- package/package.json +44 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Janic Duplessis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,181 @@
|
|
|
1
1
|
# @stim-cli/ci
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Build and launch an app through Stim, run your test command, retain diagnostics,
|
|
4
|
+
and stop the workspace. The library imports the public API from `stim`; it does
|
|
5
|
+
not depend on GitHub Actions, EAS, or internal Stim modules.
|
|
6
|
+
|
|
7
|
+
Requires Node.js 22.12 or newer and the native tools for the selected platform.
|
|
8
|
+
Install the app's dependencies before running it. Each concurrent CI run needs
|
|
9
|
+
its own checkout. The run stops that checkout's workspace, including resources
|
|
10
|
+
started before a build failure; an explicit API `run.slot` limits cleanup to
|
|
11
|
+
that slot. Do not point CI at a developer's active checkout.
|
|
12
|
+
|
|
13
|
+
## Command line
|
|
14
|
+
|
|
15
|
+
Without installing:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx --yes --package @stim-cli/ci stim-ci run --platform ios --project ./app -- pnpm test:e2e
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or install `npm install --global @stim-cli/ci`, then:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
stim-ci run --platform android --project ./app --artifacts ./test-results --timeout 1800 -- pnpm test:e2e
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`--platform` accepts `ios`, `android`, `macos`, or `web`. `--project` defaults
|
|
28
|
+
to the current directory. `--home` and `--build-cache` explicitly select the
|
|
29
|
+
Stim home and native artifact cache; leaving them unset preserves normal Stim
|
|
30
|
+
configuration. The CLI uses each project's normal build settings. The library
|
|
31
|
+
also accepts the public API's platform-specific run options.
|
|
32
|
+
|
|
33
|
+
Everything after `--` is an argument vector. No shell is inferred. To use shell
|
|
34
|
+
syntax, pass a shell explicitly: `-- bash -e -o pipefail -c 'pnpm test:e2e'`.
|
|
35
|
+
|
|
36
|
+
The test process runs in the project directory. It receives the caller's
|
|
37
|
+
environment plus these values:
|
|
38
|
+
|
|
39
|
+
| Variable | Value |
|
|
40
|
+
| ----------------------- | ------------------------------------------------------------ |
|
|
41
|
+
| `STIM_CI_PLATFORM` | Selected platform |
|
|
42
|
+
| `STIM_CI_DEVICE_ID` | Exact simulator UDID or emulator serial; empty for macOS/web |
|
|
43
|
+
| `STIM_CI_APP_ID` | App bundle/package identifier when available |
|
|
44
|
+
| `STIM_CI_METRO_PORT` | Metro port when available |
|
|
45
|
+
| `STIM_CI_ARTIFACTS_DIR` | Absolute results directory |
|
|
46
|
+
| `STIM_CI_RUN_RESULT` | Path to `run.json`, written before the command starts |
|
|
47
|
+
|
|
48
|
+
`run.json` contains the public Stim result `{ platform, facts }`, including
|
|
49
|
+
platform-specific process, target, cache, launch, and log facts. Use the exact
|
|
50
|
+
reported target; do not select an arbitrary booted device. Tests own
|
|
51
|
+
app-specific readiness checks. A launch can still be `bundling` or `unverified`.
|
|
52
|
+
|
|
53
|
+
Progress and test output go to stderr. Stdout contains one JSON result. The same
|
|
54
|
+
result is saved as `result.json` beside `run.json`, `test.stdout.log`,
|
|
55
|
+
`test.stderr.log`, and `diagnostics.json`. Without `--artifacts`, results use a
|
|
56
|
+
fresh temporary directory. An explicit results directory must be empty; a reused
|
|
57
|
+
directory is refused before setup so stale evidence cannot describe a new run.
|
|
58
|
+
Keep results outside fingerprinted project inputs.
|
|
59
|
+
|
|
60
|
+
The result includes `version: 1`, `projectRoot`, `platform`, `artifactsDir`,
|
|
61
|
+
`resultPath`, `runPath`, `startedAt`, `durationMs`, `exitCode`, the native `run`,
|
|
62
|
+
the `test` result, optional `failure`, `diagnostics`, and `cleanup`.
|
|
63
|
+
If a progress callback or writing `result.json` fails, the result includes
|
|
64
|
+
`reportingError`. Reporter failures cannot interrupt cleanup or replace an earlier
|
|
65
|
+
test failure; after a passing test, they return 1.
|
|
66
|
+
Setup failure leaves `run` and `test` null. Diagnostics include the last 1000
|
|
67
|
+
structured records and their original directory. After cleanup, regular `.log`
|
|
68
|
+
and `.ndjson` files (including `.1` rotated copies) directly inside that directory
|
|
69
|
+
are copied into `logs/` and listed in `diagnostics.files`. This retains raw
|
|
70
|
+
compiler and process output even when setup fails. These persisted files can
|
|
71
|
+
include earlier records from the same workspace. Collection does not follow
|
|
72
|
+
symlinks, nested directories, or paths referenced by log records. It does not
|
|
73
|
+
copy build binaries or caches. Collection failures appear in `diagnostics.error`
|
|
74
|
+
without replacing the setup or test exit code.
|
|
75
|
+
|
|
76
|
+
Providers can import `diagnosticArtifactFiles` from `@stim-cli/ci/artifacts` to
|
|
77
|
+
list the regular result, run, test and diagnostic files for upload. The helper
|
|
78
|
+
only selects those known files and the copied logs; custom test outputs need
|
|
79
|
+
the provider's own explicit upload configuration.
|
|
80
|
+
|
|
81
|
+
## Library
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { runCI } from '@stim-cli/ci';
|
|
85
|
+
|
|
86
|
+
const result = await runCI({
|
|
87
|
+
projectRoot: '/checkout/app',
|
|
88
|
+
run: { platform: 'ios', configuration: 'Debug' },
|
|
89
|
+
command: ['pnpm', 'test:e2e'],
|
|
90
|
+
artifactsDir: '/job/results',
|
|
91
|
+
timeoutMs: 30 * 60 * 1000,
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
onProgress: ({ message }) => process.stderr.write(message),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
process.exitCode = result.exitCode;
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The library does not change `process.cwd()`, `process.env`, process signal
|
|
100
|
+
handlers, or the caller's exit code. Invalid arguments throw before native
|
|
101
|
+
setup. Setup, test, diagnostics, and cleanup outcomes are returned in the
|
|
102
|
+
result. The CLI alone handles SIGINT/SIGTERM and sets its exit code.
|
|
103
|
+
|
|
104
|
+
## Failure and cancellation
|
|
105
|
+
|
|
106
|
+
A failing command preserves its exit code even when diagnostics or cleanup
|
|
107
|
+
also fail. A passing command followed by incomplete cleanup returns 1. The
|
|
108
|
+
result records both failures when relevant. Diagnostics failures are recorded
|
|
109
|
+
without replacing the setup or test result.
|
|
110
|
+
|
|
111
|
+
`timeoutMs` / `--timeout` covers setup and the test command; there is no timeout
|
|
112
|
+
by default. Timeout returns 124. Cancellation returns 130. Cancellation during
|
|
113
|
+
cleanup is also recorded, preserving an already completed test failure. Cancellation stops
|
|
114
|
+
the test process tree and aborts the native operation, then uses fresh signals
|
|
115
|
+
to stop the workspace (up to 60 seconds) and collect its persisted diagnostics
|
|
116
|
+
(up to 10 seconds). On macOS/Linux, the test command gets its own process group; children
|
|
117
|
+
that deliberately detach from it are outside that group. On Windows, tree
|
|
118
|
+
termination uses `taskkill`; commands must not leave background children after
|
|
119
|
+
their parent exits. Keep test resources inside the command's process tree.
|
|
120
|
+
|
|
121
|
+
Cleanup cannot run after SIGKILL, runner loss, or a provider's hard timeout.
|
|
122
|
+
Leave room between the package timeout and the job timeout. The result reports
|
|
123
|
+
incomplete cleanup; use normal Stim recovery for a persistent runner. `stop`
|
|
124
|
+
shuts down owned devices; it does not delete them.
|
|
125
|
+
|
|
126
|
+
## Disposable and shared runners
|
|
127
|
+
|
|
128
|
+
GitHub-hosted jobs need no home or cache configuration. When `GITHUB_ACTIONS=true`,
|
|
129
|
+
`RUNNER_ENVIRONMENT=github-hosted` and `RUNNER_TEMP` is set, the package defaults to
|
|
130
|
+
`$RUNNER_TEMP/stim-ci/home` and `$RUNNER_TEMP/stim-ci/build-cache`. Repeated steps
|
|
131
|
+
in the same job reuse these paths. The public API and test command receive the
|
|
132
|
+
same paths without changing the caller's environment.
|
|
133
|
+
|
|
134
|
+
An explicit `home` / `--home` or `STIM_HOME` retains the caller's home and normal
|
|
135
|
+
cache configuration. An explicit `buildCache` / `--build-cache` or
|
|
136
|
+
`STIM_BUILD_CACHE` overrides the automatic cache path. Self-hosted runners,
|
|
137
|
+
other providers and local runs keep normal Stim configuration and configured
|
|
138
|
+
build/device caps. `CI=true` alone changes no defaults or coordination guarantees.
|
|
139
|
+
|
|
140
|
+
For another disposable runner dedicated to one job, explicitly provide a fresh
|
|
141
|
+
job home and a job-local artifact cache:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
stim-ci run --platform ios --project ./app \
|
|
145
|
+
--home "$RUNNER_TEMP/stim-home" \
|
|
146
|
+
--build-cache "$RUNNER_TEMP/stim-build-cache" \
|
|
147
|
+
--artifacts "$RUNNER_TEMP/stim-results" \
|
|
148
|
+
--timeout 1800 -- pnpm test:e2e
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Restore and save only the artifact cache through the provider. Never restore
|
|
152
|
+
workspace state, process records, claims, device ledgers, or pairings. Use cache
|
|
153
|
+
names that separate OS, architecture and platform; Stim still validates its
|
|
154
|
+
native fingerprint/configuration keys. Keep provider fallback keys within
|
|
155
|
+
compatible toolchains. Save complete entries, not staging directories, after
|
|
156
|
+
the runner finishes. Cache service failure should fall back to a build.
|
|
157
|
+
|
|
158
|
+
Do not point independent homes at a shared writable filesystem cache. Build
|
|
159
|
+
single-flight claims are scoped to the Stim home. Job-local restored copies
|
|
160
|
+
have no cross-job writers; persistent shared runners should use their normal
|
|
161
|
+
shared Stim home and coordination.
|
|
162
|
+
|
|
163
|
+
Current code already skips capacity admission when `concurrency.maxBuilds` and
|
|
164
|
+
`concurrency.maxDevices` are 0, their defaults. An explicit `STIM_HOME` defaults
|
|
165
|
+
device parking to 0. These are existing fast paths, not a new unsafe mode.
|
|
166
|
+
Ownership ledgers, atomic writes, process identity, cache publication, and
|
|
167
|
+
workspace claims still run. No timing study has established that this
|
|
168
|
+
bookkeeping is a material CI cost.
|
|
169
|
+
|
|
170
|
+
One concrete setup waste in Stim's existing Android fixture workflow is booting
|
|
171
|
+
an emulator through another action and immediately killing it before Stim
|
|
172
|
+
boots its own. SDK-only setup can avoid that extra boot without adopting an
|
|
173
|
+
external device. External simulator/emulator adoption is not part of this API.
|
|
174
|
+
|
|
175
|
+
Before adding coordination shortcuts, compare cold and warm runs by phase:
|
|
176
|
+
dependency setup, fingerprint/cache lookup, native build, device boot, install,
|
|
177
|
+
Metro, test, diagnostics, cleanup, and capacity waits. Validate a fresh-home
|
|
178
|
+
warm-cache run actually skips compilation, a deliberate test failure retains
|
|
179
|
+
its logs/exit code, cancellation leaves no live owned processes, and a shared
|
|
180
|
+
runner's unrelated workspace remains running. Native timings and cross-provider
|
|
181
|
+
acceptance require real tool runs; unit tests are not that evidence.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
//#region src/artifacts.d.ts
|
|
2
|
+
/** Copies regular, non-symlink log files directly inside the public diagnostics directory. */
|
|
3
|
+
declare function copyDiagnosticLogs(directory: string, artifactsDir: string): Promise<string[]>;
|
|
4
|
+
/** Lists CI result, test and diagnostic evidence from an explicit root, excluding linked entries and app binaries. */
|
|
5
|
+
declare function diagnosticArtifactFiles(artifactsDir: string): Promise<string[]>;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { copyDiagnosticLogs, diagnosticArtifactFiles };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { copyFile, lstat, mkdir, readdir, realpath } from "node:fs/promises";
|
|
4
|
+
//#region src/artifacts.ts
|
|
5
|
+
async function regularFiles(directory, resolveDirectory = false) {
|
|
6
|
+
try {
|
|
7
|
+
if (resolveDirectory) directory = await realpath(directory);
|
|
8
|
+
if (!(await lstat(directory)).isDirectory()) return [];
|
|
9
|
+
return (await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isFile()).map((entry) => entry.name).toSorted();
|
|
10
|
+
} catch (error) {
|
|
11
|
+
if (error.code === "ENOENT") return [];
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function isLog(name) {
|
|
16
|
+
return /\.(?:log|ndjson)(?:\.1)?$/.test(name);
|
|
17
|
+
}
|
|
18
|
+
/** Copies regular, non-symlink log files directly inside the public diagnostics directory. */
|
|
19
|
+
async function copyDiagnosticLogs(directory, artifactsDir) {
|
|
20
|
+
const names = (await regularFiles(directory)).filter(isLog);
|
|
21
|
+
const destination = join(artifactsDir, "logs");
|
|
22
|
+
if (names.length) {
|
|
23
|
+
await mkdir(destination, { recursive: true });
|
|
24
|
+
if (!(await lstat(destination)).isDirectory()) throw new Error("Diagnostic logs destination must be a regular directory.");
|
|
25
|
+
}
|
|
26
|
+
const files = [];
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
const target = join(destination, name);
|
|
29
|
+
await copyFile(join(directory, name), target, constants.COPYFILE_EXCL);
|
|
30
|
+
files.push(target);
|
|
31
|
+
}
|
|
32
|
+
return files;
|
|
33
|
+
}
|
|
34
|
+
/** Lists CI result, test and diagnostic evidence from an explicit root, excluding linked entries and app binaries. */
|
|
35
|
+
async function diagnosticArtifactFiles(artifactsDir) {
|
|
36
|
+
const reports = /* @__PURE__ */ new Set([
|
|
37
|
+
"result.json",
|
|
38
|
+
"run.json",
|
|
39
|
+
"diagnostics.json",
|
|
40
|
+
"test.stdout.log",
|
|
41
|
+
"test.stderr.log"
|
|
42
|
+
]);
|
|
43
|
+
const files = (await regularFiles(artifactsDir, true)).filter((name) => reports.has(name)).map((name) => join(artifactsDir, name));
|
|
44
|
+
const logs = join(artifactsDir, "logs");
|
|
45
|
+
return [...files, ...(await regularFiles(logs)).filter(isLog).map((name) => join(logs, name))];
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
export { copyDiagnosticLogs, diagnosticArtifactFiles };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { t as runCI } from "./src-BdobdbxB.mjs";
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
//#region src/cli.ts
|
|
4
|
+
const HELP = `Usage: stim-ci run --platform <ios|android|macos|web> [options] -- <command> [args...]
|
|
5
|
+
|
|
6
|
+
Build and launch an app, run a test command, collect diagnostics, and stop it.
|
|
7
|
+
The final result is JSON on stdout; progress and test output go to stderr.
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--project <path> Project directory (default: current directory)
|
|
11
|
+
--artifacts <path> Results directory (default: a fresh temporary directory)
|
|
12
|
+
--home <path> Explicit Stim home (default: job-local on GitHub-hosted runners)
|
|
13
|
+
--build-cache <path> Native artifact cache directory
|
|
14
|
+
--timeout <seconds> Setup and test timeout (cleanup has its own deadline)
|
|
15
|
+
--help Show this help
|
|
16
|
+
--version Show the package version
|
|
17
|
+
`;
|
|
18
|
+
function parse(argv) {
|
|
19
|
+
const separator = argv.indexOf("--");
|
|
20
|
+
if (separator < 0) throw new Error("Separate the test command with --.");
|
|
21
|
+
const command = argv.slice(separator + 1);
|
|
22
|
+
if (!command[0]) throw new Error("A test command is required after --.");
|
|
23
|
+
const { values, positionals } = parseArgs({
|
|
24
|
+
args: argv.slice(0, separator),
|
|
25
|
+
allowPositionals: true,
|
|
26
|
+
options: {
|
|
27
|
+
platform: { type: "string" },
|
|
28
|
+
project: { type: "string" },
|
|
29
|
+
artifacts: { type: "string" },
|
|
30
|
+
home: { type: "string" },
|
|
31
|
+
"build-cache": { type: "string" },
|
|
32
|
+
timeout: { type: "string" }
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
if (positionals.length !== 1 || positionals[0] !== "run") throw new Error("Expected stim-ci run.");
|
|
36
|
+
const platform = values.platform;
|
|
37
|
+
if (platform !== "ios" && platform !== "android" && platform !== "macos" && platform !== "web") throw new Error("--platform must be ios, android, macos or web.");
|
|
38
|
+
const timeoutMs = values.timeout === void 0 ? void 0 : Number(values.timeout) * 1e3;
|
|
39
|
+
return {
|
|
40
|
+
projectRoot: values.project ?? process.cwd(),
|
|
41
|
+
run: { platform },
|
|
42
|
+
command,
|
|
43
|
+
artifactsDir: values.artifacts,
|
|
44
|
+
home: values.home,
|
|
45
|
+
buildCache: values["build-cache"],
|
|
46
|
+
timeoutMs
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
async function main(argv = process.argv.slice(2)) {
|
|
50
|
+
if (argv.length === 1 && argv[0] === "--version") {
|
|
51
|
+
process.stdout.write(`1.19.0\n`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (argv.length === 0 || argv[0] === "--help" || argv[0] === "run" && argv[1] === "--help") {
|
|
55
|
+
process.stdout.write(HELP);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const interrupt = () => controller.abort();
|
|
60
|
+
try {
|
|
61
|
+
const options = parse(argv);
|
|
62
|
+
process.on("SIGINT", interrupt);
|
|
63
|
+
process.on("SIGTERM", interrupt);
|
|
64
|
+
const result = await runCI({
|
|
65
|
+
...options,
|
|
66
|
+
signal: controller.signal,
|
|
67
|
+
onProgress: ({ message }) => process.stderr.write(message)
|
|
68
|
+
});
|
|
69
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
70
|
+
process.exitCode = result.exitCode;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
process.stderr.write(`stim-ci: ${error.message}\n`);
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
} finally {
|
|
75
|
+
process.off("SIGINT", interrupt);
|
|
76
|
+
process.off("SIGTERM", interrupt);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
export { main };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { StimOptions, StimRunOptions, StimRunResult, StimStopResult } from "stim";
|
|
2
|
+
//#region src/command.d.ts
|
|
3
|
+
interface CommandResult {
|
|
4
|
+
exitCode: number | null;
|
|
5
|
+
signal: NodeJS.Signals | null;
|
|
6
|
+
durationMs: number;
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/index.d.ts
|
|
13
|
+
type WithoutSignal<T> = T extends unknown ? Omit<T, "signal"> : never;
|
|
14
|
+
interface CIOptions {
|
|
15
|
+
projectRoot: string;
|
|
16
|
+
run: WithoutSignal<StimRunOptions>;
|
|
17
|
+
command: readonly [string, ...string[]];
|
|
18
|
+
artifactsDir?: string;
|
|
19
|
+
home?: string;
|
|
20
|
+
buildCache?: string;
|
|
21
|
+
timeoutMs?: number;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
onProgress?: StimOptions["onProgress"];
|
|
24
|
+
}
|
|
25
|
+
interface CIFailure {
|
|
26
|
+
code: string;
|
|
27
|
+
message: string;
|
|
28
|
+
remedy?: string;
|
|
29
|
+
}
|
|
30
|
+
interface CIResult {
|
|
31
|
+
version: 1;
|
|
32
|
+
projectRoot: string;
|
|
33
|
+
platform: StimRunOptions["platform"];
|
|
34
|
+
artifactsDir: string;
|
|
35
|
+
resultPath: string;
|
|
36
|
+
runPath: string;
|
|
37
|
+
startedAt: string;
|
|
38
|
+
durationMs: number;
|
|
39
|
+
exitCode: number;
|
|
40
|
+
run: StimRunResult | null;
|
|
41
|
+
test: CommandResult | null;
|
|
42
|
+
failure?: CIFailure;
|
|
43
|
+
reportingError?: CIFailure;
|
|
44
|
+
diagnostics: {
|
|
45
|
+
path: string | null;
|
|
46
|
+
files?: string[];
|
|
47
|
+
error?: CIFailure;
|
|
48
|
+
};
|
|
49
|
+
cleanup: {
|
|
50
|
+
result: StimStopResult | null;
|
|
51
|
+
error?: CIFailure;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Runs an app and argv-based test command, stops its workspace or explicit slot, and retains diagnostics. */
|
|
55
|
+
declare function runCI(options: CIOptions): Promise<CIResult>;
|
|
56
|
+
//#endregion
|
|
57
|
+
export { CIFailure, CIOptions, CIResult, type CommandResult, runCI };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { copyDiagnosticLogs } from "./artifacts.mjs";
|
|
2
|
+
import { closeSync, mkdirSync, mkdtempSync, openSync, readdirSync, realpathSync, renameSync, writeFileSync, writeSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { createStim } from "stim";
|
|
6
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
7
|
+
import spawn from "cross-spawn";
|
|
8
|
+
//#region src/command.ts
|
|
9
|
+
async function runCommand({ command, cwd, env, artifactsDir, signal, onOutput }) {
|
|
10
|
+
signal?.throwIfAborted();
|
|
11
|
+
const started = Date.now();
|
|
12
|
+
const stdout = join(artifactsDir, "test.stdout.log");
|
|
13
|
+
const stderr = join(artifactsDir, "test.stderr.log");
|
|
14
|
+
const out = openSync(stdout, "w");
|
|
15
|
+
let err;
|
|
16
|
+
try {
|
|
17
|
+
err = openSync(stderr, "w");
|
|
18
|
+
const child = spawn(command[0], command.slice(1), {
|
|
19
|
+
cwd,
|
|
20
|
+
env,
|
|
21
|
+
detached: process.platform !== "win32",
|
|
22
|
+
stdio: [
|
|
23
|
+
"ignore",
|
|
24
|
+
"pipe",
|
|
25
|
+
"pipe"
|
|
26
|
+
],
|
|
27
|
+
windowsHide: true
|
|
28
|
+
});
|
|
29
|
+
let error;
|
|
30
|
+
let groupError;
|
|
31
|
+
let killTimer;
|
|
32
|
+
const kill = (force) => {
|
|
33
|
+
if (!child.pid) return;
|
|
34
|
+
if (process.platform === "win32") spawn.sync("taskkill", [
|
|
35
|
+
"/pid",
|
|
36
|
+
String(child.pid),
|
|
37
|
+
"/t",
|
|
38
|
+
...force ? ["/f"] : []
|
|
39
|
+
], {
|
|
40
|
+
stdio: "ignore",
|
|
41
|
+
windowsHide: true
|
|
42
|
+
});
|
|
43
|
+
else try {
|
|
44
|
+
process.kill(-child.pid, force ? "SIGKILL" : "SIGTERM");
|
|
45
|
+
} catch (caught) {
|
|
46
|
+
if (caught.code !== "ESRCH") groupError ??= String(caught);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const terminate = () => {
|
|
50
|
+
if (killTimer) return;
|
|
51
|
+
kill(false);
|
|
52
|
+
killTimer = setTimeout(() => kill(true), 1e3);
|
|
53
|
+
};
|
|
54
|
+
const output = (stream, fd, chunk) => {
|
|
55
|
+
try {
|
|
56
|
+
writeSync(fd, chunk);
|
|
57
|
+
onOutput?.({
|
|
58
|
+
stream,
|
|
59
|
+
message: chunk.toString("utf8")
|
|
60
|
+
});
|
|
61
|
+
} catch (caught) {
|
|
62
|
+
error ??= String(caught);
|
|
63
|
+
terminate();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
child.stdout.on("data", (chunk) => output("stdout", out, chunk));
|
|
67
|
+
child.stderr.on("data", (chunk) => output("stderr", err, chunk));
|
|
68
|
+
child.on("error", (caught) => {
|
|
69
|
+
error ??= caught.message;
|
|
70
|
+
});
|
|
71
|
+
child.once("exit", terminate);
|
|
72
|
+
signal?.addEventListener("abort", terminate, { once: true });
|
|
73
|
+
if (signal?.aborted) terminate();
|
|
74
|
+
try {
|
|
75
|
+
const status = await new Promise((resolve) => {
|
|
76
|
+
child.once("close", (exitCode, exitSignal) => {
|
|
77
|
+
resolve({
|
|
78
|
+
exitCode,
|
|
79
|
+
signal: exitSignal
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
if (process.platform !== "win32" && child.pid) {
|
|
84
|
+
const deadline = Date.now() + 2e3;
|
|
85
|
+
while (true) {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(-child.pid, 0);
|
|
88
|
+
} catch (caught) {
|
|
89
|
+
const code = caught.code;
|
|
90
|
+
if (code === "ESRCH") break;
|
|
91
|
+
if (code !== "EPERM") {
|
|
92
|
+
error ??= String(caught);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
groupError ??= String(caught);
|
|
96
|
+
}
|
|
97
|
+
if (Date.now() >= deadline) {
|
|
98
|
+
error ??= groupError ?? `Test process group ${child.pid} did not exit after termination`;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
await setTimeout$1(20);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
...status,
|
|
106
|
+
durationMs: Date.now() - started,
|
|
107
|
+
stdout,
|
|
108
|
+
stderr,
|
|
109
|
+
...error ? { error } : {}
|
|
110
|
+
};
|
|
111
|
+
} finally {
|
|
112
|
+
signal?.removeEventListener("abort", terminate);
|
|
113
|
+
clearTimeout(killTimer);
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
closeSync(out);
|
|
117
|
+
if (err !== void 0) closeSync(err);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/index.ts
|
|
122
|
+
function failure(error, code) {
|
|
123
|
+
const caught = error;
|
|
124
|
+
return {
|
|
125
|
+
code: typeof caught?.code === "string" ? caught.code : code,
|
|
126
|
+
message: typeof caught?.message === "string" ? caught.message : String(error),
|
|
127
|
+
...typeof caught?.remedy === "string" ? { remedy: caught.remedy } : {}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function writeJson(path, value) {
|
|
131
|
+
const pending = `${path}.tmp`;
|
|
132
|
+
writeFileSync(pending, `${JSON.stringify(value, null, 2)}\n`);
|
|
133
|
+
renameSync(pending, path);
|
|
134
|
+
}
|
|
135
|
+
function commandEnvironment(result, options) {
|
|
136
|
+
const facts = result.run.facts;
|
|
137
|
+
return {
|
|
138
|
+
...process.env,
|
|
139
|
+
...options.home ? { STIM_HOME: resolve(options.home) } : {},
|
|
140
|
+
...options.buildCache ? { STIM_BUILD_CACHE: resolve(options.buildCache) } : {},
|
|
141
|
+
STIM_CI_PLATFORM: result.platform,
|
|
142
|
+
STIM_CI_DEVICE_ID: String("udid" in facts ? facts.udid : "serial" in facts ? facts.serial ?? "" : ""),
|
|
143
|
+
STIM_CI_APP_ID: String("bundleId" in facts ? facts.bundleId ?? "" : ""),
|
|
144
|
+
STIM_CI_METRO_PORT: String("metroPort" in facts ? facts.metroPort ?? "" : ""),
|
|
145
|
+
STIM_CI_ARTIFACTS_DIR: result.artifactsDir,
|
|
146
|
+
STIM_CI_RUN_RESULT: result.runPath
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** Runs an app and argv-based test command, stops its workspace or explicit slot, and retains diagnostics. */
|
|
150
|
+
async function runCI(options) {
|
|
151
|
+
if (!options.command[0]) throw new Error("A non-empty test command is required.");
|
|
152
|
+
for (const argument of options.command) if (typeof argument !== "string" || argument.includes("\0")) throw new Error("Test command arguments must be strings without null bytes.");
|
|
153
|
+
if (options.timeoutMs !== void 0 && (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw new Error("timeoutMs must be a positive integer.");
|
|
154
|
+
if (process.env.GITHUB_ACTIONS === "true" && process.env.RUNNER_ENVIRONMENT === "github-hosted" && process.env.RUNNER_TEMP && !options.home && !process.env.STIM_HOME) {
|
|
155
|
+
const directory = join(resolve(process.env.RUNNER_TEMP), "stim-ci");
|
|
156
|
+
options = {
|
|
157
|
+
...options,
|
|
158
|
+
home: join(directory, "home"),
|
|
159
|
+
buildCache: options.buildCache || (process.env.STIM_BUILD_CACHE ? void 0 : join(directory, "build-cache"))
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const projectRoot = realpathSync(options.projectRoot);
|
|
163
|
+
const artifactsDir = options.artifactsDir ? resolve(options.artifactsDir) : mkdtempSync(join(tmpdir(), "stim-ci-"));
|
|
164
|
+
mkdirSync(artifactsDir, { recursive: true });
|
|
165
|
+
if (readdirSync(artifactsDir).length > 0) throw new Error(`Artifacts directory must be empty: ${artifactsDir}. Choose a new directory for this run.`);
|
|
166
|
+
const timeout = options.timeoutMs === void 0 ? void 0 : AbortSignal.timeout(options.timeoutMs);
|
|
167
|
+
const signals = [options.signal, timeout].filter((value) => value !== void 0);
|
|
168
|
+
const signal = signals.length ? AbortSignal.any(signals) : void 0;
|
|
169
|
+
const started = Date.now();
|
|
170
|
+
const result = {
|
|
171
|
+
version: 1,
|
|
172
|
+
projectRoot,
|
|
173
|
+
platform: options.run.platform,
|
|
174
|
+
artifactsDir,
|
|
175
|
+
resultPath: join(artifactsDir, "result.json"),
|
|
176
|
+
runPath: join(artifactsDir, "run.json"),
|
|
177
|
+
startedAt: new Date(started).toISOString(),
|
|
178
|
+
durationMs: 0,
|
|
179
|
+
exitCode: 1,
|
|
180
|
+
run: null,
|
|
181
|
+
test: null,
|
|
182
|
+
diagnostics: { path: null },
|
|
183
|
+
cleanup: { result: null }
|
|
184
|
+
};
|
|
185
|
+
let cleaningUp = false;
|
|
186
|
+
const onProgress = options.onProgress ? (event) => {
|
|
187
|
+
try {
|
|
188
|
+
options.onProgress(event);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
result.reportingError ??= failure(error, "STIM_CI_REPORT_FAILED");
|
|
191
|
+
if (!cleaningUp) throw error;
|
|
192
|
+
}
|
|
193
|
+
} : void 0;
|
|
194
|
+
const stim = createStim({
|
|
195
|
+
projectRoot,
|
|
196
|
+
home: options.home ? resolve(options.home) : void 0,
|
|
197
|
+
buildCache: options.buildCache ? resolve(options.buildCache) : void 0,
|
|
198
|
+
onProgress
|
|
199
|
+
});
|
|
200
|
+
let attempted = false;
|
|
201
|
+
try {
|
|
202
|
+
signal?.throwIfAborted();
|
|
203
|
+
attempted = true;
|
|
204
|
+
result.run = await stim.run({
|
|
205
|
+
...options.run,
|
|
206
|
+
signal
|
|
207
|
+
});
|
|
208
|
+
signal?.throwIfAborted();
|
|
209
|
+
writeJson(result.runPath, result.run);
|
|
210
|
+
result.test = await runCommand({
|
|
211
|
+
command: options.command,
|
|
212
|
+
cwd: projectRoot,
|
|
213
|
+
env: commandEnvironment(result, options),
|
|
214
|
+
artifactsDir,
|
|
215
|
+
signal,
|
|
216
|
+
onOutput: onProgress
|
|
217
|
+
});
|
|
218
|
+
result.exitCode = result.test.exitCode ?? 1;
|
|
219
|
+
if (result.exitCode < 0 || result.exitCode === 0 && result.test.error) result.exitCode = 1;
|
|
220
|
+
if (result.exitCode !== 0) result.failure = {
|
|
221
|
+
code: "STIM_CI_TEST_FAILED",
|
|
222
|
+
message: result.test.error ?? `Test command ${result.test.signal ? `was terminated by ${result.test.signal}` : `exited with ${result.exitCode}`}.`
|
|
223
|
+
};
|
|
224
|
+
} catch (error) {
|
|
225
|
+
result.failure = failure(error, result.run ? "STIM_CI_TEST_FAILED" : "STIM_CI_SETUP_FAILED");
|
|
226
|
+
} finally {
|
|
227
|
+
cleaningUp = true;
|
|
228
|
+
if (signal?.aborted) {
|
|
229
|
+
const timedOut = timeout?.aborted && !options.signal?.aborted;
|
|
230
|
+
result.exitCode = timedOut ? 124 : 130;
|
|
231
|
+
result.failure = {
|
|
232
|
+
code: timedOut ? "STIM_CI_TIMEOUT" : "STIM_CI_CANCELLED",
|
|
233
|
+
message: timedOut ? `CI run exceeded ${options.timeoutMs}ms.` : "CI run was cancelled."
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (attempted) {
|
|
237
|
+
try {
|
|
238
|
+
result.cleanup.result = await stim.stop({
|
|
239
|
+
..."slot" in options.run ? { slot: options.run.slot } : {},
|
|
240
|
+
signal: AbortSignal.timeout(6e4)
|
|
241
|
+
});
|
|
242
|
+
if (!result.cleanup.result.ok) result.cleanup.error = {
|
|
243
|
+
code: "STIM_CI_CLEANUP_FAILED",
|
|
244
|
+
message: "Stim could not stop the workspace."
|
|
245
|
+
};
|
|
246
|
+
} catch (error) {
|
|
247
|
+
result.cleanup.error = failure(error, "STIM_CI_CLEANUP_FAILED");
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const diagnostics = await stim.diagnostics({
|
|
251
|
+
tail: 1e3,
|
|
252
|
+
signal: AbortSignal.timeout(1e4)
|
|
253
|
+
});
|
|
254
|
+
const path = join(artifactsDir, "diagnostics.json");
|
|
255
|
+
writeJson(path, diagnostics);
|
|
256
|
+
result.diagnostics.path = path;
|
|
257
|
+
result.diagnostics.files = await copyDiagnosticLogs(diagnostics.directory, artifactsDir);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
result.diagnostics.error = failure(error, "STIM_CI_DIAGNOSTICS_FAILED");
|
|
260
|
+
}
|
|
261
|
+
if (options.signal?.aborted && result.exitCode === 0) {
|
|
262
|
+
result.exitCode = 130;
|
|
263
|
+
result.failure = {
|
|
264
|
+
code: "STIM_CI_CANCELLED",
|
|
265
|
+
message: "CI run was cancelled."
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (result.cleanup.error && result.exitCode === 0) {
|
|
269
|
+
result.exitCode = 1;
|
|
270
|
+
result.failure = result.cleanup.error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (result.reportingError && result.exitCode === 0) {
|
|
274
|
+
result.exitCode = 1;
|
|
275
|
+
result.failure = result.reportingError;
|
|
276
|
+
}
|
|
277
|
+
result.durationMs = Date.now() - started;
|
|
278
|
+
try {
|
|
279
|
+
writeJson(result.resultPath, result);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
result.reportingError = failure(error, "STIM_CI_REPORT_FAILED");
|
|
282
|
+
if (result.exitCode === 0) {
|
|
283
|
+
result.exitCode = 1;
|
|
284
|
+
result.failure = result.reportingError;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
//#endregion
|
|
291
|
+
export { runCI as t };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/stim-ci.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//#region bin/node-check.ts
|
|
3
|
+
const parts = process.versions.node.split(".").map(Number);
|
|
4
|
+
const major = parts[0] ?? 0;
|
|
5
|
+
const minor = parts[1] ?? 0;
|
|
6
|
+
if (major < 22 || major === 22 && minor < 12) {
|
|
7
|
+
process.stderr.write(`stim-ci requires Node.js 22.12.0 or later; this is ${process.versions.node}.\n`);
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
} else import("./cli-RvPAkat7.mjs").then(({ main }) => main());
|
|
10
|
+
//#endregion
|
|
11
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stim-cli/ci",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.19.0",
|
|
4
|
+
"description": "Run Stim apps and test commands in CI with diagnostics and scoped cleanup.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"repository": {
|
|
7
|
-
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/appandflow/stim.git",
|
|
9
|
+
"directory": "packages/ci"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"stim-ci": "dist/stim-ci.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "dist/index.mjs",
|
|
21
|
+
"types": "dist/index.d.mts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.mts",
|
|
25
|
+
"module-sync": "./dist/index.mjs",
|
|
26
|
+
"default": "./dist/index.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./artifacts": {
|
|
29
|
+
"types": "./dist/artifacts.d.mts",
|
|
30
|
+
"module-sync": "./dist/artifacts.mjs",
|
|
31
|
+
"default": "./dist/artifacts.mjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"cross-spawn": "^7.0.6",
|
|
36
|
+
"stim": "^1.19.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/cross-spawn": "^6.0.6"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22.12.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsdown"
|
|
46
|
+
}
|
|
47
|
+
}
|