@specific.dev/spectest 0.113.0 → 0.114.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +13 -4
- package/dist/interactive.d.ts +18 -0
- package/dist/interactive.js +39 -0
- package/package.json +1 -1
- package/src/index.ts +25 -4
- package/src/interactive-setup.test.ts +7 -2
- package/src/interactive.test.ts +79 -0
- package/src/interactive.ts +41 -0
package/README.md
CHANGED
|
@@ -20,6 +20,34 @@ Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
|
|
|
20
20
|
Use a local service definition or `serviceGroup()` wrapper; the repository’s
|
|
21
21
|
`examples/google-pubsub` demonstrates this using public SDK APIs.
|
|
22
22
|
|
|
23
|
+
## Interactive entrypoints (0.114.0)
|
|
24
|
+
|
|
25
|
+
Choose the links shown in the interactive dashboard and PR preview comment:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
const env = defineEnvironment({
|
|
29
|
+
name: "my-app",
|
|
30
|
+
services: { web, api },
|
|
31
|
+
interactive: {
|
|
32
|
+
instructions: "Sign in as demo@example.com and use 000000 as the code",
|
|
33
|
+
entrypoints: [
|
|
34
|
+
{ name: "App", url: "https://app.test" },
|
|
35
|
+
{ name: "Admin", url: "https://app.test/admin" },
|
|
36
|
+
{ name: "API shell", terminal: "api" },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
List order is preserved. URLs must match declared service hostnames and are
|
|
43
|
+
translated to public preview URLs, retaining paths, queries and fragments.
|
|
44
|
+
Terminal targets are typed service names and use dashboard member permissions.
|
|
45
|
+
Entrypoints are display metadata; they do not change exposure or test execution.
|
|
46
|
+
With no entries, the only preview link is **Open environment**. Optional
|
|
47
|
+
`interactive.instructions` is a concise, direct plain-text sign-in description,
|
|
48
|
+
shown in a collapsed **Instructions** section in the dashboard and PR
|
|
49
|
+
comment. Blank instructions are omitted.
|
|
50
|
+
|
|
23
51
|
|
|
24
52
|
## Microphone fixtures and WebSocket fakes (0.103.0)
|
|
25
53
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
2
2
|
import type { Phone, PhoneOptions } from "./phone.js";
|
|
3
3
|
import type { Android, AndroidApp } from "./android.js";
|
|
4
|
+
import { type InteractiveEntrypoint, type InteractiveEntrypoints } from "./interactive.js";
|
|
5
|
+
export type { InteractiveEntrypoint, InteractiveEntrypoints } from "./interactive.js";
|
|
4
6
|
export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
|
|
5
7
|
import { strict as nodeAssert } from "node:assert";
|
|
6
8
|
export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, WrappedBuffer, WrappedBinaryView, WrappedBytes, WrappedBlob, WrappedFile, WrappedFormData, WrappedStream, WrappedReader, WrappedBYOBReader, WrappedReadResult, WrappedResponse, Provenanced, SpectestFetch, SpectestRequestInit, Unwrap, } from "./inspect.js";
|
|
@@ -69,6 +71,11 @@ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
|
|
|
69
71
|
services: S;
|
|
70
72
|
/** Sandbox timeout in seconds (default 1h). */
|
|
71
73
|
timeoutSecs?: number;
|
|
74
|
+
/** Display metadata only; entrypoints retain their author-declared order. */
|
|
75
|
+
interactive?: {
|
|
76
|
+
entrypoints: InteractiveEntrypoint[];
|
|
77
|
+
instructions?: string;
|
|
78
|
+
};
|
|
72
79
|
}
|
|
73
80
|
/**
|
|
74
81
|
* The shape that ends up in the JSON config the control plane and Rust
|
|
@@ -1717,6 +1724,15 @@ export interface EnvironmentInput<SI extends InputServicesMap, F extends FakesMa
|
|
|
1717
1724
|
/** Services and/or service groups, keyed by name. See
|
|
1718
1725
|
* {@link EnvironmentConfig.services} and {@link ServiceGroup}. */
|
|
1719
1726
|
services: SI;
|
|
1727
|
+
/** Links highlighted in the dashboard and PR previews, in list order.
|
|
1728
|
+
* URL entries target declared service hostnames; terminal entries name services. */
|
|
1729
|
+
interactive?: {
|
|
1730
|
+
entrypoints?: InteractiveEntrypoints<keyof NoInfer<ExpandServices<SI>> & string>;
|
|
1731
|
+
/** Concise, direct plain-text sign-in instructions shown in the dashboard
|
|
1732
|
+
* and a collapsed PR comment section. Example: "Sign in as demo@example.com
|
|
1733
|
+
* and use 000000 as the code". */
|
|
1734
|
+
instructions?: string;
|
|
1735
|
+
};
|
|
1720
1736
|
/** Sandbox timeout in seconds (default 1h). */
|
|
1721
1737
|
timeoutSecs?: number;
|
|
1722
1738
|
/** Fake servers — see {@link defineFake}. Keyed by stable name; the key
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveEntrypoints } from "./interactive.js";
|
|
1
2
|
// Spectest SDK. The user's single `spectest/index.ts` calls
|
|
2
3
|
// `defineEnvironment({ name, services })` once, defines test cases via
|
|
3
4
|
// the returned `env.test(...)`, and default-exports `env.project([...])`.
|
|
@@ -422,10 +423,9 @@ export function defineFake(opts) {
|
|
|
422
423
|
* produces the file's default export.
|
|
423
424
|
*/
|
|
424
425
|
export function defineEnvironment(input) {
|
|
425
|
-
// Split fakes off the wire config
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
const { fakes, services: rawServices, ...rest } = input;
|
|
426
|
+
// Split fakes off the wire config, expand service groups, and validate
|
|
427
|
+
// interactive destinations against the final service names.
|
|
428
|
+
const { fakes, interactive, services: rawServices, ...rest } = input;
|
|
429
429
|
const expanded = expandServiceGroups(rawServices);
|
|
430
430
|
for (const [key, svc] of Object.entries(expanded)) {
|
|
431
431
|
for (const build of svc.imageBuilds ?? [])
|
|
@@ -440,6 +440,15 @@ export function defineEnvironment(input) {
|
|
|
440
440
|
services: expanded,
|
|
441
441
|
};
|
|
442
442
|
validateEnvironmentConfig(config);
|
|
443
|
+
if (interactive?.instructions !== undefined && typeof interactive.instructions !== "string") {
|
|
444
|
+
throw new Error("interactive.instructions must be a string");
|
|
445
|
+
}
|
|
446
|
+
if (interactive?.entrypoints !== undefined || interactive?.instructions !== undefined) {
|
|
447
|
+
config.interactive = {
|
|
448
|
+
entrypoints: interactive.entrypoints === undefined ? [] : resolveEntrypoints(interactive.entrypoints, config.services),
|
|
449
|
+
...(interactive.instructions === undefined ? {} : { instructions: interactive.instructions }),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
443
452
|
if (fakes)
|
|
444
453
|
validateFakes(config, fakes);
|
|
445
454
|
// Every test created via this env's `.test(...)` is registered here. A
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** One destination highlighted in interactive environments and PR previews. */
|
|
2
|
+
export type InteractiveEntrypoint<Service extends string = string> = {
|
|
3
|
+
name: string;
|
|
4
|
+
} & ({
|
|
5
|
+
url: string;
|
|
6
|
+
terminal?: never;
|
|
7
|
+
} | {
|
|
8
|
+
terminal: Service;
|
|
9
|
+
url?: never;
|
|
10
|
+
});
|
|
11
|
+
/** Display order is exactly the order of this list. */
|
|
12
|
+
export type InteractiveEntrypoints<Service extends string = string> = readonly InteractiveEntrypoint<Service>[];
|
|
13
|
+
export declare function resolveEntrypoints(entries: InteractiveEntrypoints, services: Record<string, {
|
|
14
|
+
hostnames?: readonly string[];
|
|
15
|
+
tls?: readonly {
|
|
16
|
+
hostname: string;
|
|
17
|
+
}[];
|
|
18
|
+
}>): InteractiveEntrypoint[];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function resolveEntrypoints(entries, services) {
|
|
2
|
+
if (!Array.isArray(entries))
|
|
3
|
+
throw new Error("interactive.entrypoints must be a list");
|
|
4
|
+
const names = new Set();
|
|
5
|
+
return entries.map((entry, index) => {
|
|
6
|
+
const fail = (reason) => { throw new Error(`interactive.entrypoints[${index}]: ${reason}`); };
|
|
7
|
+
if (!entry || typeof entry.name !== "string" || !entry.name.trim())
|
|
8
|
+
fail("name must be a non-empty string");
|
|
9
|
+
const { name } = entry;
|
|
10
|
+
if (names.has(name))
|
|
11
|
+
fail(`duplicate name ${JSON.stringify(name)}`);
|
|
12
|
+
names.add(name);
|
|
13
|
+
if ((entry.url !== undefined) === (entry.terminal !== undefined))
|
|
14
|
+
fail("specify exactly one of url or terminal");
|
|
15
|
+
if (entry.terminal !== undefined) {
|
|
16
|
+
if (typeof entry.terminal !== "string" || !Object.hasOwn(services, entry.terminal)) {
|
|
17
|
+
fail("terminal must name a declared service");
|
|
18
|
+
}
|
|
19
|
+
return { name, terminal: entry.terminal };
|
|
20
|
+
}
|
|
21
|
+
if (typeof entry.url !== "string")
|
|
22
|
+
return fail("expected an absolute HTTP or HTTPS URL");
|
|
23
|
+
let url;
|
|
24
|
+
try {
|
|
25
|
+
url = new URL(entry.url);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return fail("expected an absolute HTTP or HTTPS URL");
|
|
29
|
+
}
|
|
30
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.port) {
|
|
31
|
+
fail("use an HTTP or HTTPS service URL without credentials or a non-default port");
|
|
32
|
+
}
|
|
33
|
+
const declared = Object.values(services).some((service) => [...(service.hostnames ?? []), ...(service.tls ?? []).map((tls) => tls.hostname)]
|
|
34
|
+
.some((host) => !host.startsWith("*.") && host.toLowerCase() === url.hostname));
|
|
35
|
+
if (!declared)
|
|
36
|
+
fail(`hostname ${JSON.stringify(url.hostname)} is not a declared service endpoint`);
|
|
37
|
+
return { name, url: url.href };
|
|
38
|
+
});
|
|
39
|
+
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
2
2
|
import type { Phone, PhoneOptions } from "./phone.js";
|
|
3
3
|
import type { Android, AndroidApp } from "./android.js";
|
|
4
|
+
import { resolveEntrypoints, type InteractiveEntrypoint, type InteractiveEntrypoints } from "./interactive.js";
|
|
5
|
+
export type { InteractiveEntrypoint, InteractiveEntrypoints } from "./interactive.js";
|
|
4
6
|
export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
|
|
5
7
|
// Spectest SDK. The user's single `spectest/index.ts` calls
|
|
6
8
|
// `defineEnvironment({ name, services })` once, defines test cases via
|
|
@@ -245,6 +247,8 @@ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
|
|
|
245
247
|
services: S;
|
|
246
248
|
/** Sandbox timeout in seconds (default 1h). */
|
|
247
249
|
timeoutSecs?: number;
|
|
250
|
+
/** Display metadata only; entrypoints retain their author-declared order. */
|
|
251
|
+
interactive?: { entrypoints: InteractiveEntrypoint[]; instructions?: string };
|
|
248
252
|
}
|
|
249
253
|
|
|
250
254
|
/**
|
|
@@ -2481,6 +2485,15 @@ export interface EnvironmentInput<
|
|
|
2481
2485
|
/** Services and/or service groups, keyed by name. See
|
|
2482
2486
|
* {@link EnvironmentConfig.services} and {@link ServiceGroup}. */
|
|
2483
2487
|
services: SI;
|
|
2488
|
+
/** Links highlighted in the dashboard and PR previews, in list order.
|
|
2489
|
+
* URL entries target declared service hostnames; terminal entries name services. */
|
|
2490
|
+
interactive?: {
|
|
2491
|
+
entrypoints?: InteractiveEntrypoints<keyof NoInfer<ExpandServices<SI>> & string>;
|
|
2492
|
+
/** Concise, direct plain-text sign-in instructions shown in the dashboard
|
|
2493
|
+
* and a collapsed PR comment section. Example: "Sign in as demo@example.com
|
|
2494
|
+
* and use 000000 as the code". */
|
|
2495
|
+
instructions?: string;
|
|
2496
|
+
};
|
|
2484
2497
|
/** Sandbox timeout in seconds (default 1h). */
|
|
2485
2498
|
timeoutSecs?: number;
|
|
2486
2499
|
/** Fake servers — see {@link defineFake}. Keyed by stable name; the key
|
|
@@ -2591,10 +2604,9 @@ export function defineEnvironment<
|
|
|
2591
2604
|
F extends FakesMap = FakesMap,
|
|
2592
2605
|
>(input: EnvironmentInput<SI, F>): DefinedEnvironment<ExpandServices<SI>, F> {
|
|
2593
2606
|
type S = ExpandServices<SI>;
|
|
2594
|
-
// Split fakes off the wire config
|
|
2595
|
-
//
|
|
2596
|
-
|
|
2597
|
-
const { fakes, services: rawServices, ...rest } = input;
|
|
2607
|
+
// Split fakes off the wire config, expand service groups, and validate
|
|
2608
|
+
// interactive destinations against the final service names.
|
|
2609
|
+
const { fakes, interactive, services: rawServices, ...rest } = input;
|
|
2598
2610
|
const expanded = expandServiceGroups(rawServices);
|
|
2599
2611
|
for (const [key, svc] of Object.entries(expanded)) {
|
|
2600
2612
|
for (const build of svc.imageBuilds ?? []) validateServiceImage(`${key} image ${build.reference}`, build.image);
|
|
@@ -2608,6 +2620,15 @@ export function defineEnvironment<
|
|
|
2608
2620
|
services: expanded as S,
|
|
2609
2621
|
};
|
|
2610
2622
|
validateEnvironmentConfig(config);
|
|
2623
|
+
if (interactive?.instructions !== undefined && typeof interactive.instructions !== "string") {
|
|
2624
|
+
throw new Error("interactive.instructions must be a string");
|
|
2625
|
+
}
|
|
2626
|
+
if (interactive?.entrypoints !== undefined || interactive?.instructions !== undefined) {
|
|
2627
|
+
config.interactive = {
|
|
2628
|
+
entrypoints: interactive.entrypoints === undefined ? [] : resolveEntrypoints(interactive.entrypoints, config.services),
|
|
2629
|
+
...(interactive.instructions === undefined ? {} : { instructions: interactive.instructions }),
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2611
2632
|
if (fakes) validateFakes(config, fakes);
|
|
2612
2633
|
|
|
2613
2634
|
// Every test created via this env's `.test(...)` is registered here. A
|
|
@@ -16,7 +16,10 @@ for (const withHook of [false, true]) {
|
|
|
16
16
|
await writeFile(entry, `
|
|
17
17
|
import { defineEnvironment } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)};
|
|
18
18
|
export const calls = [];
|
|
19
|
-
const env = defineEnvironment({ name: "seed",
|
|
19
|
+
const env = defineEnvironment({ name: "seed", interactive: {
|
|
20
|
+
entrypoints: [{ name: "App shell", terminal: "app" }],
|
|
21
|
+
instructions: "Sign in as demo@example.com and use 000000 as the code",
|
|
22
|
+
}, services: {
|
|
20
23
|
app: { image: { type: "registry", reference: "busybox" },
|
|
21
24
|
helpers: () => ({ label: "typed helper" }) },
|
|
22
25
|
}});
|
|
@@ -43,7 +46,9 @@ for (const withHook of [false, true]) {
|
|
|
43
46
|
import { METHODS } from ${JSON.stringify(new URL("./daemon.ts", import.meta.url).href)};
|
|
44
47
|
import { calls } from ${JSON.stringify(entry)};
|
|
45
48
|
const nativeFetch = globalThis.fetch;
|
|
46
|
-
await METHODS.load({});
|
|
49
|
+
const loaded = await METHODS.load({});
|
|
50
|
+
assert.deepEqual(loaded.environment.interactive.entrypoints, [{ name: "App shell", terminal: "app" }]);
|
|
51
|
+
assert.equal(loaded.environment.interactive.instructions, "Sign in as demo@example.com and use 000000 as the code");
|
|
47
52
|
assert.equal((await METHODS.health({})).setupInteractive, ${withHook});
|
|
48
53
|
await METHODS.projectSetup({});
|
|
49
54
|
assert.deepEqual(calls, ["baseline"]);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineEnvironment, serviceGroup, type InteractiveEntrypoints } from "./index.js";
|
|
3
|
+
|
|
4
|
+
const services = {
|
|
5
|
+
web: { image: { type: "registry" as const, reference: "nginx" }, tls: [{ hostname: "app.test", port: 80 }] },
|
|
6
|
+
api: { image: { type: "registry" as const, reference: "nginx" }, hostnames: ["api.test"] },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe("interactive entrypoints", () => {
|
|
10
|
+
test("instructions survive serialization without any entrypoints", () => {
|
|
11
|
+
const instructions = "Sign in as demo@example.com and use 000000 as the code";
|
|
12
|
+
const env = defineEnvironment({ name: "app", services, interactive: { instructions } });
|
|
13
|
+
expect(JSON.parse(JSON.stringify(env.project().environment)).interactive).toEqual({ entrypoints: [], instructions });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("instructions must be a string", () => {
|
|
17
|
+
const invalid = () => defineEnvironment({ name: "app", services, interactive: {
|
|
18
|
+
// @ts-expect-error instructions are plain text
|
|
19
|
+
instructions: 123,
|
|
20
|
+
} });
|
|
21
|
+
expect(invalid).toThrow("interactive.instructions must be a string");
|
|
22
|
+
});
|
|
23
|
+
test("keeps mixed destinations in list order on the project wire config", () => {
|
|
24
|
+
const env = defineEnvironment({ name: "app", services, interactive: { entrypoints: [
|
|
25
|
+
{ name: "Website", url: "https://app.test/admin?tab=users#invite" },
|
|
26
|
+
{ name: "2", terminal: "api" },
|
|
27
|
+
{ name: "1", url: "http://api.test/docs" },
|
|
28
|
+
] } });
|
|
29
|
+
const config = JSON.parse(JSON.stringify(env.project().environment));
|
|
30
|
+
expect(config.interactive.entrypoints).toEqual([
|
|
31
|
+
{ name: "Website", url: "https://app.test/admin?tab=users#invite" },
|
|
32
|
+
{ name: "2", terminal: "api" },
|
|
33
|
+
{ name: "1", url: "http://api.test/docs" },
|
|
34
|
+
]);
|
|
35
|
+
expect(env.project().tests).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("omitting entrypoints leaves existing projects unchanged", () => {
|
|
39
|
+
expect(defineEnvironment({ name: "app", services }).config.interactive).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("terminal destinations can name expanded service-group members", () => {
|
|
43
|
+
const stack = serviceGroup({ primary: "api", services: () => ({ api: services.api, worker: services.web }) });
|
|
44
|
+
const env = defineEnvironment({ name: "app", services: { stack }, interactive: {
|
|
45
|
+
entrypoints: [{ name: "Worker shell", terminal: "stack-worker" }],
|
|
46
|
+
} });
|
|
47
|
+
expect(env.config.interactive?.entrypoints).toEqual([{ name: "Worker shell", terminal: "stack-worker" }]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test.each([null, { App: "https://app.test" }])("requires a list: %j", (entries) => {
|
|
51
|
+
expect(() => defineEnvironment({ name: "app", services, interactive: {
|
|
52
|
+
entrypoints: entries as unknown as InteractiveEntrypoints<"web" | "api">,
|
|
53
|
+
} })).toThrow("must be a list");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("terminal service names are checked statically and at runtime", () => {
|
|
57
|
+
const invalid = () => defineEnvironment({ name: "app", services, interactive: { entrypoints: [
|
|
58
|
+
// @ts-expect-error terminal targets must be declared services
|
|
59
|
+
{ name: "Shell", terminal: "missing" },
|
|
60
|
+
] } });
|
|
61
|
+
expect(invalid).toThrow("terminal must name a declared service");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test.each([
|
|
65
|
+
[{ name: "App", url: "https://external.test" }],
|
|
66
|
+
[{ name: "App", url: "/relative" }],
|
|
67
|
+
[{ name: "App", url: "javascript:alert(1)" }],
|
|
68
|
+
[{ name: "App", url: "https://user:pass@app.test" }],
|
|
69
|
+
[{ name: "App", url: "https://app.test:8443" }],
|
|
70
|
+
[{ name: "App", url: "https://app.test", terminal: "web" }],
|
|
71
|
+
[{ name: "App" }],
|
|
72
|
+
[{ name: "", terminal: "web" }],
|
|
73
|
+
[{ name: "Same", terminal: "web" }, { name: "Same", terminal: "api" }],
|
|
74
|
+
])("rejects invalid destinations %j", (...entries) => {
|
|
75
|
+
expect(() => defineEnvironment({ name: "app", services, interactive: {
|
|
76
|
+
entrypoints: entries as unknown as InteractiveEntrypoints<"web" | "api">,
|
|
77
|
+
} })).toThrow("interactive.entrypoints");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** One destination highlighted in interactive environments and PR previews. */
|
|
2
|
+
export type InteractiveEntrypoint<Service extends string = string> = { name: string } & (
|
|
3
|
+
| { url: string; terminal?: never }
|
|
4
|
+
| { terminal: Service; url?: never }
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
/** Display order is exactly the order of this list. */
|
|
8
|
+
export type InteractiveEntrypoints<Service extends string = string> = readonly InteractiveEntrypoint<Service>[];
|
|
9
|
+
|
|
10
|
+
export function resolveEntrypoints(
|
|
11
|
+
entries: InteractiveEntrypoints,
|
|
12
|
+
services: Record<string, { hostnames?: readonly string[]; tls?: readonly { hostname: string }[] }>,
|
|
13
|
+
): InteractiveEntrypoint[] {
|
|
14
|
+
if (!Array.isArray(entries)) throw new Error("interactive.entrypoints must be a list");
|
|
15
|
+
const names = new Set<string>();
|
|
16
|
+
return entries.map((entry, index) => {
|
|
17
|
+
const fail = (reason: string): never => { throw new Error(`interactive.entrypoints[${index}]: ${reason}`); };
|
|
18
|
+
if (!entry || typeof entry.name !== "string" || !entry.name.trim()) fail("name must be a non-empty string");
|
|
19
|
+
const { name } = entry;
|
|
20
|
+
if (names.has(name)) fail(`duplicate name ${JSON.stringify(name)}`);
|
|
21
|
+
names.add(name);
|
|
22
|
+
if ((entry.url !== undefined) === (entry.terminal !== undefined)) fail("specify exactly one of url or terminal");
|
|
23
|
+
if (entry.terminal !== undefined) {
|
|
24
|
+
if (typeof entry.terminal !== "string" || !Object.hasOwn(services, entry.terminal)) {
|
|
25
|
+
fail("terminal must name a declared service");
|
|
26
|
+
}
|
|
27
|
+
return { name, terminal: entry.terminal };
|
|
28
|
+
}
|
|
29
|
+
if (typeof entry.url !== "string") return fail("expected an absolute HTTP or HTTPS URL");
|
|
30
|
+
let url: URL;
|
|
31
|
+
try { url = new URL(entry.url); } catch { return fail("expected an absolute HTTP or HTTPS URL"); }
|
|
32
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.port) {
|
|
33
|
+
fail("use an HTTP or HTTPS service URL without credentials or a non-default port");
|
|
34
|
+
}
|
|
35
|
+
const declared = Object.values(services).some((service) =>
|
|
36
|
+
[...(service.hostnames ?? []), ...(service.tls ?? []).map((tls) => tls.hostname)]
|
|
37
|
+
.some((host) => !host.startsWith("*.") && host.toLowerCase() === url.hostname));
|
|
38
|
+
if (!declared) fail(`hostname ${JSON.stringify(url.hostname)} is not a declared service endpoint`);
|
|
39
|
+
return { name, url: url.href };
|
|
40
|
+
});
|
|
41
|
+
}
|