1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
5import * as speech from "@distilled.cloud/gcp/speech_v1";
6import { describe, expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import { dockerAvailable, expectProbe } from "../bindingHost.ts";
9import SpeechBindingsHost, { Hints, Ships } from "./fixtures/bindings-host.ts";
10
11const testOptions = { providers: GCP.providers() };
12const { test, beforeAll, afterAll } = Test.make(testOptions);
13const sharedStack = Core.scratchStack(testOptions, "SpeechBindings");
14
15let baseUrl: string;
16let hostAccount: string;
17let project: string;
18let customClassName: string;
19let phraseSetName: string;
20
21/**
22 * Project-level roles (with their IAM Condition, if any) held by the
23 * host's service account. Speech-to-Text has no resource-level IAM.
24 */
25const projectRoles = () =>
26 resourcemanager
27 .getIamPolicyProjects({
28 resource: `projects/${project}`,
29 body: { options: { requestedPolicyVersion: 3 } },
30 })
31 .pipe(
32 Effect.map((policy) =>
33 (policy.bindings ?? [])
34 .filter((binding) =>
35 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
36 )
37 .map((binding) => ({
38 role: binding.role,
39 condition: binding.condition?.expression,
40 })),
41 ),
42 );
43
44describe.skipIf(!dockerAvailable)(
45 "Speech Bindings",
46 {
47 tags: ["provider:gcp", "provider:gcp:speech", "provider:gcp:run", "live"],
48 },
49 () => {
50 beforeAll(
51 Effect.gen(function* () {
52 yield* sharedStack.destroy();
53 const out = yield* sharedStack.deploy(
54 Effect.gen(function* () {
55 const host = yield* SpeechBindingsHost;
56 const ships = yield* Ships;
57 const hints = yield* Hints;
58 return {
59 uri: host.uri,
60 serviceAccount: host.serviceAccount,
61 project: host.project,
62 customClass: ships.name,
63 phraseSet: hints.name,
64 };
65 }),
66 );
67 baseUrl = out.uri!;
68 hostAccount = out.serviceAccount!;
69 project = out.project;
70 customClassName = out.customClass;
71 phraseSetName = out.phraseSet;
72 }),
73 { timeout: 900_000 },
74 );
75
76 afterAll(sharedStack.destroy(), { timeout: 600_000 });
77
78 // Every Speech binding grants the same unconditioned project role.
79 const expectSpeechClientOnly = Effect.gen(function* () {
80 expect(yield* projectRoles()).toEqual([
81 { role: "roles/speech.client", condition: undefined },
82 ]);
83 });
84
85 describe("GetCustomClass", () => {
86 test.provider(
87 "reads the bound custom class as the host",
88 (_stack) =>
89 Effect.gen(function* () {
90 const out = yield* expectProbe<{ name: string; items: string[] }>(
91 baseUrl,
92 "getCustomClass",
93 );
94 expect(out.name).toEqual(customClassName);
95 expect(out.items).toEqual(["sloop", "schooner"]);
96
97 const live = yield* speech.getProjectsLocationsCustomClasses({
98 name: customClassName,
99 });
100 expect(live.name).toEqual(out.name);
101 yield* expectSpeechClientOnly;
102 }),
103 {
104 tags: ["provider:gcp", "provider:gcp:speech", "live"],
105 timeout: 600_000,
106 },
107 );
108 });
109
110 describe("GetPhraseSet", () => {
111 test.provider(
112 "reads the bound phrase set as the host",
113 (_stack) =>
114 Effect.gen(function* () {
115 const out = yield* expectProbe<{
116 name: string;
117 phrases: string[];
118 }>(baseUrl, "getPhraseSet");
119 expect(out.name).toEqual(phraseSetName);
120 expect(out.phrases).toEqual(["weather"]);
121
122 const live = yield* speech.getProjectsLocationsPhraseSets({
123 name: phraseSetName,
124 });
125 expect(live.name).toEqual(out.name);
126 yield* expectSpeechClientOnly;
127 }),
128 {
129 tags: ["provider:gcp", "provider:gcp:speech", "live"],
130 timeout: 600_000,
131 },
132 );
133 });
134
135 describe("Recognize", () => {
136 test.provider(
137 "recognizes audio adapted with the bound phrase set as the host",
138 (_stack) =>
139 Effect.gen(function* () {
140 const out = yield* expectProbe<{
141 results: unknown[];
142 totalBilledTime: string;
143 }>(baseUrl, "recognize");
144 // Silence has nothing to transcribe, but the request is billed.
145 expect(out.results).toEqual([]);
146 expect(out.totalBilledTime).toMatch(/^\d+(\.\d+)?s$/);
147 yield* expectSpeechClientOnly;
148 }),
149 {
150 tags: ["provider:gcp", "provider:gcp:speech", "live"],
151 timeout: 600_000,
152 },
153 );
154 });
155 },
156);