@norskvideo/ctl-test-harness 0.1.75 → 0.1.77

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.
@@ -6,7 +6,8 @@
6
6
  // -> nodes[].mediaIn). Playout's scripts/ensure-sources.sh:52-64 as TypeScript
7
7
  // (05-demo s7 step 2), pure over an injected fetch so it is unit-tested and
8
8
  // reachable through whichever base the caller has: the daemon proxy's
9
- // `/instance/<id>/visualiser`, or `<id>-media-1:6791/visualiser` on norsk-net.
9
+ // `/instance/<id>/visualiser`, or `<id>-media-1:6791/visualiser` from the
10
+ // instance network `<id>_default` (media is on no other).
10
11
  export const DEFAULT_COMPOSE_NODE = "video_compose";
11
12
  const DEFAULT_SETTLE_MS = 6000;
12
13
  /** The highest mediaIn across every node of that name; 0 when none. */
@@ -118,11 +118,13 @@ export declare function ownContainerId(opts?: {
118
118
  isDockerObject?: (id: string) => boolean;
119
119
  }): string | null;
120
120
  /** `docker network connect` is not idempotent — a second attach errors. Treat
121
- * the "already attached" variants as success so setup can call this blindly. */
121
+ * the "already attached" variants as success so setup can call this blindly.
122
+ * Twin of norsk-ctl backend/src/docker/container-ops.ts isAlreadyOnNetwork. */
122
123
  export declare function isAlreadyOnNetworkError(stderr: string): boolean;
123
124
  type RunResult = {
124
125
  status: number | null;
125
126
  stderr: string;
127
+ stdout?: string;
126
128
  };
127
129
  type Runner = (cmd: string, args: string[]) => RunResult;
128
130
  /** Idempotently join the test container to norsk-net so `direct` service DNS
@@ -139,4 +141,44 @@ export declare function ensureRunnerOnNetwork(opts?: {
139
141
  detect?: Parameters<typeof ownContainerId>[0];
140
142
  run?: Runner;
141
143
  }): "attached" | "already" | "not-in-container" | "failed";
144
+ /** The compose project is the instance id, so its network is `<id>_default`.
145
+ * Mirrors norsk-ctl's instanceNetworkName. */
146
+ export declare function instanceNetworkName(instanceId: string): string;
147
+ /** docker's wording for a disconnect that has nothing to do. Twin of norsk-ctl
148
+ * backend/src/docker/container-ops.ts isNotOnNetwork: change both together. */
149
+ export declare function isNotOnNetworkError(stderr: string): boolean;
150
+ export declare class InstanceNetworks {
151
+ private readonly opts;
152
+ private readonly ids;
153
+ private resolvedId;
154
+ constructor(opts?: {
155
+ /** Explicit id (tests). Omitted -> resolved once by `ownContainerId`. */
156
+ containerId?: string | null;
157
+ run?: Runner;
158
+ });
159
+ private containerId;
160
+ /** Join the runner to the instance's network. Connects on every call rather
161
+ * than trusting the record: an instance deleted and relaunched under the
162
+ * same id has a new network the runner is not on. One `docker network
163
+ * connect` costs the same as the inspect that checking the record would. */
164
+ join(instanceId: string): "attached" | "already" | "not-in-container" | "failed";
165
+ /** Leave a network joined here. Never throws: teardown carries on regardless.
166
+ * `force` leaves even if this process did not record the join (a later
167
+ * `demo down` in a fresh process). */
168
+ leave(instanceId: string, opts?: {
169
+ force?: boolean;
170
+ }): void;
171
+ /** The runner's IP on the instance network, for something media dials back
172
+ * (a capture server). Joins first. Null off-container or on failure. */
173
+ addressOn(instanceId: string): string | null;
174
+ leaveAll(): void;
175
+ joined(): string[];
176
+ }
177
+ /** The process-wide registry the address helpers join through. */
178
+ export declare function currentInstanceNetworks(): InstanceNetworks;
179
+ /** Swap the registry (tests); returns a restore function. */
180
+ export declare function useInstanceNetworks(next: InstanceNetworks): () => void;
181
+ /** Make `<id>-media-1` resolvable in direct mode. Publish mode reaches media by
182
+ * published port and needs nothing. */
183
+ export declare function reachInstanceMedia(instanceId: string, mode?: NetReachMode): void;
142
184
  export {};
package/container-net.js CHANGED
@@ -23,12 +23,14 @@
23
23
  // makes for media HTTP — generalised to the studio-control + SRT-egress ports
24
24
  // that are the actual colliders.
25
25
  //
26
- // THE ONE CONSTRAINT: container-DNS reachability needs the test container joined
27
- // to norsk-net (bridge DNS is per-network). On Docker Desktop / OrbStack the
28
- // bridge is hidden inside a VM and is not routable from the host at all, so the
29
- // direct path can't work there — those runs MUST keep publishing. Hence the mode
30
- // switch: `direct` on the Linux DooD CI box, `publish` (the default) everywhere
31
- // else. Nothing here changes behaviour until a caller opts a suite into `direct`.
26
+ // THE ONE CONSTRAINT: container-DNS reachability needs the test container on
27
+ // the target's network (bridge DNS is per-network): norsk-net for studio, the
28
+ // instance network `<id>_default` for media (see "Instance networks" below).
29
+ // On Docker Desktop / OrbStack the bridge is hidden inside a VM and is not
30
+ // routable from the host at all, so the direct path can't work there — those
31
+ // runs MUST keep publishing. Hence the mode switch: `direct` on the Linux DooD
32
+ // CI box, `publish` (the default) everywhere else. Nothing here changes
33
+ // behaviour until a caller opts a suite into `direct`.
32
34
  import { spawnSync } from "node:child_process";
33
35
  import { existsSync, readFileSync } from "node:fs";
34
36
  import { hostname } from "node:os";
@@ -83,6 +85,7 @@ export function studioApiBase(o) {
83
85
  * `<id>-media-1:8080`. Mirrors studio-state.mediaDirectUrl, generalised for the
84
86
  * direct-pull capture path. */
85
87
  export function mediaHttpBase(o) {
88
+ reachInstanceMedia(o.instanceId, o.mode);
86
89
  const e = resolveEndpoint({
87
90
  instanceId: o.instanceId,
88
91
  service: "media",
@@ -100,6 +103,7 @@ export function mediaHttpBase(o) {
100
103
  * and `publishPort` are the same band value today — going direct is what later
101
104
  * lets that band be dropped entirely. */
102
105
  export function srtEgressUrl(o) {
106
+ reachInstanceMedia(o.instanceId, o.mode);
103
107
  const e = resolveEndpoint({
104
108
  instanceId: o.instanceId,
105
109
  service: "media",
@@ -127,8 +131,8 @@ export function studioBaseFrom(studio, publishHost, mode) {
127
131
  });
128
132
  }
129
133
  // ── Runner network attach ───────────────────────────────────────────────────
130
- // `direct` DNS only resolves if the test container is on norsk-net. These
131
- // helpers attach it idempotently; all pure/injectable so the logic is tested
134
+ // `direct` DNS for studio only resolves if the test container is on norsk-net.
135
+ // These helpers attach it idempotently; all pure/injectable so the logic is tested
132
136
  // without touching docker.
133
137
  /** The 64-hex container id from a /proc/self/cgroup dump, or null on a bare host.
134
138
  * In a container the id also appears as the hostname, but cgroup is present in
@@ -180,13 +184,14 @@ export function ownContainerId(opts = {}) {
180
184
  });
181
185
  }
182
186
  /** `docker network connect` is not idempotent — a second attach errors. Treat
183
- * the "already attached" variants as success so setup can call this blindly. */
187
+ * the "already attached" variants as success so setup can call this blindly.
188
+ * Twin of norsk-ctl backend/src/docker/container-ops.ts isAlreadyOnNetwork. */
184
189
  export function isAlreadyOnNetworkError(stderr) {
185
190
  return /already exists in network|is already connected|endpoint with name .* already exists/i.test(stderr);
186
191
  }
187
192
  const defaultRunner = (cmd, args) => {
188
193
  const r = spawnSync(cmd, args, { encoding: "utf-8" });
189
- return { status: r.status, stderr: r.stderr ?? "" };
194
+ return { status: r.status, stderr: r.stderr ?? "", stdout: r.stdout ?? "" };
190
195
  };
191
196
  /** Idempotently join the test container to norsk-net so `direct` service DNS
192
197
  * resolves. Returns a verdict rather than throwing: `not-in-container` (nothing
@@ -205,3 +210,119 @@ export function ensureRunnerOnNetwork(opts = {}) {
205
210
  return "already";
206
211
  return "failed";
207
212
  }
213
+ // ── Instance networks ───────────────────────────────────────────────────────
214
+ // ctl attaches media to its instance's own compose network only, not to
215
+ // norsk-net: Docker Desktop's port forwarder delivers published UDP to either IP
216
+ // of a dual-homed container, and an SRT listener's reply then leaves by the
217
+ // other interface and is dropped (norsk-ctl docs/product-template-format.md,
218
+ // Networking). So `<id>-media-1` resolves only from `<id>_default`, and direct
219
+ // mode joins the runner to that network whenever it addresses an instance's
220
+ // media.
221
+ //
222
+ // The runner must leave again BEFORE the instance is deleted: a foreign
223
+ // endpoint keeps `compose down` from removing the network, which then leaks.
224
+ // cleanupDaemon does that for every network joined here.
225
+ /** The compose project is the instance id, so its network is `<id>_default`.
226
+ * Mirrors norsk-ctl's instanceNetworkName. */
227
+ export function instanceNetworkName(instanceId) {
228
+ return `${instanceId}_default`;
229
+ }
230
+ /** docker's wording for a disconnect that has nothing to do. Twin of norsk-ctl
231
+ * backend/src/docker/container-ops.ts isNotOnNetwork: change both together. */
232
+ export function isNotOnNetworkError(stderr) {
233
+ return /is not connected to (the )?network|network .* not found|no such network/i.test(stderr);
234
+ }
235
+ export class InstanceNetworks {
236
+ opts;
237
+ ids = new Set();
238
+ resolvedId;
239
+ constructor(opts = {}) {
240
+ this.opts = opts;
241
+ }
242
+ containerId() {
243
+ if (this.opts.containerId !== undefined)
244
+ return this.opts.containerId;
245
+ if (this.resolvedId === undefined)
246
+ this.resolvedId = ownContainerId();
247
+ return this.resolvedId;
248
+ }
249
+ /** Join the runner to the instance's network. Connects on every call rather
250
+ * than trusting the record: an instance deleted and relaunched under the
251
+ * same id has a new network the runner is not on. One `docker network
252
+ * connect` costs the same as the inspect that checking the record would. */
253
+ join(instanceId) {
254
+ const verdict = ensureRunnerOnNetwork({
255
+ network: instanceNetworkName(instanceId),
256
+ containerId: this.containerId(),
257
+ ...(this.opts.run ? { run: this.opts.run } : {}),
258
+ });
259
+ if (verdict === "attached" || verdict === "already")
260
+ this.ids.add(instanceId);
261
+ return verdict;
262
+ }
263
+ /** Leave a network joined here. Never throws: teardown carries on regardless.
264
+ * `force` leaves even if this process did not record the join (a later
265
+ * `demo down` in a fresh process). */
266
+ leave(instanceId, opts = {}) {
267
+ if (!this.ids.delete(instanceId) && !opts.force)
268
+ return;
269
+ const id = this.containerId();
270
+ if (!id)
271
+ return;
272
+ const r = (this.opts.run ?? defaultRunner)("docker", [
273
+ "network",
274
+ "disconnect",
275
+ instanceNetworkName(instanceId),
276
+ id,
277
+ ]);
278
+ if (r.status !== 0 && !isNotOnNetworkError(r.stderr))
279
+ console.error(`leaving ${instanceNetworkName(instanceId)} failed: ${r.stderr.trim()}`);
280
+ }
281
+ /** The runner's IP on the instance network, for something media dials back
282
+ * (a capture server). Joins first. Null off-container or on failure. */
283
+ addressOn(instanceId) {
284
+ const verdict = this.join(instanceId);
285
+ if (verdict === "not-in-container" || verdict === "failed")
286
+ return null;
287
+ const id = this.containerId();
288
+ if (!id)
289
+ return null;
290
+ const net = instanceNetworkName(instanceId);
291
+ const r = (this.opts.run ?? defaultRunner)("docker", [
292
+ "inspect",
293
+ "-f",
294
+ `{{with index .NetworkSettings.Networks "${net}"}}{{.IPAddress}}{{end}}`,
295
+ id,
296
+ ]);
297
+ const ip = (r.stdout ?? "").trim();
298
+ return r.status === 0 && ip ? ip : null;
299
+ }
300
+ leaveAll() {
301
+ for (const id of [...this.ids])
302
+ this.leave(id);
303
+ }
304
+ joined() {
305
+ return [...this.ids];
306
+ }
307
+ }
308
+ let instanceNetworks = new InstanceNetworks();
309
+ /** The process-wide registry the address helpers join through. */
310
+ export function currentInstanceNetworks() {
311
+ return instanceNetworks;
312
+ }
313
+ /** Swap the registry (tests); returns a restore function. */
314
+ export function useInstanceNetworks(next) {
315
+ const prev = instanceNetworks;
316
+ instanceNetworks = next;
317
+ return () => {
318
+ instanceNetworks = prev;
319
+ };
320
+ }
321
+ /** Make `<id>-media-1` resolvable in direct mode. Publish mode reaches media by
322
+ * published port and needs nothing. */
323
+ export function reachInstanceMedia(instanceId, mode = netReachMode()) {
324
+ if (mode !== "direct")
325
+ return;
326
+ if (instanceNetworks.join(instanceId) === "failed")
327
+ console.error(`could not join ${instanceNetworkName(instanceId)}: ${instanceId}-media-1 will not resolve`);
328
+ }
package/daemon.d.ts CHANGED
@@ -140,6 +140,10 @@ export declare function cleanupDaemon(opts: {
140
140
  containers: string[];
141
141
  /** Injected for tests; defaults to the Docker-label reap. */
142
142
  reapInstance?: (id: string) => string[];
143
+ /** Injected for tests; defaults to leaving the instance network the harness
144
+ * joined (container-net InstanceNetworks). It must precede the delete: a
145
+ * foreign endpoint keeps `compose down` from removing the network. */
146
+ leaveNetwork?: (id: string) => void;
143
147
  deleteTimeoutMs?: number;
144
148
  }): Promise<void>;
145
149
  /** Tear down a direct-API test: stop proxy, remove instances, rm temp dir. */
package/daemon.js CHANGED
@@ -13,6 +13,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { createServer } from "node:net";
14
14
  import { join } from "node:path";
15
15
  import { cliCommand } from "./cli-command.js";
16
+ import { currentInstanceNetworks } from "./container-net.js";
16
17
  import { rememberDaemonStore } from "./daemon-stores.js";
17
18
  import { pollUntil } from "./poll.js";
18
19
  import { reapInstance } from "./reap.js";
@@ -339,7 +340,9 @@ export const INSTANCE_DELETE_TIMEOUT_MS = 20_000;
339
340
  */
340
341
  export async function cleanupDaemon(opts) {
341
342
  const deleteTimeoutMs = opts.deleteTimeoutMs ?? INSTANCE_DELETE_TIMEOUT_MS;
343
+ const leave = opts.leaveNetwork ?? ((id) => currentInstanceNetworks().leave(id));
342
344
  for (const id of opts.instances ?? []) {
345
+ leave(id);
343
346
  try {
344
347
  if ((await settleWithin(opts.deleteInstance(id), deleteTimeoutMs)) === "timeout")
345
348
  console.error(`cleanup: delete ${id} gave no answer in ${deleteTimeoutMs}ms`);
@@ -348,6 +351,8 @@ export async function cleanupDaemon(opts) {
348
351
  console.error(`cleanup: delete ${id} failed: ${e}`);
349
352
  }
350
353
  }
354
+ if (!opts.leaveNetwork)
355
+ currentInstanceNetworks().leaveAll();
351
356
  const reap = opts.reapInstance ?? reapInstance;
352
357
  for (const id of opts.instances ?? []) {
353
358
  const left = reap(id);
package/demo/harness.js CHANGED
@@ -179,6 +179,7 @@ export class ProductHarness {
179
179
  attempt: () => s.cliOk(launchArgv),
180
180
  log: deps.log,
181
181
  });
182
+ s.joinExtras(instanceId);
182
183
  await s.waitRunning(instanceId);
183
184
  const rows = await s.ingestPorts(instanceId);
184
185
  const templateParams = { declared, overrides: specParams };
package/demo/run.d.ts CHANGED
@@ -356,6 +356,12 @@ export declare class DemoSession {
356
356
  /** name -> stringified default, from `template show`. */
357
357
  declaredParams(templateName: string): Promise<Map<string, string | undefined>>;
358
358
  startExtras(instanceId: string): void;
359
+ /** ctl puts media on its instance network only, so an extra media dials (a
360
+ * capture origin) must join that network too. It exists only once the
361
+ * launch has created the instance, so this runs after the launch, not with
362
+ * startExtras. Extras are removed before the instance is deleted, so this
363
+ * never holds the network open. */
364
+ joinExtras(instanceId: string): void;
359
365
  stopExtras(): void;
360
366
  waitRunning(instanceId: string): Promise<void>;
361
367
  instanceListed(instanceId: string): Promise<boolean>;
package/demo/run.js CHANGED
@@ -30,7 +30,7 @@ import { homedir } from "node:os";
30
30
  import { dirname, isAbsolute, join, resolve } from "node:path";
31
31
  import { parseManifestSeed, repoOf } from "@norskvideo/ctl-sdk/manifest-seed";
32
32
  import { preserveInstanceContainerLogs } from "../container-logs.js";
33
- import { DOCKER_NETWORK_NAME, ensureRunnerOnNetwork, netReachMode } from "../container-net.js";
33
+ import { currentInstanceNetworks, DOCKER_NETWORK_NAME, ensureRunnerOnNetwork, instanceNetworkName, isAlreadyOnNetworkError, netReachMode, } from "../container-net.js";
34
34
  import { cleanupDaemon, ctlSupportsEphemeralPorts, PROXY_PORT_FILE, requireLicenseFile, runCli, startDaemon, } from "../daemon.js";
35
35
  import { hashSlot } from "../harness-config.js";
36
36
  import { ctlSupportsNoPublish, ctlSupportsPublishDebugPorts, runnerContainerUser } from "../launch.js";
@@ -565,6 +565,7 @@ export class DemoSession {
565
565
  * the product publishes (plus the spec's built one), the product and its
566
566
  * control-plane container all go first. Each step tolerates absence. */
567
567
  async resetRegistration(instanceId) {
568
+ currentInstanceNetworks().leave(instanceId, { force: true });
568
569
  await this.cli(["instance", "delete", instanceId, "--purge"]);
569
570
  const listed = await this.cli(["template", "list"], { output: "json" });
570
571
  const names = listed.exitCode === 0
@@ -695,6 +696,19 @@ export class DemoSession {
695
696
  this.deps.log(`extra ${e.name}: ${e.image}`);
696
697
  }
697
698
  }
699
+ /** ctl puts media on its instance network only, so an extra media dials (a
700
+ * capture origin) must join that network too. It exists only once the
701
+ * launch has created the instance, so this runs after the launch, not with
702
+ * startExtras. Extras are removed before the instance is deleted, so this
703
+ * never holds the network open. */
704
+ joinExtras(instanceId) {
705
+ for (const name of this.extras) {
706
+ const c = this.deps.docker(["network", "connect", instanceNetworkName(instanceId), name]);
707
+ if (c.exitCode !== 0 && !isAlreadyOnNetworkError(c.stderr)) {
708
+ throw new Error(`extra '${name}' could not join ${instanceNetworkName(instanceId)}: ${c.stderr.trim()}`);
709
+ }
710
+ }
711
+ }
698
712
  stopExtras() {
699
713
  for (const name of this.extras)
700
714
  this.deps.docker(["rm", "-f", name]);
@@ -748,8 +762,11 @@ export class DemoSession {
748
762
  this.preserveContainerLogs(opts.instances);
749
763
  if (opts.handles.length)
750
764
  await this.deps.stopSources(opts.handles).catch(() => { });
765
+ // Extras sit on the instance networks; they go before any instance does.
766
+ this.stopExtras();
751
767
  if (this.policy === "reuse") {
752
768
  for (const id of opts.instances) {
769
+ currentInstanceNetworks().leave(id);
753
770
  const r = await this.cli(["instance", "delete", id, "--purge"]);
754
771
  if (r.exitCode !== 0)
755
772
  this.deps.log(`instance delete ${id}: ${(r.stderr || r.stdout).trim()}`);
@@ -836,6 +853,7 @@ export async function demoDown(product, deps = defaultDemoDeps(process.cwd())) {
836
853
  const cli = (argv) => deps.cli(state.storeDir, ["--port", String(state.daemonPort), ...argv]);
837
854
  if (state.daemon === "reuse") {
838
855
  if (state.instanceId) {
856
+ currentInstanceNetworks().leave(state.instanceId, { force: true });
839
857
  const r = await cli(["instance", "delete", state.instanceId, "--purge"]);
840
858
  if (r.exitCode !== 0)
841
859
  deps.log(`instance delete ${state.instanceId}: ${(r.stderr || r.stdout).trim()}`);
package/demo/spec.d.ts CHANGED
@@ -93,7 +93,8 @@ export type DemoTemplate = {
93
93
  };
94
94
  /** A sidecar container the demo needs beside the instance (funke's ad-push
95
95
  * capture origin): `docker run -d --name <name> --network norsk-net <args>
96
- * <image> <command>`, started before the launch, removed at teardown. */
96
+ * <image> <command>`, started before the launch, joined to the instance
97
+ * network after it (media is on no other), removed at teardown. */
97
98
  export interface DemoExtra {
98
99
  name: string;
99
100
  image: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@norskvideo/ctl-test-harness",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -85,9 +85,9 @@
85
85
  "devDependencies": {
86
86
  "@types/bun": "latest",
87
87
  "@types/node": "^22.10.0",
88
- "@norskvideo/norsk-studio": "1.30.0-2026-10-04-e672beb6",
89
- "@norskvideo/norsk-studio-built-ins": "1.30.0-2026-10-04-e672beb6",
90
- "@norskvideo/norsk-sdk": "1.3.1-2026-10-04-dc313351"
88
+ "@norskvideo/norsk-studio": "1.30.0-2026-10-05-3d2846dc",
89
+ "@norskvideo/norsk-studio-built-ins": "1.30.0-2026-10-05-3d2846dc",
90
+ "@norskvideo/norsk-sdk": "1.3.1-2026-10-04-f034e83d"
91
91
  },
92
92
  "peerDependenciesMeta": {
93
93
  "@norskvideo/norsk-studio": {
package/studio-state.js CHANGED
@@ -18,7 +18,7 @@
18
18
  // Studio's /live/api/components returns each entry as `{ componentId, componentType, capabilities }`
19
19
  // — NOT `{ id, type }` (which is what the source-yaml schema uses internally).
20
20
  // Verified empirically against a live Studio instance.
21
- import { studioBaseFrom } from "./container-net.js";
21
+ import { reachInstanceMedia, studioBaseFrom } from "./container-net.js";
22
22
  // The host the studio port is reachable on. Normally the product template
23
23
  // publishes it on the loopback of the machine running the tests, so localhost is
24
24
  // right. When the test process itself runs inside a container that launches the
@@ -55,11 +55,13 @@ export function applyTestHost(rawUrl) {
55
55
  // proxies `/instance/<id>/media/*` here; whip-driver hits the same port direct.
56
56
  const MEDIA_HTTP_PORT = 8080;
57
57
  // Rewrite a daemon-proxy-routed media URL to a direct hit on the Norsk media
58
- // container over the daemon's `norsk-net` bridge. Studio advertises playback
59
- // URLs as `http://<publicHost>/instance/<id>/media/<native>` — that route is
60
- // served ONLY by the daemon's nginx proxy. A harness that runs proxy-less (and
61
- // whose runner has joined norsk-net) can instead reach the media container
62
- // directly by its compose service name: `http://<id>-media-1:8080/<native>`.
58
+ // container over its instance network `<id>_default`, the only one media is
59
+ // on. Studio advertises playback URLs as
60
+ // `http://<publicHost>/instance/<id>/media/<native>` — that route is served ONLY
61
+ // by the daemon's nginx proxy. A harness that runs proxy-less can instead reach
62
+ // the media container directly by its compose service name:
63
+ // `http://<id>-media-1:8080/<native>`; in direct mode this joins the runner to
64
+ // `<id>_default` so that name resolves.
63
65
  // The `/instance/<id>/media` prefix is the proxy's routing concern; Norsk's
64
66
  // native path is what follows it. Falls back to applyTestHost for URLs that
65
67
  // don't carry the proxy prefix. Mirrors whip-driver's dockerNetUrl.
@@ -70,6 +72,7 @@ export function mediaDirectUrl(rawUrl) {
70
72
  if (m) {
71
73
  const id = m[1];
72
74
  const rest = m[2] ?? "/";
75
+ reachInstanceMedia(id);
73
76
  return `http://${id}-media-1:${MEDIA_HTTP_PORT}${rest}${u.search}`;
74
77
  }
75
78
  }