@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 +202 -0
- package/README.md +370 -2
- package/binary.mjs +52 -0
- package/examples/homarr-sonarr.mjs +66 -0
- package/examples/sonarr.mjs +15 -0
- package/index.d.mts +102 -0
- package/index.mjs +837 -0
- package/package.json +56 -5
- package/reasons.d.mts +15 -0
- package/reasons.mjs +14 -0
- package/setup.mjs +109 -0
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
|
-
#
|
|
1
|
+
# @ajnart/self-host-testkit
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|