@ajnart/self-host-testkit 0.0.0-stage → 1.3.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 ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,371 @@
1
- # Temporary Holding Version
1
+ # @ajnart/self-host-testkit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Real App fixtures for integration tests, using self-host's existing headless
4
+ Setup. ESM only, Node.js 22+, no runtime npm dependencies or test-runner plugin.
5
+
6
+ This guide describes **1.3.0**: `withApp`, ordered Pairs, recovery paths, the
7
+ Sonarr witness and the JavaScript Action's automatic post cleanup are not in
8
+ 1.2.1. For 1.2.1, see its
9
+ [immutable guide](https://github.com/ajnart/self-host/blob/5bd44f7f4cd3c350d6cf449e958eae6c0df28a69/testkit/node/README.md).
10
+
11
+ ## Requirements and setup
12
+
13
+ Run tests directly on a Linux/macOS x64/arm64 Docker host with Node.js 22+, local
14
+ Unix socket access and Docker Compose v2. Docker Desktop must be running. Docker
15
+ must enforce memory, CPU and PID caps: up to five containers per fixture, each
16
+ limited to 1 GiB, one CPU and 512 PIDs. Budget up to 5 GiB and five CPUs per fixture,
17
+ plus runner overhead, disk space and network access for images.
18
+
19
+ GitHub-hosted Ubuntu is the simplest headless environment. Self-hosted runners
20
+ need the same limits and permissions. Containerized test runners, GitHub job
21
+ containers, Docker-in-Docker, remote/TCP Docker and native Windows are unsupported.
22
+ A mounted socket is insufficient: App loopback Addresses and bind-mount paths
23
+ belong to the daemon host, not the test container. See the [support matrix](../README.md#headless-and-container-requirements).
24
+
25
+ **Source:** follow the [checkout build](../README.md#try-this-source-checkout),
26
+ which builds generated SDK exports with Bun and the helper with Go, then selects
27
+ that helper using `SELF_HOST_TESTKIT_BINARY`. Run the included demo from this
28
+ checkout so the package self-import resolves to its matching SDK.
29
+
30
+ **Published release:** version 1.3.0 must exist on npm and GitHub before using
31
+ these commands. Install it and explicitly prepare its matching helper:
32
+
33
+ ```bash
34
+ npm install --save-dev @ajnart/self-host-testkit@1.3.0
35
+ npm exec -- self-host-testkit-setup
36
+ ```
37
+
38
+ Released setup requires network access and `tar`, verifies the archive's SHA-256
39
+ checksum and reuses a private versioned helper cache. It needs **no Go** and does
40
+ not start Docker or modify PATH. Neither npm installation nor import downloads
41
+ anything.
42
+
43
+ Helper resolution: `binary` option, `SELF_HOST_TESTKIT_BINARY`, private versioned
44
+ cache, then `self-host-testkit` on PATH. Keep SDK and helper on the same release
45
+ or source commit; matching version strings alone do not prove source identity.
46
+ The normal `self-host` CLI is not the testkit helper.
47
+
48
+ ## Replace a mock, not your adapter
49
+
50
+ Pass the real App's `url` and key to the adapter you already test. For example,
51
+ if your existing test helper is `testSonarrAdapter({ baseUrl, apiKey })`, replace
52
+ its mock configuration with:
53
+
54
+ ```javascript
55
+ import { withApp } from "@ajnart/self-host-testkit";
56
+
57
+ await withApp("sonarr", async (sonarr) => {
58
+ await testSonarrAdapter({
59
+ baseUrl: sonarr.url,
60
+ apiKey: await sonarr.getApiKey(),
61
+ });
62
+ });
63
+ ```
64
+
65
+ `testSonarrAdapter` denotes your own existing test code, not another package to
66
+ install. Keep its real assertions for meaningful data and rejected credentials;
67
+ HTTP 200 or a running container alone is not integration acceptance. Never print
68
+ API keys, credentials or raw response bodies in assertion failures.
69
+
70
+ For a standalone example without an adapter, run `examples/sonarr.mjs` after the
71
+ source build. It checks Sonarr's authenticated identity and prints no secrets.
72
+
73
+ ## Scoped fixtures and timeouts
74
+
75
+ `withApp(id, callback, options?)` returns the awaited callback result and always
76
+ attempts cleanup after startup or callback failure. The callback receives the
77
+ existing `App` API, including `url` and `getApiKey()`; it is runner-independent.
78
+ Options combine `binary`, `timeoutMs`, `manifest`, an optional `image` override
79
+ and the same optional `onCleanupError` as `withPair`.
80
+
81
+ Use your runner's existing async-test API and increase its timeout for image
82
+ pulls, Setup, assertions **and cleanup**. For Node's built-in runner:
83
+
84
+ ```javascript
85
+ import assert from "node:assert/strict";
86
+ import { test } from "node:test";
87
+ import { withApp } from "@ajnart/self-host-testkit";
88
+
89
+ test("Sonarr authenticates", { timeout: 900_000 }, async () => {
90
+ await withApp("sonarr", async (sonarr) => {
91
+ const response = await fetch(`${sonarr.url}/api/v3/system/status`, {
92
+ headers: { "X-Api-Key": await sonarr.getApiKey() },
93
+ signal: AbortSignal.timeout(10_000),
94
+ });
95
+ assert.equal(response.ok, true);
96
+ const status = await response.json();
97
+ assert.equal(status.appName, "Sonarr");
98
+ });
99
+ });
100
+ ```
101
+
102
+ `timeoutMs` defaults to 720,000 (12 minutes), accepts 1–840,000 (14 minutes) and
103
+ bounds the **App lifetime**, not just startup. The helper's hard maximum is 15
104
+ minutes, with time reserved for cleanup. Cold pulls can take minutes; avoid
105
+ sharing one fixture across a suite longer than its lifetime. Bound concurrency
106
+ and allow cleanup time in your test hooks and CI job timeout.
107
+
108
+ When startup or your callback throws, `withApp` preserves that exact thrown value,
109
+ including primitives or `undefined`, even if cleanup also fails. If there is no
110
+ primary failure, cleanup failure is thrown. For recovery after a primary failure,
111
+ use the Action's shared manifest or select your own private manifest in advance.
112
+
113
+ ## Advanced: manage fixture lifetime yourself
114
+
115
+ Use `createSelfHost` when existing hooks need to own several App lifetimes:
116
+
117
+ ```javascript
118
+ import { createSelfHost } from "@ajnart/self-host-testkit";
119
+
120
+ const selfHost = createSelfHost({ timeoutMs: 720_000 });
121
+ try {
122
+ const sonarr = await selfHost.spinup("sonarr");
123
+ const apiKey = await sonarr.getApiKey();
124
+ await testSonarrAdapter({ baseUrl: sonarr.url, apiKey });
125
+ } finally {
126
+ await selfHost.close();
127
+ }
128
+ ```
129
+
130
+ - `createSelfHost({ binary?, timeoutMs?, manifest? })` creates a manager without
131
+ starting Docker. `spinup(id, { image? })` awaits Setup. Every App has its own
132
+ temporary Project and random loopback-only Address.
133
+ - `getApiKey(name?)` explicitly reads a key; the default name is `api_key`.
134
+ - `credentials()` returns private `username`, `password`, `email`, `apiKey` fields.
135
+ JSON/inspection redact the object, but reading a field returns the real secret.
136
+ - `info()` returns public `id`, `url`, `image`, `imageId`, `repoDigests`. Record
137
+ digests for reproducibility; `latest` is mutable. An `image` override changes
138
+ the selected App's image, not dependency images.
139
+ - `app.close()` closes one fixture; `selfHost.close()` closes its fixtures.
140
+ Concurrent calls share one attempt; successful cleanup is idempotent. A failed
141
+ close can be retried, but does not reopen the manager for new starts.
142
+ - Successful cleanup removes owned containers, networks, anonymous container
143
+ volumes and private storage. Named volumes are rejected.
144
+
145
+ The advanced example uses ordinary `finally` semantics: a cleanup error can
146
+ replace an earlier error. Prefer `withApp` when exact primary-error preservation
147
+ matters.
148
+
149
+ ## Ordered pair API
150
+
151
+ Pair calls require the helper matching this SDK; 1.2.1 does not contain them. Existing `spinup`/`withApp` calls are unchanged.
152
+
153
+ ```javascript
154
+ import { withPair } from "@ajnart/self-host-testkit";
155
+
156
+ await withPair(
157
+ "homarr",
158
+ "sonarr",
159
+ async (pair) => {
160
+ const result = await pair.inspect();
161
+ if (result.outcome !== "complete") throw new Error("Pair needs attention");
162
+ const key = await pair.target.getApiKey();
163
+ await testSonarrAdapter({ baseUrl: pair.target.url, apiKey: key });
164
+ },
165
+ { order: "consumer-first" },
166
+ );
167
+ ```
168
+
169
+ `createSelfHost().spinupPair(consumer, target, { order, images? })` returns the
170
+ same Pair for explicit lifetime management. `order` must be `consumer-first` or
171
+ `target-first`; both are sequential production installs into one fresh Project.
172
+ `images` may override only selected main App images, keyed by App ID.
173
+ Pairs need web Apps whose required Inputs have defaults or are host ports, and
174
+ one declared consumer Link. Unsupported Compose/account/hardware/private-input boundaries
175
+ remain explicit errors or Manual outcomes, not automatic skips.
176
+
177
+ - `consumer`/`target` expose `url`, `getApiKey`, `credentials`, `info` and private
178
+ recovery access, but **no close method**. Only Pair/manager close owns cleanup.
179
+ - `initial` records first-install absence. `inspect()` returns selected public
180
+ observations and `complete`, `manual`, `prerequisite` or `failed`. Missing,
181
+ skipped or unready Links are never complete. Completion is the production
182
+ recorded outcome, not a claim of rendered acceptance for every peer.
183
+ Selected Info and inspection results must retain the selected IDs and exact
184
+ loopback Addresses established at startup, including their frozen ports/paths.
185
+ Inspections must also retain the startup-declared Link ID; response steps cannot
186
+ nominate a replacement Link to satisfy completion.
187
+ Step IDs must be unique within each App. Summaries must agree with observed
188
+ failures, Manual Setup and prerequisite facts; contradictory results reject
189
+ through the same awaited whole-owner cleanup boundary.
190
+ - `setup(app?)` retries production Setup for one/both selected Apps;
191
+ `remove(app)` is non-purge and retains data; `install(app)` reinstalls only a
192
+ selected admitted App with its original ports/options. No action renews the
193
+ lifetime. An absent App cannot expose stale Info/credentials.
194
+ - One owner/admission budget covers both Apps plus every backing service:
195
+ five containers, 5 GiB, five CPUs; first-creation 1 GiB/one CPU/512-PID caps.
196
+ Local Access/private binds/volume/socket restrictions remain; no override or
197
+ arbitrary command/secret-write interface is exposed.
198
+ - An infrastructure action/inspection or selected-view key/credential/Info failure
199
+ immediately attempts independent-context whole-Pair cleanup and disables further actions.
200
+ Expected Manual/incomplete Setup remains inspectable. `TestkitError` preserves
201
+ an `OPERATION` reason (such as `LIMITS`), or `TIMEOUT`/`CANCELED`, and an additive
202
+ `cleanupFailed` flag. A failed cleanup retains private recovery ownership and
203
+ can be retried with close/manifest
204
+ recovery; it does not replace the original action cause.
205
+ - Selected-view key/credential/Info reads share the Pair's original work deadline.
206
+ Ordinary missing keys, absent Apps and Manual credentials remain nonfatal;
207
+ cancellation, deadline and storage failures disable the whole owner. Malformed
208
+ action/inspection or selected-view results likewise disable the owner and await
209
+ owned cleanup before rejecting with `PROTOCOL`, retaining any secondary `cleanupFailed` flag.
210
+ Retrying cleanup never re-enables Pair or selected-view actions.
211
+ - Beginning explicit Pair close also permanently disables live actions and reads,
212
+ even if cleanup fails. Close remains retryable within the original lifetime.
213
+ - `withPair` preserves exact primary failures, including `undefined`, over
214
+ cleanup failure. Cleanup-only failures throw. Optional `onCleanupError` receives
215
+ a sanitized cleanup error when there is already a primary failure; failure of
216
+ that notification does not replace the original. Recovery remains manifest-
217
+ scoped and uses the existing Action post hook.
218
+
219
+ `examples/homarr-sonarr.mjs` demonstrates one order with authenticated Sonarr
220
+ identity and production-Setup-driven advancing Stats. Run a second fresh scenario
221
+ with `target-first` to test the other order. It never configures Homarr in Node,
222
+ does not require a downloader or Kuma, and must wait for integrated Sonarr leaf
223
+ registration plus a centrally approved runtime slot. Empty/native/error and
224
+ rendered/lifecycle acceptance remain separate evidence; no runtime is implied by
225
+ building or importing the SDK.
226
+
227
+ ## GitHub Actions
228
+
229
+ **Source workflow:** the local Action builds its helper and uses the generated
230
+ SDK from the same checkout. The demo starts a real Sonarr App; run it only when
231
+ you intend to use Docker.
232
+
233
+ ```yaml
234
+ jobs:
235
+ integration:
236
+ runs-on: ubuntu-latest
237
+ timeout-minutes: 20
238
+ steps:
239
+ - uses: actions/checkout@v7
240
+ - uses: ./testkit
241
+ with:
242
+ source: true
243
+ - run: node "$SELF_HOST_TESTKIT_SDK/examples/sonarr.mjs"
244
+ ```
245
+
246
+ The **JavaScript Action** registers automatic post cleanup, including after a
247
+ test step fails. Keep ordinary fixture cleanup too; post cleanup is a safety net,
248
+ not a guarantee after runner loss. No separate cleanup step is needed for this
249
+ new Action. The published 1.2.1 composite Action does not have this post hook.
250
+
251
+ In default release mode, install the consumer SDK with `npm ci` before the Action;
252
+ it resolves that installed SDK and downloads its exact matching checksummed
253
+ helper, without Go. Pin a reviewed Action commit. For monorepos, set `working-directory` to the
254
+ consumer directory and run installation/tests there. The Action does not install
255
+ npm dependencies or start Apps. Outputs remain `binary-path`, `sdk-path`, `manifest-path`; environment
256
+ variables remain `SELF_HOST_TESTKIT_BINARY`, `SELF_HOST_TESTKIT_SDK`,
257
+ `SELF_HOST_TESTKIT_MANIFEST`.
258
+
259
+ With `source: true`, the Action privately obtains Go and builds its helper,
260
+ using that checkout's committed generated SDK, with no release fallback. Source
261
+ mode also requires Python 3 and `tar`. Tests must import **that** SDK, not an older
262
+ installed npm package; for the included demo, run
263
+ `node "$SELF_HOST_TESTKIT_SDK/examples/sonarr.mjs"`. The consumer's working
264
+ directory does not change which source checkout supplies the helper and SDK.
265
+
266
+ ## Errors and private recovery
267
+
268
+ `TestkitError` has sanitized `code`, optional startup/operation `reason` with safe
269
+ hints, and a `cleanupFailed` flag for secondary cleanup trouble.
270
+ Codes: `BINARY`, `PROTOCOL`, `STATE`, `START`, `CREDENTIALS`, `INFO`, `CLEANUP`,
271
+ `TIMEOUT`, `CANCELED`, `TRANSPORT`, `OPERATION`. Raw provider output and helper stderr
272
+ are not forwarded.
273
+
274
+ - **BINARY:** explicitly prepare a matching helper or set its absolute path.
275
+ - **START:** check Docker/Compose, resource limits and image availability. Apps
276
+ needing Manual steps, external accounts, hardware, host networking, socket
277
+ mounts or named volumes fail explicitly, rather than silently skipping Setup.
278
+ - **TIMEOUT:** budget cold pulls within the lifetime or reduce concurrency.
279
+ - **CLEANUP:** retain private recovery storage and retry once Docker is available.
280
+
281
+ `selfHost.recoveryPath` and `app.recoveryPath` explicitly expose the private
282
+ manifest filename; `TestkitError.recoveryPath` may expose it when available.
283
+ These properties are non-enumerable and must not be logged or uploaded. To choose a
284
+ manifest yourself, provide an absolute filename inside an existing owned
285
+ mode-0700 directory. The helper records ownership before starting containers.
286
+
287
+ Retry `close()` on a retained handle, or after helper exit use the matching helper:
288
+
289
+ ```bash
290
+ "$SELF_HOST_TESTKIT_BINARY" --cleanup-manifest "$SELF_HOST_TESTKIT_MANIFEST"
291
+ ```
292
+
293
+ The Action sets both variables; outside CI, set the manifest variable privately
294
+ from your chosen manifest or recovery getter. Failed cleanup retains the marker
295
+ for retry. Do not upload manifests, Project files or credentials as artifacts;
296
+ do not use Docker prune or broad container/volume deletion for recovery. SIGKILL,
297
+ runner loss and daemon outages can still prevent cleanup; prefer disposable runners.
298
+
299
+ ## Sonarr data-preservation witness
300
+
301
+ This capability is new in 1.3.0. Keep the SDK and helper on the same release or
302
+ reviewed checkout. For a fresh ordered
303
+ `spinupPair("homarr", "sonarr", options)` or `withPair`, explicitly set
304
+ `sonarrFilterWitness: true` and provide `images.sonarr` as a reviewed immutable
305
+ `repository@sha256:<64 lowercase hex digits>` reference. Omission preserves the
306
+ legacy startup sequence and grants no witness permission. The two installation
307
+ orders use the same methods in separate fresh Pairs.
308
+
309
+ Before opted-in Pair startup, the SDK requires protocol-v1 static capability
310
+ `sonarrFilterWitness: 1` from its helper and then the matching startup echo.
311
+ An old or incompatible helper fails safely before `spinupPair`; there is no
312
+ fallback to Node HTTP. Capability support is not image/source approval: review
313
+ the resolved Sonarr release and image separately before any approved native run.
314
+ The current witness contract accepts authenticated stable Sonarr v4 identity,
315
+ requires a complete/running owned Pair and refuses an existing saved-filter
316
+ collection. Default/standalone APIs and resource/lifetime limits are unchanged.
317
+
318
+ An opted-in Pair exposes exactly two zero-argument methods:
319
+
320
+ ```typescript
321
+ const original = await pair.createSonarrFilterWitness();
322
+ const before = await pair.readSonarrFilterWitness();
323
+ if (
324
+ before.kind !== original.kind ||
325
+ before.objectId !== original.objectId ||
326
+ before.contentSha256 !== original.contentSha256
327
+ ) {
328
+ throw new Error("The original Sonarr witness changed");
329
+ }
330
+ ```
331
+
332
+ Each returns an immutable `SonarrFilterWitness` with `kind` equal to
333
+ `"sonarr-custom-filter-v1"`, the original positive `objectId`, and a 64-character
334
+ lowercase `contentSha256`. The Go helper generates the opaque synthetic label;
335
+ Node never supplies or receives its content, resource selector, request body or
336
+ credential. The saved object is an unselected series filter with no predicates,
337
+ not a series, profile, tag, provider or job. Creation uses the official Sonarr v3
338
+ custom-filter API once and confirms the new identity by readback. Tags would not
339
+ be durable because Sonarr housekeeping deletes unused tags.
340
+
341
+ Create once after the production Pair Link is complete. Read before removal and
342
+ after each of two ordinary `pair.remove("sonarr")` / `pair.install("sonarr")`
343
+ cycles; compare all three fields with the original receipt. Do not call Create
344
+ inside a cycle. Repeated creation, read-before-create, ambiguous POST results,
345
+ changed/missing data, unsupported schemas and image drift fail rather than adopt
346
+ or recreate a witness. Owner work deadlines, mutex, credential owner and scoped
347
+ failure/cleanup behavior are reused. A timeout or failed write cannot be retried
348
+ within that Pair. Receipt IDs/hashes may be recorded; never export credentials,
349
+ nonce-bearing bodies, App logs or private fixture files.
350
+
351
+ An invalid witness receipt or startup capability echo disables that helper's
352
+ whole owner and awaits scoped cleanup before rejecting with `PROTOCOL`.
353
+ Secondary cleanup failure is retained as `cleanupFailed: true`; it does not
354
+ replace the protocol failure or expose raw causes. Subsequent actions are
355
+ rejected, while explicit close/manifest recovery may retry failed cleanup.
356
+
357
+ Both cycles must retain data through non-purge target removal. Final Pair/manager
358
+ close still cleans the complete owned disposable fixture; do not add an API
359
+ delete, broad Docker teardown or attach/reseed recovery. A helper lost before
360
+ verification can be recovered for cleanup, not resumed as a successful proof.
361
+ Fake transport tests do not establish native preservation, populated-media
362
+ semantics, rendering, cross-kind survivors or stored-key corruption/recovery.
363
+
364
+ ## Source maintenance
365
+
366
+ `src/*.mts` owns the generated `.mjs` and `.d.mts` files. From the repository root,
367
+ run `bun run --cwd testkit/node build`, then `check` and `lint` with the same
368
+ command prefix. Never mix source SDK output with an unrelated released helper.
369
+ For Go callers, see the [Go API](https://pkg.go.dev/github.com/ajnart/self-host/testkit).
370
+ Live App acceptance and Backup/Restore caching remain deferred; transport tests
371
+ do not establish either.
package/binary.mjs ADDED
@@ -0,0 +1,52 @@
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join } from "node:path";
4
+ export function packageVersion(path) {
5
+ try {
6
+ const metadata = JSON.parse(readFileSync(path, "utf8"));
7
+ if (typeof metadata === "object" &&
8
+ metadata !== null &&
9
+ "name" in metadata &&
10
+ metadata.name === "@ajnart/self-host-testkit" &&
11
+ "version" in metadata &&
12
+ typeof metadata.version === "string" &&
13
+ /^\d+\.\d+\.\d+$/.test(metadata.version)) {
14
+ return metadata.version;
15
+ }
16
+ }
17
+ catch {
18
+ throw new Error("Invalid testkit package metadata.");
19
+ }
20
+ throw new Error("Invalid testkit package metadata.");
21
+ }
22
+ export const version = packageVersion(new URL("./package.json", import.meta.url));
23
+ export function binaryDirectory(exactVersion = version) {
24
+ if (!/^\d+\.\d+\.\d+$/.test(exactVersion)) {
25
+ throw new Error("Invalid testkit helper version.");
26
+ }
27
+ let cache = join(homedir(), ".cache");
28
+ if (process.env.XDG_CACHE_HOME && isAbsolute(process.env.XDG_CACHE_HOME)) {
29
+ cache = process.env.XDG_CACHE_HOME;
30
+ }
31
+ return join(cache, "self-host-testkit", exactVersion, `${process.platform}-${process.arch}`);
32
+ }
33
+ export function privateDirectory(path) {
34
+ const info = lstatSync(path);
35
+ return (info.isDirectory() &&
36
+ info.uid === process.getuid?.() &&
37
+ (info.mode & 0o077) === 0);
38
+ }
39
+ export function installedBinary(directory = binaryDirectory()) {
40
+ const binary = join(directory, "self-host-testkit");
41
+ if (!existsSync(binary))
42
+ return "self-host-testkit";
43
+ const info = lstatSync(binary);
44
+ if (!privateDirectory(directory) ||
45
+ !info.isFile() ||
46
+ info.uid !== process.getuid?.() ||
47
+ (info.mode & 0o077) !== 0 ||
48
+ (info.mode & 0o100) === 0) {
49
+ throw new Error("Testkit helper cache is not private; set SELF_HOST_TESTKIT_BINARY explicitly.");
50
+ }
51
+ return binary;
52
+ }