@specific.dev/spectest 0.92.0 → 0.93.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.
@@ -1,9 +1,39 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
- import { topologicalOrder, validateServiceGraph } from "./service-graph";
3
+ import { startServices, topologicalOrder, validateServiceGraph } from "./service-graph";
4
4
 
5
5
  const svc = (name: string, dependsOn?: string[]) => ({ name, dependsOn });
6
6
 
7
+ test("bootstrap waits for sibling hooks after a failure and never starts failed dependents", async () => {
8
+ let release!: () => void;
9
+ const gate = new Promise<void>(resolve => { release = resolve; });
10
+ const started: string[] = [];
11
+ let completed = false;
12
+ let settled = false;
13
+ const failed = new Error("setup failed");
14
+ const result = startServices([svc("bad"), svc("slow"), svc("child", ["bad"])], async ({ name }) => {
15
+ started.push(name);
16
+ if (name === "bad") throw failed;
17
+ if (name === "slow") { await gate; completed = true; }
18
+ }).then(() => { settled = true; return undefined; }, error => { settled = true; return error; });
19
+ await new Promise(resolve => setTimeout(resolve, 0));
20
+ expect(started).toEqual(["bad", "slow"]);
21
+ expect(settled).toBe(false);
22
+ release();
23
+ const error = await result;
24
+ expect(completed).toBe(true);
25
+ expect(error instanceof AggregateError ? error.errors : [error]).toContain(failed);
26
+ });
27
+
28
+ test("shared dependencies complete setup once before either dependent starts", async () => {
29
+ const started: string[] = [];
30
+ await startServices([svc("a", ["db"]), svc("b", ["db"]), svc("db")], async ({ name }) => {
31
+ await Promise.resolve();
32
+ started.push(name);
33
+ });
34
+ expect(started).toEqual(["db", "a", "b"]);
35
+ });
36
+
7
37
  describe("validateServiceGraph", () => {
8
38
  test("returns a name→service map for a clean graph", () => {
9
39
  const map = validateServiceGraph([svc("db"), svc("api", ["db"])]);
@@ -1,3 +1,5 @@
1
+ import { waitForAll } from "./concurrency.js";
2
+
1
3
  /**
2
4
  * The `dependsOn` graph: validation, and the traversal order bring-up
3
5
  * uses.
@@ -95,3 +97,24 @@ export function topologicalOrder<T extends GraphNode>(services: readonly T[]): T
95
97
  for (const s of services) visit(s.name);
96
98
  return out;
97
99
  }
100
+
101
+ /** Start each service after its dependencies finish setup. Drain all branches
102
+ * before reporting failure so teardown cannot race containers or hooks. */
103
+ export async function startServices<T extends GraphNode>(
104
+ services: readonly T[],
105
+ startOne: (svc: T) => Promise<void>,
106
+ ): Promise<void> {
107
+ const byName = validateServiceGraph(services);
108
+ const started = new Map<string, Promise<void>>();
109
+ const start = (svc: T): Promise<void> => {
110
+ const existing = started.get(svc.name);
111
+ if (existing) return existing;
112
+ const pending = (async () => {
113
+ await Promise.all((svc.dependsOn ?? []).map(name => start(byName.get(name)!)));
114
+ await startOne(svc);
115
+ })();
116
+ started.set(svc.name, pending);
117
+ return pending;
118
+ };
119
+ await waitForAll(services.map(start));
120
+ }
package/src/index.ts CHANGED
@@ -796,13 +796,16 @@ export interface ServiceDefinition<
796
796
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
797
797
  export type ServicesMap = Record<string, ServiceDefinition<any>>;
798
798
 
799
- /** Awaited return type of a service's `helpers` factory, or `never` if
800
- * the service doesn't ship one. */
801
- type HelpersOf<D> = D extends {
802
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
803
- helpers: (...args: any) => infer R;
804
- }
805
- ? Awaited<R>
799
+ /** Awaited return type of a service's helpers factory. Check key presence
800
+ * before removing optionality: annotations keep `helpers?`, while plain
801
+ * service literals with no factory must stay out of ctx.svc. */
802
+ type HelpersOf<D> = "helpers" extends keyof D
803
+ ? [NonNullable<D["helpers"]>] extends [never]
804
+ ? never
805
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
806
+ : NonNullable<D["helpers"]> extends (...args: any[]) => infer R
807
+ ? Awaited<R>
808
+ : never
806
809
  : never;
807
810
 
808
811
  /**
@@ -1247,7 +1250,8 @@ export type BuildImage = (
1247
1250
  ) & {
1248
1251
  /** Full image reference, e.g. "k8s.internal:5000/runtime:spectest".
1249
1252
  * Optional for a service; required for a Kubernetes build. This names a
1250
- * local image and does not publish it to a registry. */
1253
+ * local image. In k3s, builds targeting `registry.host` are also published
1254
+ * to that component's registry; other references are only preloaded. */
1251
1255
  reference?: string;
1252
1256
  };
1253
1257