@miosa/sdk 1.2.3 → 1.2.5

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.
@@ -13,7 +13,6 @@
13
13
  import { randomUUID } from "node:crypto";
14
14
 
15
15
  import type { HttpClient } from "../http.js";
16
- import type { DockerDeployHostData } from "./docker-deploy.js";
17
16
 
18
17
  // ── Branded IDs ─────────────────────────────────────────────────────────────
19
18
 
@@ -121,35 +120,6 @@ export interface DeploymentData {
121
120
  updated_at?: string;
122
121
  }
123
122
 
124
- export interface DockerDeployDoctorCheck {
125
- name: string;
126
- ok: boolean;
127
- message: string;
128
- details?: Record<string, unknown>;
129
- }
130
-
131
- export interface DockerDeployDoctorProbe {
132
- url: string;
133
- ok: boolean;
134
- status?: number;
135
- error?: string;
136
- }
137
-
138
- export interface DockerDeployDoctorResult {
139
- ok: boolean;
140
- deployment: DeploymentData;
141
- host?: DockerDeployHostData;
142
- checks: DockerDeployDoctorCheck[];
143
- probe?: DockerDeployDoctorProbe;
144
- }
145
-
146
- export interface DockerDeployDoctorParams {
147
- probePath?: string;
148
- probe_path?: string;
149
- timeoutMs?: number;
150
- timeout_ms?: number;
151
- }
152
-
153
123
  export type DeploymentDatabaseRequest =
154
124
  | boolean
155
125
  | {
@@ -311,12 +281,118 @@ export interface DeploymentCreateParams extends ExternalAttribution {
311
281
  auto_deploy?: boolean;
312
282
  autoDeploy?: boolean;
313
283
  database?: DeploymentDatabaseRequest;
284
+ docker_deploy_template_id?: string;
285
+ dockerDeployTemplateId?: string;
314
286
  metadata?: Record<string, unknown>;
315
287
  idempotencyKey?: string;
316
288
  }
317
289
 
318
290
  export interface DockerDeployCreateParams extends DeploymentCreateParams {}
319
291
 
292
+ export interface DockerDeployHostData {
293
+ id: string;
294
+ tenant_id?: string;
295
+ workspace_id?: string | null;
296
+ computer_id?: string | null;
297
+ fleet_node_id?: string | null;
298
+ runtime_base_url?: string | null;
299
+ status?: string;
300
+ appliance_status?: string | null;
301
+ appliance_version?: string | null;
302
+ appliance_image?: string | null;
303
+ agent_configured?: boolean;
304
+ last_health_check_at?: string | null;
305
+ created_at?: string;
306
+ updated_at?: string;
307
+ metadata?: Record<string, unknown>;
308
+ }
309
+
310
+ export interface DockerDeployHostListParams {
311
+ workspace_id?: string;
312
+ workspaceId?: string;
313
+ }
314
+
315
+ export interface DockerDeployHostEnsureParams {
316
+ workspace_id: string;
317
+ workspaceId?: string;
318
+ region?: string;
319
+ size?: string;
320
+ appliance_image?: string;
321
+ applianceImage?: string;
322
+ metadata?: Record<string, unknown>;
323
+ idempotencyKey?: string;
324
+ }
325
+
326
+ export interface DockerDeployTemplateData {
327
+ id: string;
328
+ name: string;
329
+ summary?: string;
330
+ description?: string;
331
+ category?: string;
332
+ runtime?: string;
333
+ framework?: string;
334
+ build_command?: string | null;
335
+ run_command?: string | null;
336
+ ports?: number[];
337
+ env?: Record<string, string>;
338
+ files?: Record<string, string>;
339
+ design_md?: string | null;
340
+ design_source_url?: string | null;
341
+ design_sources?: string[];
342
+ design_reference_presets?: DockerDeployDesignReferencePreset[];
343
+ metadata?: Record<string, unknown>;
344
+ }
345
+
346
+ export interface DockerDeployDesignReferencePreset {
347
+ id: string;
348
+ name: string;
349
+ summary: string;
350
+ best_for?: string[];
351
+ }
352
+
353
+ export interface DockerDeployTemplateListParams {
354
+ category?: string;
355
+ runtime?: string;
356
+ framework?: string;
357
+ includePreview?: boolean;
358
+ include_preview?: boolean;
359
+ }
360
+
361
+ export interface DockerDeployDoctorParams {
362
+ /** Probe the deployment public URL after checking control-plane metadata. Default: true. */
363
+ probe?: boolean;
364
+ /** Path to request on the public URL. Default: "/". */
365
+ probePath?: string;
366
+ /** Public URL probe timeout in milliseconds. Default: 20_000. */
367
+ timeoutMs?: number;
368
+ /** Test hook or custom runtime fetch implementation. */
369
+ fetchImpl?: typeof fetch;
370
+ }
371
+
372
+ export interface DockerDeployDoctorCheck {
373
+ name: string;
374
+ ok: boolean;
375
+ message: string;
376
+ data?: Record<string, unknown>;
377
+ }
378
+
379
+ export interface DockerDeployDoctorProbe {
380
+ url: string;
381
+ status: number | null;
382
+ ok: boolean;
383
+ responseSnippet: string;
384
+ gatewayJson?: boolean;
385
+ }
386
+
387
+ export interface DockerDeployDoctorResult {
388
+ ok: boolean;
389
+ deployment: DeploymentData;
390
+ host?: DockerDeployHostData;
391
+ publicUrl?: string | null;
392
+ checks: DockerDeployDoctorCheck[];
393
+ probe?: DockerDeployDoctorProbe;
394
+ }
395
+
320
396
  export interface DeploymentUpdateParams {
321
397
  name?: string;
322
398
  branch?: string;
@@ -363,6 +439,10 @@ export interface PublishParams extends ExternalAttribution {
363
439
  healthCheckPath?: string;
364
440
  /** @deprecated Reserved for dynamic runtime publish. */
365
441
  health_check_path?: string;
442
+ deploymentType?: DeploymentProduct | string;
443
+ deployment_type?: DeploymentProduct | string;
444
+ dockerDeployTemplateId?: string;
445
+ docker_deploy_template_id?: string;
366
446
  /** @deprecated Reserved for managed data-service binding. */
367
447
  dataServices?: string[];
368
448
  /** @deprecated Reserved for managed data-service binding. */
@@ -459,26 +539,49 @@ function stripUndefined(
459
539
 
460
540
  function dockerDeployMetadata(
461
541
  metadata: Record<string, unknown> | undefined,
542
+ templateId?: string,
462
543
  ): Record<string, unknown> {
463
- return {
544
+ return stripUndefined({
464
545
  ...(metadata ?? {}),
465
546
  deployment_product: "docker_deploy",
466
- };
547
+ docker_deploy_template_id: templateId,
548
+ });
467
549
  }
468
550
 
469
- function dockerDeployProduct(deployment: DeploymentData): unknown {
470
- return (
471
- deployment.deployment_product ??
472
- deployment.metadata?.["deployment_product"]
473
- );
551
+ function isRecord(value: unknown): value is Record<string, unknown> {
552
+ return !!value && typeof value === "object" && !Array.isArray(value);
474
553
  }
475
554
 
476
- function dockerDeployHostId(deployment: DeploymentData): string | null {
477
- const metadataHost = deployment.metadata?.["docker_deploy_host_id"];
478
- return (
479
- deployment.docker_deploy_host_id ??
480
- (typeof metadataHost === "string" ? metadataHost : null)
481
- );
555
+ function stringValue(value: unknown): string | undefined {
556
+ return typeof value === "string" && value.length > 0 ? value : undefined;
557
+ }
558
+
559
+ function runtimeMetadata(
560
+ metadata: Record<string, unknown>,
561
+ ): Record<string, unknown> | undefined {
562
+ const runtime = metadata.runtime;
563
+ return isRecord(runtime) ? runtime : undefined;
564
+ }
565
+
566
+ function publicProbeUrl(publicUrl: string, probePath: string): string {
567
+ const url = new URL(publicUrl);
568
+ const normalizedPath = probePath.startsWith("/") ? probePath : `/${probePath}`;
569
+ url.pathname = normalizedPath;
570
+ return url.toString();
571
+ }
572
+
573
+ function looksLikeMiosaGatewayJson(body: string): boolean {
574
+ try {
575
+ const parsed = JSON.parse(body) as unknown;
576
+ return (
577
+ isRecord(parsed) &&
578
+ parsed.ok === true &&
579
+ typeof parsed.run_id === "string" &&
580
+ Object.keys(parsed).every((key) => ["ok", "run_id"].includes(key))
581
+ );
582
+ } catch {
583
+ return false;
584
+ }
482
585
  }
483
586
 
484
587
  function addDoctorCheck(
@@ -486,21 +589,11 @@ function addDoctorCheck(
486
589
  name: string,
487
590
  ok: boolean,
488
591
  message: string,
489
- details?: Record<string, unknown>,
592
+ data?: Record<string, unknown>,
490
593
  ): void {
491
- checks.push({ name, ok, message, ...(details ? { details } : {}) });
492
- }
493
-
494
- function hostHealthy(host: DockerDeployHostData | undefined): boolean {
495
- return Boolean(
496
- host && host.status === "active" && host.appliance_status === "healthy",
497
- );
498
- }
499
-
500
- function probeUrl(publicUrl: string, probePath: string): string {
501
- const url = new URL(publicUrl);
502
- url.pathname = probePath.startsWith("/") ? probePath : `/${probePath}`;
503
- return url.toString();
594
+ const check: DockerDeployDoctorCheck = { name, ok, message };
595
+ if (data !== undefined) check.data = data;
596
+ checks.push(check);
504
597
  }
505
598
 
506
599
  // ── Sub-resources ──────────────────────────────────────────────────────────
@@ -698,6 +791,8 @@ export class Deployments {
698
791
  run_command: params.runCommand ?? params.run_command,
699
792
  auto_deploy: params.autoDeploy ?? params.auto_deploy,
700
793
  database: params.database,
794
+ docker_deploy_template_id:
795
+ params.dockerDeployTemplateId ?? params.docker_deploy_template_id,
701
796
  metadata: params.metadata,
702
797
  ...attributionBody(params),
703
798
  });
@@ -717,16 +812,55 @@ export class Deployments {
717
812
  async createDockerDeploy(
718
813
  params: DockerDeployCreateParams,
719
814
  ): Promise<DeploymentData> {
815
+ const templateId =
816
+ params.dockerDeployTemplateId ?? params.docker_deploy_template_id;
720
817
  return this.create({
721
818
  ...params,
722
- metadata: dockerDeployMetadata(params.metadata),
819
+ metadata: dockerDeployMetadata(params.metadata, templateId),
723
820
  });
724
821
  }
725
822
 
823
+ async listDockerDeployHosts(
824
+ params: DockerDeployHostListParams = {},
825
+ ): Promise<DockerDeployHostData[]> {
826
+ const data = await this.http.get<unknown>(
827
+ "/docker-deploy/hosts",
828
+ stripUndefined({
829
+ workspace_id: params.workspaceId ?? params.workspace_id,
830
+ }) as Record<string, string | number | boolean | undefined>,
831
+ );
832
+ return listItems<DockerDeployHostData>(data, ["hosts", "items"]);
833
+ }
834
+
835
+ async ensureDockerDeployHost(
836
+ params: DockerDeployHostEnsureParams,
837
+ ): Promise<DockerDeployHostData> {
838
+ const data = await this.http.request<unknown>("/docker-deploy/hosts/ensure", {
839
+ method: "POST",
840
+ body: stripUndefined({
841
+ workspace_id: params.workspaceId ?? params.workspace_id,
842
+ region: params.region,
843
+ size: params.size,
844
+ appliance_image: params.applianceImage ?? params.appliance_image,
845
+ metadata: params.metadata,
846
+ }),
847
+ headers: { "Idempotency-Key": idempotencyKey(params.idempotencyKey) },
848
+ });
849
+ return unwrap(data) as DockerDeployHostData;
850
+ }
851
+
852
+ async getDockerDeployHost(hostId: string): Promise<DockerDeployHostData> {
853
+ const data = await this.http.get<unknown>(`/docker-deploy/hosts/${hostId}`);
854
+ return unwrap(data) as DockerDeployHostData;
855
+ }
856
+
726
857
  /**
727
- * Verify a Docker Deploy deployment before telling a user or agent it is
728
- * live. Checks product markers, appliance host health, route metadata, and
729
- * optionally probes the public URL.
858
+ * Verify a Docker Deploy deployment end-to-end.
859
+ *
860
+ * This checks the deployment product marker, Docker Deploy host linkage,
861
+ * appliance health, route runtime metadata, and optionally the public URL.
862
+ * It is intentionally agent-friendly: use this after sandbox.deployDocker()
863
+ * or createDockerDeploy() before telling a user the app is live.
730
864
  */
731
865
  async doctorDockerDeploy(
732
866
  deploymentId: string,
@@ -734,127 +868,176 @@ export class Deployments {
734
868
  ): Promise<DockerDeployDoctorResult> {
735
869
  const checks: DockerDeployDoctorCheck[] = [];
736
870
  const deployment = await this.get(deploymentId);
737
- const metadata = deployment.metadata ?? {};
738
- const product = dockerDeployProduct(deployment);
739
- const hostId = dockerDeployHostId(deployment);
871
+ const metadata = isRecord(deployment.metadata) ? deployment.metadata : {};
872
+ const product =
873
+ stringValue(deployment.deployment_product) ??
874
+ stringValue(metadata.deployment_product);
875
+ const hostId =
876
+ stringValue(deployment.docker_deploy_host_id) ??
877
+ stringValue(metadata.docker_deploy_host_id);
740
878
 
741
879
  addDoctorCheck(
742
880
  checks,
743
881
  "deployment_product",
744
882
  product === "docker_deploy",
745
883
  product === "docker_deploy"
746
- ? "Deployment is marked for Docker Deploy."
747
- : `Expected deployment_product=docker_deploy, got ${String(product ?? "missing")}.`,
748
- { deployment_product: product ?? null },
884
+ ? "Deployment is marked as Docker Deploy."
885
+ : `Expected deployment_product=docker_deploy, got ${product ?? "missing"}.`,
886
+ { product: product ?? null },
749
887
  );
750
888
 
751
889
  addDoctorCheck(
752
890
  checks,
753
891
  "docker_deploy_host_id",
754
- Boolean(hostId),
892
+ !!hostId,
755
893
  hostId
756
- ? "Deployment has a Docker Deploy host id."
894
+ ? "Deployment is linked to a Docker Deploy appliance host."
757
895
  : "Deployment has no docker_deploy_host_id.",
758
- { docker_deploy_host_id: hostId },
896
+ { docker_deploy_host_id: hostId ?? null },
759
897
  );
760
898
 
761
899
  let host: DockerDeployHostData | undefined;
762
900
  if (hostId) {
763
901
  try {
764
- const rawHost = await this.http.get<unknown>(
765
- `/docker-deploy/hosts/${hostId}`,
766
- );
767
- host = unwrap(rawHost) as DockerDeployHostData;
902
+ host = await this.getDockerDeployHost(hostId);
903
+ const status = host.status ?? null;
904
+ const applianceStatus = host.appliance_status ?? null;
905
+ const badHostStates = new Set(["failed", "error", "destroyed"]);
906
+ const badApplianceStates = new Set(["failed", "error", "unhealthy"]);
907
+ const ok =
908
+ !badHostStates.has(String(status)) &&
909
+ !badApplianceStates.has(String(applianceStatus));
768
910
  addDoctorCheck(
769
911
  checks,
770
912
  "docker_deploy_host_health",
771
- hostHealthy(host),
772
- hostHealthy(host)
773
- ? "Docker Deploy host is active and healthy."
774
- : `Docker Deploy host status=${host.status} appliance=${host.appliance_status}.`,
775
- {
776
- status: host.status,
777
- appliance_status: host.appliance_status,
778
- },
913
+ ok,
914
+ ok
915
+ ? "Docker Deploy host is not reporting a failed state."
916
+ : `Docker Deploy host is unhealthy: status=${status}, appliance_status=${applianceStatus}.`,
917
+ { status, appliance_status: applianceStatus },
779
918
  );
780
- } catch (error) {
919
+ } catch (err) {
781
920
  addDoctorCheck(
782
921
  checks,
783
922
  "docker_deploy_host_health",
784
923
  false,
785
- error instanceof Error ? error.message : String(error),
924
+ `Could not fetch Docker Deploy host ${hostId}: ${
925
+ err instanceof Error ? err.message : String(err)
926
+ }`,
786
927
  );
787
928
  }
788
929
  }
789
930
 
790
- const runtime = metadata["runtime"];
791
- const hasRuntimeRoute =
792
- typeof runtime === "object" &&
793
- runtime !== null &&
794
- typeof (runtime as Record<string, unknown>)["ip"] === "string" &&
795
- typeof (runtime as Record<string, unknown>)["port"] === "number";
931
+ const runtime = runtimeMetadata(metadata);
932
+ const runtimeIp = runtime
933
+ ? stringValue(runtime.ip) ?? stringValue(runtime.ip_address)
934
+ : undefined;
935
+ const runtimePort = runtime?.port;
936
+ addDoctorCheck(
937
+ checks,
938
+ "route_runtime",
939
+ !!runtimeIp && runtimePort !== undefined && runtimePort !== null,
940
+ runtimeIp && runtimePort !== undefined && runtimePort !== null
941
+ ? "Deployment route metadata points at a runtime target."
942
+ : "Deployment metadata has no runtime ip/port target.",
943
+ { ip: runtimeIp ?? null, port: runtimePort ?? null },
944
+ );
945
+
946
+ const publicUrl =
947
+ stringValue(deployment.public_url) ?? stringValue(metadata.public_url);
796
948
  addDoctorCheck(
797
949
  checks,
798
- "runtime_route",
799
- hasRuntimeRoute,
800
- hasRuntimeRoute
801
- ? "Deployment has appliance runtime route metadata."
802
- : "Deployment is missing appliance runtime route metadata.",
803
- typeof runtime === "object" && runtime !== null
804
- ? (runtime as Record<string, unknown>)
805
- : undefined,
950
+ "public_url",
951
+ !!publicUrl,
952
+ publicUrl
953
+ ? "Deployment has a public URL."
954
+ : "Deployment has no public URL to probe.",
955
+ { public_url: publicUrl ?? null },
806
956
  );
807
957
 
808
958
  let probe: DockerDeployDoctorProbe | undefined;
809
- const publicUrl = deployment.public_url;
810
- const path = params.probePath ?? params.probe_path ?? "/";
811
- if (publicUrl && typeof fetch === "function") {
812
- const url = probeUrl(publicUrl, path);
959
+ if (params.probe !== false && publicUrl) {
960
+ const fetchImpl = params.fetchImpl ?? fetch;
961
+ const url = publicProbeUrl(publicUrl, params.probePath ?? "/");
813
962
  const controller = new AbortController();
814
- const timeout = setTimeout(
815
- () => controller.abort(),
816
- params.timeoutMs ?? params.timeout_ms ?? 10_000,
817
- );
963
+ const timer = setTimeout(() => controller.abort(), params.timeoutMs ?? 20_000);
818
964
  try {
819
- const response = await fetch(url, {
820
- method: "GET",
965
+ const response = await fetchImpl(url, {
966
+ headers: { Accept: "*/*" },
821
967
  signal: controller.signal,
822
968
  });
823
- probe = { url, ok: response.ok, status: response.status };
969
+ const text = await response.text();
970
+ const gatewayJson = looksLikeMiosaGatewayJson(text);
971
+ const ok = response.ok && !gatewayJson;
972
+ probe = {
973
+ url,
974
+ status: response.status,
975
+ ok,
976
+ responseSnippet: text.slice(0, 500),
977
+ };
978
+ if (gatewayJson) probe.gatewayJson = true;
824
979
  addDoctorCheck(
825
980
  checks,
826
981
  "public_url_probe",
827
- response.ok,
828
- response.ok
982
+ ok,
983
+ ok
829
984
  ? `Public URL returned HTTP ${response.status}.`
830
- : `Public URL returned HTTP ${response.status}.`,
831
- { url, status: response.status },
985
+ : gatewayJson
986
+ ? "Public URL returned MIOSA gateway JSON instead of the app."
987
+ : `Public URL returned HTTP ${response.status}.`,
988
+ { status: response.status, gateway_json: gatewayJson },
832
989
  );
833
- } catch (error) {
834
- probe = {
835
- url,
836
- ok: false,
837
- error: error instanceof Error ? error.message : String(error),
838
- };
990
+ } catch (err) {
991
+ const message = err instanceof Error ? err.message : String(err);
992
+ probe = { url, status: null, ok: false, responseSnippet: message };
839
993
  addDoctorCheck(
840
994
  checks,
841
995
  "public_url_probe",
842
996
  false,
843
- probe.error ?? "Public URL probe failed.",
844
- { url },
997
+ `Public URL probe failed: ${message}`,
845
998
  );
846
999
  } finally {
847
- clearTimeout(timeout);
1000
+ clearTimeout(timer);
848
1001
  }
849
1002
  }
850
1003
 
851
- return {
1004
+ const result: DockerDeployDoctorResult = {
852
1005
  ok: checks.every((check) => check.ok),
853
1006
  deployment,
854
- ...(host ? { host } : {}),
855
1007
  checks,
856
- ...(probe ? { probe } : {}),
857
1008
  };
1009
+ if (host) result.host = host;
1010
+ if (publicUrl !== undefined) result.publicUrl = publicUrl;
1011
+ if (probe) result.probe = probe;
1012
+ return result;
1013
+ }
1014
+
1015
+ async listDockerDeployTemplates(
1016
+ params: DockerDeployTemplateListParams = {},
1017
+ ): Promise<DockerDeployTemplateData[]> {
1018
+ const query: Record<string, string | number | boolean | undefined> = {
1019
+ category: params.category,
1020
+ runtime: params.runtime,
1021
+ framework: params.framework,
1022
+ include_preview: params.includePreview ?? params.include_preview,
1023
+ };
1024
+ const data = await this.http.get<unknown>(
1025
+ "/docker-deploy/templates",
1026
+ stripUndefined(query) as Record<string, string | number | boolean | undefined>,
1027
+ );
1028
+ const templates = unwrap<any>(data);
1029
+ if (Array.isArray(templates)) return templates as DockerDeployTemplateData[];
1030
+ return ((templates?.templates ?? templates?.items ?? templates?.data ?? []) ||
1031
+ []) as DockerDeployTemplateData[];
1032
+ }
1033
+
1034
+ async getDockerDeployTemplate(
1035
+ templateId: string,
1036
+ ): Promise<DockerDeployTemplateData> {
1037
+ const data = await this.http.get<unknown>(
1038
+ `/docker-deploy/templates/${templateId}`,
1039
+ );
1040
+ return unwrap(data) as DockerDeployTemplateData;
858
1041
  }
859
1042
 
860
1043
  async update(
@@ -888,6 +1071,15 @@ export class Deployments {
888
1071
  output_path: params.outputPath ?? params.output_path,
889
1072
  entrypoint: params.entrypoint,
890
1073
  promote: params.promote,
1074
+ build_command: params.buildCommand ?? params.build_command,
1075
+ run_command: params.runCommand ?? params.run_command,
1076
+ port: params.port,
1077
+ health_check_path: params.healthCheckPath ?? params.health_check_path,
1078
+ deployment_type: params.deploymentType ?? params.deployment_type,
1079
+ docker_deploy_template_id:
1080
+ params.dockerDeployTemplateId ?? params.docker_deploy_template_id,
1081
+ data_services: params.dataServices ?? params.data_services,
1082
+ ...attributionBody(params),
891
1083
  });
892
1084
  const data = await this.http.request<unknown>(
893
1085
  `/deployments/${deploymentId}/publish`,
@@ -918,6 +1110,15 @@ export class Deployments {
918
1110
  entrypoint: params.entrypoint,
919
1111
  domain: params.domain,
920
1112
  custom_domain: params.customDomain ?? params.custom_domain,
1113
+ build_command: params.buildCommand ?? params.build_command,
1114
+ run_command: params.runCommand ?? params.run_command,
1115
+ port: params.port,
1116
+ health_check_path: params.healthCheckPath ?? params.health_check_path,
1117
+ deployment_type: params.deploymentType ?? params.deployment_type,
1118
+ docker_deploy_template_id:
1119
+ params.dockerDeployTemplateId ?? params.docker_deploy_template_id,
1120
+ data_services: params.dataServices ?? params.data_services,
1121
+ ...attributionBody(params),
921
1122
  });
922
1123
  const data = await this.http.request<unknown>(
923
1124
  `/sandboxes/${sandboxId}/deploy`,
@@ -0,0 +1,92 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { HttpClient } from "../http.js";
3
+ import { Devices } from "./devices.js";
4
+
5
+ const mockGet = vi.fn();
6
+
7
+ function makeHttp(): HttpClient {
8
+ const http = {} as HttpClient;
9
+ http.get = mockGet;
10
+ return http;
11
+ }
12
+
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ });
16
+
17
+ describe("Devices", () => {
18
+ it("returns the static agent device catalog", () => {
19
+ const catalog = new Devices(makeHttp()).catalog();
20
+
21
+ expect(catalog.map((device) => device.kind)).toEqual(
22
+ expect.arrayContaining([
23
+ "sandbox_worker",
24
+ "computer",
25
+ "local_device",
26
+ "docker_deploy_host",
27
+ ]),
28
+ );
29
+ expect(catalog.find((device) => device.kind === "sandbox_worker")
30
+ ?.primaryCommands).toEqual(
31
+ expect.arrayContaining([
32
+ expect.stringContaining("sandboxes.create"),
33
+ expect.stringContaining("sandbox.exec"),
34
+ ]),
35
+ );
36
+ });
37
+
38
+ it("normalizes sandboxes and computers into one device list", async () => {
39
+ mockGet
40
+ .mockResolvedValueOnce({
41
+ data: [
42
+ {
43
+ id: "sbx_123",
44
+ name: "builder",
45
+ state: "running",
46
+ ready: true,
47
+ persistent: true,
48
+ preview_url: "https://3000-sbx_123.sandbox.miosa.app",
49
+ },
50
+ ],
51
+ })
52
+ .mockResolvedValueOnce({
53
+ data: [
54
+ {
55
+ id: "cmp_123",
56
+ name: "desktop",
57
+ status: "running",
58
+ region: "us-nyc",
59
+ },
60
+ ],
61
+ });
62
+
63
+ const result = await new Devices(makeHttp()).list();
64
+
65
+ expect(mockGet).toHaveBeenNthCalledWith(1, "/sandboxes");
66
+ expect(mockGet).toHaveBeenNthCalledWith(2, "/computers");
67
+ expect(result.errors).toEqual([]);
68
+ expect(result.devices).toEqual(
69
+ expect.arrayContaining([
70
+ expect.objectContaining({ id: "sbx_123", kind: "sandbox_worker" }),
71
+ expect.objectContaining({ id: "cmp_123", kind: "computer" }),
72
+ ]),
73
+ );
74
+ });
75
+
76
+ it("returns partial device inventory when one backing endpoint fails", async () => {
77
+ mockGet
78
+ .mockResolvedValueOnce({
79
+ data: [{ id: "sbx_123", state: "running" }],
80
+ })
81
+ .mockRejectedValueOnce(new Error("HTTP 502"));
82
+
83
+ const result = await new Devices(makeHttp()).list();
84
+
85
+ expect(result.devices).toEqual([
86
+ expect.objectContaining({ id: "sbx_123", kind: "sandbox_worker" }),
87
+ ]);
88
+ expect(result.errors).toEqual([
89
+ expect.objectContaining({ source: "computers", retryable: true }),
90
+ ]);
91
+ });
92
+ });