@stim-cli/ci 1.35.0 → 1.37.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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @stim-cli/ci
2
2
 
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.
3
+ Build an app once, then install and launch it through Stim, run your test
4
+ command and stop the workspace. The library imports the public API from `stim`;
5
+ it does not depend on GitHub Actions, EAS, or internal Stim modules.
6
6
 
7
7
  Requires Node.js 22.12 or newer and the native tools for the selected platform.
8
8
  Install the app's dependencies before running it. Each concurrent CI run needs
@@ -10,50 +10,78 @@ its own checkout. The run stops that checkout's workspace, including resources
10
10
  started before a build failure; an explicit API `run.slot` limits cleanup to
11
11
  that slot. Do not point CI at a developer's active checkout.
12
12
 
13
- ## Build only
13
+ ## Build, then run
14
+
15
+ `stim-ci build` is the only step that compiles. `stim-ci run` never compiles:
14
16
 
15
17
  ```sh
16
- npx --yes --package @stim-cli/ci stim-ci build --platform ios --project ./app --artifacts ./build-results
18
+ npx --yes --package @stim-cli/ci stim-ci build --platform ios --project ./app
19
+ npx --yes --package @stim-cli/ci stim-ci run --platform ios --project ./app -- pnpm test:e2e
17
20
  ```
18
21
 
19
- This compiles or restores an artifact without starting a simulator, emulator,
20
- Metro server, or app. It does not stop an existing workspace session. iOS builds
21
- target the simulator; archives, device distribution and web compilation are
22
- not supported. By default, iOS and Android builds use the same native artifact
23
- cache as `run`. A later `run` in the same job validates that cache normally; its
24
- `run.json` reports `cacheKey` and `cacheHit`, which show whether it reused the
25
- build. Android reuse requires the run's emulator system image to have the host's
26
- ABI; a configured `android.systemImage` with another ABI, or a physical device,
27
- misses. An iOS Debug build with `--arch all` is keyed without an architecture,
28
- so a later Debug `run` misses. macOS builds are not cached, so a later macOS
29
- `run` builds again.
30
-
31
- The result has `stage: "build"`, `build`, `buildPath`, and `artifactPath`.
32
- The results directory contains `build.json`, `result.json`, `diagnostics.json`,
33
- and `app.apk` for Android or `app.tar.gz` for iOS/macOS. iOS and macOS also
34
- keep the archive command's output in `artifact.stdout.log` and
35
- `artifact.stderr.log`. The archive preserves app
36
- executable permissions and symlinks when uploaded through an artifact service.
37
- Extract it with `tar -xzf app.tar.gz`. Importing a downloaded artifact into a
38
- Stim run is not part of this command.
39
-
40
- The library exports `buildCI({ projectRoot, build: { platform: "ios" }, ... })`.
41
- It accepts the same home/cache, timeout, cancellation and progress options as
42
- `runCI`. Build cancellation waits for the owned build operation to finish
43
- cleanup and retains diagnostics; it never calls workspace `stop()`. A cancel or timeout after the artifact is exported does not fail the build
44
- stage.
22
+ `build` compiles or restores the app from the native artifact cache, without
23
+ starting a simulator, emulator, Metro server, or app, and without stopping an
24
+ existing workspace session. It records the resulting app in the checkout's Stim
25
+ state (the workspace state under the Stim home) for the platform and selectors:
26
+ iOS scheme and configuration, Android variant. A later `build` with the same
27
+ selectors replaces the record. Architecture and ABI are build-only; the last build
28
+ for the selectors wins whatever its architecture.
29
+
30
+ `run` installs and launches the recorded app exactly as built, then runs the
31
+ test. It does not fingerprint, consult the cache, build or replace the app's JS:
32
+ it trusts the recorded build, so rebuild after changing the project. A Debug app
33
+ loads its JS from Metro, which `run` starts as before. With no recorded build for
34
+ the selectors (or after its app was removed), `run` refuses before touching a
35
+ device with `STIM_NO_RECORDED_BUILD`, and the failure's `remedy` names the command
36
+ to run first, such as `stim-ci build --platform ios --scheme App`. `build` and
37
+ `run` must use the same Stim home: pass the same `--home`, or keep the default.
38
+
39
+ Every iOS and Android `run` on this machine starts the app from clean state on
40
+ the workspace's own simulator or emulator, including when the device already
41
+ has this build.
42
+ Before it installs, `run` uninstalls every other user app except XCUITest
43
+ runner apps, such as the ones agent-device installs. On iOS it also clears the
44
+ app's defaults and data container and resets the simulator keychain; on Android
45
+ it runs `pm clear` on the app. Simulator-wide settings such as privacy grants
46
+ are kept. A local simulator or emulator Stim does not own refuses with
47
+ `STIM_BAD_ARG` instead of skipping the cleanup. A run that `ios.remote` or
48
+ `android.remote` places on another machine or on EAS is not cleaned, and a
49
+ physical device is refused. `build` always compiles on this machine and ignores
50
+ `remote.build`. By default it compiles an iOS app, and an Android Debug app,
51
+ for this machine's architecture only, so when `run` can land on another
52
+ machine or on EAS, build
53
+ for that device too with `--arch all` (iOS) or `--abi all` (Android).
54
+
55
+ `--eas-profile <name>` (iOS and Android, build only) downloads a completed EAS
56
+ development build instead of compiling, as `stim ios --eas-profile` does; `run`
57
+ then installs it. Stim never starts an EAS build.
58
+
59
+ macOS and web are unchanged: `run --platform macos` builds its app itself and web
60
+ has no native build. iOS builds target the simulator; archives, device
61
+ distribution and web compilation are not supported.
62
+
63
+ `build` exports nothing by default. `--export <path>` also writes the app there:
64
+ `app.apk` for Android, a tar.gz for iOS and macOS that preserves executable
65
+ permissions and symlinks (extract with `tar -xzf`). On a cache hit the archive is
66
+ the slowest part of `build`, so export only when something consumes the file.
67
+
68
+ The build result has `stage: "build"`, `build`, `buildPath`, and `artifactPath`
69
+ (the export, or null). With `--artifacts` the directory also contains
70
+ `build.json`, `result.json` and `diagnostics.json`.
71
+
72
+ The library exports `buildCI({ projectRoot, build: { platform: "ios" }, exportPath, ... })`
73
+ and `runCI`. `runCI`'s `run` option takes the selectors of the build to run; build-only
74
+ options such as `easProfile` belong to `buildCI`. Build cancellation waits for the
75
+ owned build operation to finish cleanup and retains diagnostics; it never calls
76
+ workspace `stop()`. A cancel or timeout after the export is written does not fail
77
+ the build stage.
45
78
 
46
79
  ## Command line
47
80
 
48
- Without installing:
49
-
50
- ```sh
51
- npx --yes --package @stim-cli/ci stim-ci run --platform ios --project ./app -- pnpm test:e2e
52
- ```
53
-
54
81
  Or install `npm install --global @stim-cli/ci`, then:
55
82
 
56
83
  ```sh
84
+ stim-ci build --platform android --project ./app
57
85
  stim-ci run --platform android --project ./app --artifacts ./test-results --timeout 1800 -- pnpm test:e2e
58
86
  ```
59
87
 
@@ -62,20 +90,21 @@ to the current directory. `--home` and `--build-cache` explicitly select the
62
90
  Stim home and native artifact cache; leaving them unset preserves normal Stim
63
91
  configuration.
64
92
 
65
- Without selectors, the CLI uses each project's normal build settings. `build`
66
- and `run` accept the same selectors as the public API, so a run can match a
67
- build:
93
+ Without selectors, the CLI uses each project's normal build settings. `run`
94
+ must use the selectors its `build` used to find the recorded app:
68
95
 
69
- | Option | Platform | Stage | Selects |
70
- | ------------------------ | -------- | ---------- | ------------------------------------------------------- |
71
- | `--scheme <name>` | iOS | build, run | Xcode scheme |
72
- | `--configuration <name>` | iOS | build, run | Xcode configuration |
73
- | `--variant <name>` | Android | build, run | Gradle variant |
74
- | `--arch <name>` | iOS | build | Architecture: `arm64`, `x86_64`, `all` |
75
- | `--abi <name>` | Android | build | ABI: `arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`, `all` |
96
+ | Option | Platform | Stage | Selects |
97
+ | ------------------------ | ------------ | ---------- | ------------------------------------------------------- |
98
+ | `--scheme <name>` | iOS | build, run | Xcode scheme |
99
+ | `--configuration <name>` | iOS | build, run | Xcode configuration |
100
+ | `--variant <name>` | Android | build, run | Gradle variant |
101
+ | `--arch <name>` | iOS | build | Architecture: `arm64`, `x86_64`, `all` |
102
+ | `--abi <name>` | Android | build | ABI: `arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`, `all` |
103
+ | `--eas-profile <name>` | iOS, Android | build | Download this EAS profile's development build |
104
+ | `--export <path>` | all native | build | Also write the app (APK or tar.gz) to this path |
76
105
 
77
- The library also accepts the public API's other platform-specific build and run
78
- options.
106
+ The library also accepts the public API's other platform-specific build options, and
107
+ run options other than those that build.
79
108
 
80
109
  Everything after `--` is an argument vector. No shell is inferred. To use shell
81
110
  syntax, pass a shell explicitly: `-- bash -e -o pipefail -c 'pnpm test:e2e'`.
@@ -83,25 +112,28 @@ syntax, pass a shell explicitly: `-- bash -e -o pipefail -c 'pnpm test:e2e'`.
83
112
  The test process runs in the project directory. It receives the caller's
84
113
  environment plus these values:
85
114
 
86
- | Variable | Value |
87
- | ----------------------- | ------------------------------------------------------------ |
88
- | `STIM_CI_PLATFORM` | Selected platform |
89
- | `STIM_CI_DEVICE_ID` | Exact simulator UDID or emulator serial; empty for macOS/web |
90
- | `STIM_CI_APP_ID` | App bundle/package identifier when available |
91
- | `STIM_CI_METRO_PORT` | Metro port when available |
92
- | `STIM_CI_ARTIFACTS_DIR` | Absolute results directory |
93
- | `STIM_CI_RUN_RESULT` | Path to `run.json`, written before the command starts |
115
+ | Variable | Value |
116
+ | ----------------------- | ------------------------------------------------------------------------- |
117
+ | `STIM_CI_PLATFORM` | Selected platform |
118
+ | `STIM_CI_DEVICE_ID` | Exact simulator UDID or emulator serial; empty for macOS/web |
119
+ | `STIM_CI_APP_ID` | App bundle/package identifier when available |
120
+ | `STIM_CI_METRO_PORT` | Metro port when available |
121
+ | `STIM_CI_ARTIFACTS_DIR` | Absolute results directory; empty without `--artifacts` |
122
+ | `STIM_CI_RUN_RESULT` | Path to `run.json` before the command starts; empty without `--artifacts` |
94
123
 
95
124
  `run.json` contains the public Stim result `{ platform, facts }`, including
96
- platform-specific process, target, cache, launch, and log facts. Use the exact
125
+ platform-specific process, target, launch, and log facts; `appPath` is the stable
126
+ path of the installed app. Use the exact
97
127
  reported target; do not select an arbitrary booted device. Tests own
98
128
  app-specific readiness checks. A launch can still be `bundling` or `unverified`.
99
129
 
100
- Progress and test output go to stderr. Stdout contains one JSON result. The same
101
- result is saved as `result.json` beside `run.json`, `test.stdout.log`,
102
- `test.stderr.log`, and `diagnostics.json`. Without `--artifacts`, results use a
103
- fresh temporary directory. An explicit results directory must be empty; a reused
104
- directory is refused before setup so stale evidence cannot describe a new run.
130
+ Progress and test output go to stderr. Stdout contains one JSON result. With
131
+ `--artifacts <dir>` the same result is saved as `result.json` beside `run.json`,
132
+ `test.stdout.log`, `test.stderr.log`, and `diagnostics.json`. Without it no result,
133
+ test or diagnostic file is written, no logs are copied, and the result's
134
+ `artifactsDir`, `resultPath` and `runPath` are null. An explicit results directory
135
+ must be empty; a reused directory is refused before setup so stale evidence cannot
136
+ describe a new run.
105
137
  Keep results outside fingerprinted project inputs.
106
138
 
107
139
  The result includes `version: 1`, `projectRoot`, `platform`, `artifactsDir`,
@@ -188,6 +220,9 @@ For another disposable runner dedicated to one job, explicitly provide a fresh
188
220
  job home and a job-local artifact cache:
189
221
 
190
222
  ```sh
223
+ stim-ci build --platform ios --project ./app \
224
+ --home "$RUNNER_TEMP/stim-home" \
225
+ --build-cache "$RUNNER_TEMP/stim-build-cache"
191
226
  stim-ci run --platform ios --project ./app \
192
227
  --home "$RUNNER_TEMP/stim-home" \
193
228
  --build-cache "$RUNNER_TEMP/stim-build-cache" \
@@ -1,16 +1,19 @@
1
- import { n as buildCI, t as runCI } from "./src-q52NuL5J.mjs";
1
+ import { n as buildCI, t as runCI } from "./src-CFd6F73i.mjs";
2
2
  import { parseArgs } from "node:util";
3
3
  //#region src/cli.ts
4
4
  const HELP = `Usage: stim-ci build --platform <ios|android|macos> [options]
5
5
  stim-ci run --platform <ios|android|macos|web> [options] -- <command> [args...]
6
6
 
7
- Build exports an artifact without starting or stopping an app.
8
- Run builds and launches an app, runs a test command, collects diagnostics, and stops it.
9
- The final result is JSON on stdout; progress and test output go to stderr.
7
+ Build is the only step that compiles. It records the app for this checkout, platform and
8
+ selectors, and exports it only with --export.
9
+ Run installs and launches the app the last matching build recorded, runs a test command, and
10
+ stops the app. It never builds; with no recorded build it refuses and names the build to run.
11
+ The final result is JSON on stdout; progress and test output go to stderr. No result, test or
12
+ diagnostic file is written unless --artifacts is given.
10
13
 
11
14
  Options:
12
15
  --project <path> Project directory (default: current directory)
13
- --artifacts <path> Results directory (default: a fresh temporary directory)
16
+ --artifacts <path> Empty directory for result, test and diagnostic files (default: write none)
14
17
  --home <path> Explicit Stim home (default: job-local on GitHub-hosted runners)
15
18
  --build-cache <path> Native artifact cache directory
16
19
  --timeout <seconds> Setup and test timeout (cleanup has its own deadline)
@@ -22,7 +25,9 @@ Build selectors (build and run):
22
25
  --configuration <name> iOS configuration
23
26
  --variant <name> Android variant
24
27
 
25
- Build-only selectors:
28
+ Build-only options:
29
+ --eas-profile <name> Download this EAS profile's completed development build instead of compiling
30
+ --export <path> Also write the app here: an APK for Android, a tar.gz for iOS and macOS
26
31
  --arch <name> iOS architecture: arm64, x86_64, all
27
32
  --abi <name> Android ABI: arm64-v8a, armeabi-v7a, x86, x86_64, all
28
33
  `;
@@ -43,7 +48,9 @@ function parse(argv) {
43
48
  configuration: { type: "string" },
44
49
  variant: { type: "string" },
45
50
  arch: { type: "string" },
46
- abi: { type: "string" }
51
+ abi: { type: "string" },
52
+ "eas-profile": { type: "string" },
53
+ export: { type: "string" }
47
54
  }
48
55
  });
49
56
  const stage = positionals[0];
@@ -58,11 +65,17 @@ function parse(argv) {
58
65
  buildCache: values["build-cache"],
59
66
  timeoutMs
60
67
  };
61
- if (stage === "run" && (values.arch !== void 0 || values.abi !== void 0)) throw new Error("--arch and --abi only apply to build-only.");
68
+ for (const flag of [
69
+ "arch",
70
+ "abi",
71
+ "eas-profile",
72
+ "export"
73
+ ]) if (stage === "run" && values[flag] !== void 0) throw new Error(`--${flag} only applies to build.`);
74
+ if (values["eas-profile"] !== void 0 && platform !== "ios" && platform !== "android") throw new Error("--eas-profile only applies to iOS and Android.");
62
75
  if (platform !== "ios" && (values.scheme !== void 0 || values.configuration !== void 0 || values.arch !== void 0)) throw new Error("--scheme, --configuration and --arch only apply to iOS.");
63
76
  if (platform !== "android" && (values.variant !== void 0 || values.abi !== void 0)) throw new Error("--variant and --abi only apply to Android.");
64
77
  if (stage === "build") {
65
- if (separator >= 0) throw new Error("Build-only does not take a test command. Use stim-ci run to launch and test.");
78
+ if (separator >= 0) throw new Error("Build does not take a test command. Use stim-ci run to launch and test.");
66
79
  if (platform === "web") throw new Error("Web projects have no native build artifact. Use stim-ci run --platform web.");
67
80
  if (values.arch !== void 0 && ![
68
81
  "arm64",
@@ -80,17 +93,20 @@ function parse(argv) {
80
93
  platform,
81
94
  scheme: values.scheme,
82
95
  configuration: values.configuration,
83
- arch: values.arch
96
+ arch: values.arch,
97
+ easProfile: values["eas-profile"]
84
98
  } : platform === "android" ? {
85
99
  platform,
86
100
  variant: values.variant,
87
- abi: values.abi
101
+ abi: values.abi,
102
+ easProfile: values["eas-profile"]
88
103
  } : { platform };
89
104
  return {
90
105
  stage,
91
106
  options: {
92
107
  ...common,
93
- build
108
+ build,
109
+ exportPath: values.export
94
110
  }
95
111
  };
96
112
  }
@@ -115,7 +131,7 @@ function parse(argv) {
115
131
  }
116
132
  async function main(argv = process.argv.slice(2)) {
117
133
  if (argv.length === 1 && argv[0] === "--version") {
118
- process.stdout.write(`1.35.0\n`);
134
+ process.stdout.write(`1.37.0\n`);
119
135
  return;
120
136
  }
121
137
  if (argv.length === 0 || argv[0] === "--help" || (argv[0] === "run" || argv[0] === "build") && argv[1] === "--help") {
package/dist/index.d.mts CHANGED
@@ -2,6 +2,7 @@ import { StimBuildOptions, StimBuildResult, StimOptions, StimRunOptions, StimRun
2
2
  //#region src/context.d.ts
3
3
  interface CIContextOptions {
4
4
  projectRoot: string;
5
+ /** Writes result, test and diagnostic files here; without it nothing is written. */
5
6
  artifactsDir?: string;
6
7
  home?: string;
7
8
  buildCache?: string;
@@ -15,25 +16,28 @@ interface CommandResult {
15
16
  exitCode: number | null;
16
17
  signal: NodeJS.Signals | null;
17
18
  durationMs: number;
18
- stdout: string;
19
- stderr: string;
19
+ stdout: string | null;
20
+ stderr: string | null;
20
21
  error?: string;
21
22
  }
22
23
  //#endregion
23
24
  //#region src/build.d.ts
24
- type WithoutSignal$1<T> = T extends unknown ? Omit<T, "signal"> : never;
25
+ type WithoutKeys$1<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
25
26
  interface CIBuildOptions extends CIContextOptions {
26
- build: WithoutSignal$1<StimBuildOptions>;
27
+ /** Always compiles on this machine; `remoteBuild` is not accepted. */
28
+ build: WithoutKeys$1<StimBuildOptions, "signal" | "remoteBuild">;
29
+ /** Also writes the app here as a portable APK (Android) or tar.gz (iOS, macOS); without it the build is only recorded for `run`. */
30
+ exportPath?: string;
27
31
  }
28
32
  interface CIBuildResult {
29
33
  version: 1;
30
34
  stage: "build";
31
35
  projectRoot: string;
32
36
  platform: StimBuildOptions["platform"];
33
- artifactsDir: string;
34
- resultPath: string;
35
- buildPath: string;
36
- /** Portable APK or tar.gz bundle preserving executable permissions and symlinks. */
37
+ artifactsDir: string | null;
38
+ resultPath: string | null;
39
+ buildPath: string | null;
40
+ /** The exported APK or tar.gz bundle preserving executable permissions and symlinks; null unless an export was requested. */
37
41
  artifactPath: string | null;
38
42
  startedAt: string;
39
43
  durationMs: number;
@@ -46,13 +50,14 @@ interface CIBuildResult {
46
50
  error?: CIFailure;
47
51
  };
48
52
  }
49
- /** Builds and exports an artifact without starting or stopping a workspace runtime. */
53
+ /** Builds the app and records it for `runCI` without starting or stopping a workspace runtime. */
50
54
  declare function buildCI(input: CIBuildOptions): Promise<CIBuildResult>;
51
55
  //#endregion
52
56
  //#region src/index.d.ts
53
- type WithoutSignal<T> = T extends unknown ? Omit<T, "signal"> : never;
57
+ type WithoutKeys<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
54
58
  interface CIOptions extends CIContextOptions {
55
- run: WithoutSignal<StimRunOptions>;
59
+ /** Selectors of the recorded build to run. `run` never builds, so build-time options such as `easProfile` belong to `buildCI`. */
60
+ run: WithoutKeys<StimRunOptions, "signal" | "recordedBuild" | "cleanAppState" | "easProfile">;
56
61
  command: readonly [string, ...string[]];
57
62
  }
58
63
  interface CIFailure {
@@ -64,9 +69,9 @@ interface CIResult {
64
69
  version: 1;
65
70
  projectRoot: string;
66
71
  platform: StimRunOptions["platform"];
67
- artifactsDir: string;
68
- resultPath: string;
69
- runPath: string;
72
+ artifactsDir: string | null;
73
+ resultPath: string | null;
74
+ runPath: string | null;
70
75
  startedAt: string;
71
76
  durationMs: number;
72
77
  exitCode: number;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { n as buildCI, t as runCI } from "./src-q52NuL5J.mjs";
1
+ import { n as buildCI, t as runCI } from "./src-CFd6F73i.mjs";
2
2
  export { buildCI, runCI };
@@ -1,6 +1,5 @@
1
1
  import { copyDiagnosticLogs } from "./artifacts.mjs";
2
- import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync } from "node:fs";
3
- import { tmpdir } from "node:os";
2
+ import { closeSync, copyFileSync, mkdirSync, openSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, writeSync } from "node:fs";
4
3
  import { basename, dirname, join, resolve } from "node:path";
5
4
  import { createStim } from "stim";
6
5
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
@@ -30,9 +29,11 @@ function prepareCI(options) {
30
29
  };
31
30
  }
32
31
  const projectRoot = realpathSync(options.projectRoot);
33
- const artifactsDir = options.artifactsDir ? resolve(options.artifactsDir) : mkdtempSync(join(tmpdir(), "stim-ci-"));
34
- mkdirSync(artifactsDir, { recursive: true });
35
- if (readdirSync(artifactsDir).length > 0) throw new Error(`Artifacts directory must be empty: ${artifactsDir}. Choose a new directory for this run.`);
32
+ const artifactsDir = options.artifactsDir ? resolve(options.artifactsDir) : null;
33
+ if (artifactsDir) {
34
+ mkdirSync(artifactsDir, { recursive: true });
35
+ if (readdirSync(artifactsDir).length > 0) throw new Error(`Artifacts directory must be empty: ${artifactsDir}. Choose a new directory for this run.`);
36
+ }
36
37
  const timeout = options.timeoutMs === void 0 ? void 0 : AbortSignal.timeout(options.timeoutMs);
37
38
  const signals = [options.signal, timeout].filter((value) => value !== void 0);
38
39
  const signal = signals.length ? AbortSignal.any(signals) : void 0;
@@ -49,12 +50,12 @@ function prepareCI(options) {
49
50
  async function runCommand({ command, cwd, env, artifactsDir, logName = "test", signal, onOutput }) {
50
51
  signal?.throwIfAborted();
51
52
  const started = Date.now();
52
- const stdout = join(artifactsDir, `${logName}.stdout.log`);
53
- const stderr = join(artifactsDir, `${logName}.stderr.log`);
54
- const out = openSync(stdout, "w");
53
+ const stdout = artifactsDir ? join(artifactsDir, `${logName}.stdout.log`) : null;
54
+ const stderr = artifactsDir ? join(artifactsDir, `${logName}.stderr.log`) : null;
55
+ const out = stdout ? openSync(stdout, "w") : void 0;
55
56
  let err;
56
57
  try {
57
- err = openSync(stderr, "w");
58
+ if (stderr) err = openSync(stderr, "w");
58
59
  const child = spawn(command[0], command.slice(1), {
59
60
  cwd,
60
61
  env,
@@ -93,7 +94,7 @@ async function runCommand({ command, cwd, env, artifactsDir, logName = "test", s
93
94
  };
94
95
  const output = (stream, fd, chunk) => {
95
96
  try {
96
- writeSync(fd, chunk);
97
+ if (fd !== void 0) writeSync(fd, chunk);
97
98
  onOutput?.({
98
99
  stream,
99
100
  message: chunk.toString("utf8")
@@ -153,13 +154,13 @@ async function runCommand({ command, cwd, env, artifactsDir, logName = "test", s
153
154
  clearTimeout(killTimer);
154
155
  }
155
156
  } finally {
156
- closeSync(out);
157
+ if (out !== void 0) closeSync(out);
157
158
  if (err !== void 0) closeSync(err);
158
159
  }
159
160
  }
160
161
  //#endregion
161
162
  //#region src/build.ts
162
- /** Builds and exports an artifact without starting or stopping a workspace runtime. */
163
+ /** Builds the app and records it for `runCI` without starting or stopping a workspace runtime. */
163
164
  async function buildCI(input) {
164
165
  const { options, projectRoot, artifactsDir, timeout, signal } = prepareCI(input);
165
166
  const started = Date.now();
@@ -169,8 +170,8 @@ async function buildCI(input) {
169
170
  projectRoot,
170
171
  platform: options.build.platform,
171
172
  artifactsDir,
172
- resultPath: join(artifactsDir, "result.json"),
173
- buildPath: join(artifactsDir, "build.json"),
173
+ resultPath: artifactsDir ? join(artifactsDir, "result.json") : null,
174
+ buildPath: artifactsDir ? join(artifactsDir, "build.json") : null,
174
175
  artifactPath: null,
175
176
  startedAt: new Date(started).toISOString(),
176
177
  durationMs: 0,
@@ -200,16 +201,16 @@ async function buildCI(input) {
200
201
  attempted = true;
201
202
  result.build = await stim.build({
202
203
  ...options.build,
204
+ remoteBuild: "local",
203
205
  signal
204
206
  });
205
207
  signal?.throwIfAborted();
206
- writeJson(result.buildPath, result.build);
207
- if (result.build.platform === "android") {
208
- exporting = join(artifactsDir, "app.apk");
209
- copyFileSync(result.build.facts.apkPath, exporting);
210
- } else {
208
+ if (result.buildPath) writeJson(result.buildPath, result.build);
209
+ if (input.exportPath) exporting = resolve(input.exportPath);
210
+ if (exporting) mkdirSync(dirname(exporting), { recursive: true });
211
+ if (exporting && result.build.platform === "android") copyFileSync(result.build.facts.apkPath, exporting);
212
+ else if (exporting && result.build.platform !== "android") {
211
213
  const bundle = result.build.platform === "ios" ? result.build.facts.appPath : result.build.facts.bundle;
212
- exporting = join(artifactsDir, "app.tar.gz");
213
214
  const packed = await runCommand({
214
215
  command: [
215
216
  "tar",
@@ -233,7 +234,7 @@ async function buildCI(input) {
233
234
  if (packed.exitCode !== 0 || packed.error) throw Object.assign(new Error(packed.error ?? `Artifact export exited with ${packed.exitCode}.`), { code: "STIM_CI_ARTIFACT_FAILED" });
234
235
  }
235
236
  signal?.throwIfAborted();
236
- result.artifactPath = exporting;
237
+ result.artifactPath = exporting ?? null;
237
238
  result.exitCode = 0;
238
239
  } catch (error) {
239
240
  result.failure = failure(error, result.build ? "STIM_CI_ARTIFACT_FAILED" : "STIM_CI_BUILD_FAILED");
@@ -253,7 +254,7 @@ async function buildCI(input) {
253
254
  } catch (error) {
254
255
  result.reportingError ??= failure(error, "STIM_CI_REPORT_FAILED");
255
256
  }
256
- if (attempted) try {
257
+ if (attempted && artifactsDir) try {
257
258
  const diagnostics = await stim.diagnostics({
258
259
  tail: 1e3,
259
260
  signal: AbortSignal.timeout(1e4)
@@ -268,7 +269,7 @@ async function buildCI(input) {
268
269
  result.failure = result.reportingError;
269
270
  }
270
271
  result.durationMs = Date.now() - started;
271
- try {
272
+ if (result.resultPath) try {
272
273
  writeJson(result.resultPath, result);
273
274
  } catch (error) {
274
275
  result.reportingError = failure(error, "STIM_CI_REPORT_FAILED");
@@ -292,8 +293,8 @@ function commandEnvironment(result, options) {
292
293
  STIM_CI_DEVICE_ID: String("udid" in facts ? facts.udid : "serial" in facts ? facts.serial ?? "" : ""),
293
294
  STIM_CI_APP_ID: String("bundleId" in facts ? facts.bundleId ?? "" : ""),
294
295
  STIM_CI_METRO_PORT: String("metroPort" in facts ? facts.metroPort ?? "" : ""),
295
- STIM_CI_ARTIFACTS_DIR: result.artifactsDir,
296
- STIM_CI_RUN_RESULT: result.runPath
296
+ STIM_CI_ARTIFACTS_DIR: result.artifactsDir ?? "",
297
+ STIM_CI_RUN_RESULT: result.runPath ?? ""
297
298
  };
298
299
  }
299
300
  /** Runs an app and argv-based test command, stops its workspace or explicit slot, and retains diagnostics. */
@@ -309,8 +310,8 @@ async function runCI(options) {
309
310
  projectRoot,
310
311
  platform: options.run.platform,
311
312
  artifactsDir,
312
- resultPath: join(artifactsDir, "result.json"),
313
- runPath: join(artifactsDir, "run.json"),
313
+ resultPath: artifactsDir ? join(artifactsDir, "result.json") : null,
314
+ runPath: artifactsDir ? join(artifactsDir, "run.json") : null,
314
315
  startedAt: new Date(started).toISOString(),
315
316
  durationMs: 0,
316
317
  exitCode: 1,
@@ -340,10 +341,12 @@ async function runCI(options) {
340
341
  attempted = true;
341
342
  result.run = await stim.run({
342
343
  ...options.run,
344
+ recordedBuild: true,
345
+ cleanAppState: true,
343
346
  signal
344
347
  });
345
348
  signal?.throwIfAborted();
346
- writeJson(result.runPath, result.run);
349
+ if (result.runPath) writeJson(result.runPath, result.run);
347
350
  result.test = await runCommand({
348
351
  command: options.command,
349
352
  cwd: projectRoot,
@@ -360,6 +363,7 @@ async function runCI(options) {
360
363
  };
361
364
  } catch (error) {
362
365
  result.failure = failure(error, result.run ? "STIM_CI_TEST_FAILED" : "STIM_CI_SETUP_FAILED");
366
+ if (result.failure.code === "STIM_NO_RECORDED_BUILD") result.failure.remedy = `Run ${buildCommand(options, projectRoot)} first.`;
363
367
  } finally {
364
368
  cleaningUp = true;
365
369
  if (signal?.aborted) {
@@ -383,7 +387,7 @@ async function runCI(options) {
383
387
  } catch (error) {
384
388
  result.cleanup.error = failure(error, "STIM_CI_CLEANUP_FAILED");
385
389
  }
386
- try {
390
+ if (artifactsDir) try {
387
391
  const diagnostics = await stim.diagnostics({
388
392
  tail: 1e3,
389
393
  signal: AbortSignal.timeout(1e4)
@@ -412,7 +416,7 @@ async function runCI(options) {
412
416
  result.failure = result.reportingError;
413
417
  }
414
418
  result.durationMs = Date.now() - started;
415
- try {
419
+ if (result.resultPath) try {
416
420
  writeJson(result.resultPath, result);
417
421
  } catch (error) {
418
422
  result.reportingError = failure(error, "STIM_CI_REPORT_FAILED");
@@ -424,5 +428,16 @@ async function runCI(options) {
424
428
  }
425
429
  return result;
426
430
  }
431
+ function buildCommand({ run, home, buildCache }, projectRoot) {
432
+ const flags = [`--platform ${run.platform}`];
433
+ if (projectRoot !== realpathSync(process.cwd())) flags.push(`--project ${JSON.stringify(projectRoot)}`);
434
+ if (home) flags.push(`--home ${JSON.stringify(resolve(home))}`);
435
+ if (buildCache) flags.push(`--build-cache ${JSON.stringify(resolve(buildCache))}`);
436
+ if (run.platform === "ios") {
437
+ if (run.scheme !== void 0) flags.push(`--scheme ${JSON.stringify(run.scheme)}`);
438
+ if (run.configuration !== void 0) flags.push(`--configuration ${JSON.stringify(run.configuration)}`);
439
+ } else if (run.platform === "android" && run.variant !== void 0) flags.push(`--variant ${JSON.stringify(run.variant)}`);
440
+ return `stim-ci build ${flags.join(" ")}`;
441
+ }
427
442
  //#endregion
428
443
  export { buildCI as n, runCI as t };
package/dist/stim-ci.mjs CHANGED
@@ -6,6 +6,6 @@ const minor = parts[1] ?? 0;
6
6
  if (major < 22 || major === 22 && minor < 12) {
7
7
  process.stderr.write(`stim-ci requires Node.js 22.12.0 or later; this is ${process.versions.node}.\n`);
8
8
  process.exitCode = 1;
9
- } else import("./cli-BujO5iDm.mjs").then(({ main }) => main());
9
+ } else import("./cli-BCFk6LoD.mjs").then(({ main }) => main());
10
10
  //#endregion
11
11
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stim-cli/ci",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "description": "Build Stim app artifacts or run Stim apps and test commands in CI with diagnostics and scoped cleanup.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "cross-spawn": "^7.0.6",
36
- "stim": "1.35.0"
36
+ "stim": "1.37.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/cross-spawn": "^6.0.6"