@alexkroman1/aai-cli 6.11.0 → 7.0.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/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-B4RqNF5Z.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-CiKFH9qw.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-CE4HKdgV.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-nRFaBEPJ.mjs} +2 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-FI3xPEMj.mjs} +1 -1
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-6jfaakgc.mjs} +5 -2
- package/dist/cli.mjs +30 -25
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DvYeD8ia.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-B59JIfWO.mjs} +1 -1
- package/dist/{eject-31gjtaHF.mjs → eject-C9WJyyr2.mjs} +1 -1
- package/dist/{init-l6xfU4xX.mjs → init-CpJVjEg0.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-9q-psa4q.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +40 -19
- package/dist/scaffold/package.json +5 -3
- package/dist/scaffold/server.mjs +1 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-CVvSLIDV.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-BvUrnvM3.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio--MUV0cid.mjs} +4 -4
- package/dist/templates/call-audit/agent.test.ts +57 -204
- package/dist/templates/call-audit/agent.ts +32 -19
- package/dist/templates/call-audit/client.tsx +17 -54
- package/dist/templates/call-audit/workflows/audit.ts +11 -18
- package/dist/templates/call-audit/workflows/ingest.ts +106 -114
- package/dist/templates/call-audit/workflows/media.ts +2 -12
- package/dist/templates/call-audit/workflows/summarize.ts +47 -52
- package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
- package/dist/templates/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.test.ts +4 -6
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- package/dist/templates/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- package/dist/templates/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- package/dist/templates/pizza-ordering/agent.test.ts +27 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- package/dist/templates/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.test.ts +746 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +411 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +209 -0
- package/dist/templates/recap-workflow/agent.test.ts +97 -94
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- package/dist/templates/retail/agent.test.ts +26 -23
- package/dist/templates/retail/client.tsx +226 -117
- package/dist/templates/retail/registry.test.ts +38 -6
- package/dist/templates/retail/store.test.ts +82 -15
- package/dist/templates/retail/store.ts +174 -47
- package/dist/templates/retail/system-prompt.md +11 -2
- package/dist/templates/retail/tools/cancel_pending_order.ts +2 -2
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +2 -2
- package/dist/templates/retail/tools/modify_pending_order_items.ts +2 -3
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +2 -2
- package/dist/templates/retail/tools/modify_user_address.ts +2 -2
- package/dist/templates/retail/tools/return_delivered_order_items.ts +2 -2
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +8 -4
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.test.ts +68 -97
- package/dist/templates/spoken-summary/agent.ts +30 -17
- package/dist/templates/spoken-summary/client.tsx +10 -50
- package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
- package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/tools/answer_question.ts +17 -4
- package/dist/templates/transcription-workflow/agent.test.ts +94 -194
- package/dist/templates/transcription-workflow/agent.ts +1 -1
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
- package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
- package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
- package/dist/templates/travel-concierge/agent.test.ts +64 -33
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +70 -3
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_excursion.ts +2 -2
- package/dist/templates/travel-concierge/tools/book_hotel.ts +2 -2
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +2 -2
- package/dist/templates/travel-concierge/tools/update_ticket.ts +2 -2
- package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-COxnqstQ.mjs} +221 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-D2AQf2Pl.mjs} +28 -1
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* What this template promises, checked without a network.
|
|
4
|
+
*
|
|
5
|
+
* Three kinds of thing are checked, and the workflow BODY is none of them: it is
|
|
6
|
+
* only durable once the Workflow DevKit's build has transformed it, so a unit
|
|
7
|
+
* test of it would exercise a plain async function and prove nothing about
|
|
8
|
+
* replay. What IS here:
|
|
9
|
+
*
|
|
10
|
+
* - The DECLARATION — the config a deploy validates and the schema a `start()`
|
|
11
|
+
* is checked against.
|
|
12
|
+
* - The PURE helpers, pulled out of the flow for exactly this reason.
|
|
13
|
+
* - The STEPS, directly. Imported with no bundler in the path a `"use step"`
|
|
14
|
+
* function is an ordinary async function, so its HTTP handling, its
|
|
15
|
+
* partial-failure policy and its `FatalError` guards are all reachable —
|
|
16
|
+
* `installStubStepFetch` answers the network and `stubGateway` answers the model.
|
|
17
|
+
*
|
|
18
|
+
* The cases worth having are the ones where a mistake is SILENT: a schema that
|
|
19
|
+
* accepts a webhook pointing anywhere, a Slack payload in the shape the other
|
|
20
|
+
* kind of URL wants, feed parsing that quietly drops episodes, and an episode
|
|
21
|
+
* that fails to transcribe taking the whole digest down with it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { parseSchemaInput, schemaInputIssues } from "@alexkroman1/aai/testing";
|
|
25
|
+
import {
|
|
26
|
+
installStubStepFetch,
|
|
27
|
+
installStubGateway as stubGateway,
|
|
28
|
+
} from "@alexkroman1/aai/testing/vitest";
|
|
29
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
30
|
+
import agentDef, { dailyDigest } from "./agent.ts";
|
|
31
|
+
import {
|
|
32
|
+
formatScheduleInterval,
|
|
33
|
+
pollTranscript,
|
|
34
|
+
scheduleIntervalMs,
|
|
35
|
+
submitTranscript,
|
|
36
|
+
summarizeTranscript,
|
|
37
|
+
timestamp,
|
|
38
|
+
} from "./workflows/digest.ts";
|
|
39
|
+
import {
|
|
40
|
+
discoverEpisodes,
|
|
41
|
+
discoverFeedUrl,
|
|
42
|
+
episodeFromItem,
|
|
43
|
+
extractAppleSerializedFeed,
|
|
44
|
+
isApplePodcastUrl,
|
|
45
|
+
isSpotifyShowUrl,
|
|
46
|
+
looksLikePodcastFeed,
|
|
47
|
+
parsePodcastChannels,
|
|
48
|
+
stableEpisodeId,
|
|
49
|
+
titleMatchesSpotify,
|
|
50
|
+
} from "./workflows/feeds.ts";
|
|
51
|
+
import {
|
|
52
|
+
escapeSlack,
|
|
53
|
+
isSlackWebhookUrl,
|
|
54
|
+
isSlackWorkflowTriggerUrl,
|
|
55
|
+
renderPlainTextDigest,
|
|
56
|
+
renderSlackPayload,
|
|
57
|
+
sendDigestToSlack,
|
|
58
|
+
slackAdvice,
|
|
59
|
+
} from "./workflows/slack.ts";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Validate through the SDK's reader, as `start()` does.
|
|
63
|
+
*
|
|
64
|
+
* `schemaInputIssues` / `parseSchemaInput` rather than a reach through
|
|
65
|
+
* `["~standard"].validate`: that is the vendor WIRE contract, and whether it
|
|
66
|
+
* answers synchronously or with a promise is the vendor's business — a missing
|
|
67
|
+
* `await` there leaves `.issues` undefined and every rejecting case below passes
|
|
68
|
+
* for the wrong reason.
|
|
69
|
+
*/
|
|
70
|
+
const issues = (input: unknown) => schemaInputIssues(dailyDigest.input, input, "dailyDigest");
|
|
71
|
+
|
|
72
|
+
/** A complete, valid input — each case below changes one field of it. */
|
|
73
|
+
const VALID = {
|
|
74
|
+
podcastChannels: "https://example.com/feed.xml",
|
|
75
|
+
slackWebhookUrl: "https://hooks.slack.com/services/T000/B000/abc123",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const EPISODE = {
|
|
79
|
+
id: "episode-1",
|
|
80
|
+
feedUrl: "https://example.com/feed.xml",
|
|
81
|
+
podcastTitle: "Example Podcast",
|
|
82
|
+
title: "Example Episode",
|
|
83
|
+
url: "https://example.com/episode",
|
|
84
|
+
audioUrl: "https://example.com/audio.mp3",
|
|
85
|
+
published: "Fri, 21 Aug 2026 00:00:00 GMT",
|
|
86
|
+
transcriptSource: "assemblyai" as const,
|
|
87
|
+
summary: "A concise episode summary.",
|
|
88
|
+
keyPoints: ["First point", "Second point"],
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const slackInput = (slackWebhookUrl: string) => ({
|
|
92
|
+
slackWebhookUrl,
|
|
93
|
+
slackWorkflowTextParam: "text",
|
|
94
|
+
podcastChannels: "https://example.com/feed.xml",
|
|
95
|
+
episodes: [EPISODE],
|
|
96
|
+
digestNumber: 1,
|
|
97
|
+
totalDigests: 2,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("the declaration", () => {
|
|
101
|
+
test("is a workflow app with one workflow and a static page", () => {
|
|
102
|
+
expect(agentDef.name).toBe("Podcast Digest");
|
|
103
|
+
expect(agentDef.page).toBe("static");
|
|
104
|
+
expect(Object.keys(agentDef.workflows ?? {})).toEqual(["dailyDigest"]);
|
|
105
|
+
expect(agentDef.workflows?.dailyDigest).toBe(dailyDigest);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("names the one credential its steps read", () => {
|
|
109
|
+
// A workflow app declares no providers, so nothing else in the config can
|
|
110
|
+
// name this — which is what makes a deploy able to check for it.
|
|
111
|
+
expect(agentDef.requiredEnv).toEqual(["ASSEMBLYAI_API_KEY"]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("input schema", () => {
|
|
116
|
+
test("accepts a minimal input and applies every default", async () => {
|
|
117
|
+
// `parseSchemaInput` throws naming every issue, so the failure arm needs no
|
|
118
|
+
// hand-written narrowing and a refusal reports WHAT was refused.
|
|
119
|
+
//
|
|
120
|
+
// These defaults are no longer restated anywhere: `DigestInput` is
|
|
121
|
+
// `WorkflowInputOf<typeof dailyDigest>`, the schema's OUTPUT, so the body
|
|
122
|
+
// reads them already applied instead of re-implementing each one with `??`.
|
|
123
|
+
const parsed = await parseSchemaInput(dailyDigest.input, VALID, "dailyDigest");
|
|
124
|
+
expect(parsed).toMatchObject({
|
|
125
|
+
slackWorkflowTextParam: "text",
|
|
126
|
+
maxEpisodesPerDigest: 5,
|
|
127
|
+
intervalEvery: 1,
|
|
128
|
+
intervalUnit: "days",
|
|
129
|
+
daysToRun: 7,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("trims the webhook before validating it", async () => {
|
|
134
|
+
expect(
|
|
135
|
+
await issues({
|
|
136
|
+
...VALID,
|
|
137
|
+
slackWebhookUrl: " https://hooks.slack.com/services/T000/B000/abc123 ",
|
|
138
|
+
}),
|
|
139
|
+
).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test.each([
|
|
143
|
+
["a workflow trigger", "https://hooks.slack.com/triggers/T000/B000/abc"],
|
|
144
|
+
["the gov host", "https://hooks.slack-gov.com/services/T000/B000/abc"],
|
|
145
|
+
])("accepts %s", async (_label, slackWebhookUrl) => {
|
|
146
|
+
expect(await issues({ ...VALID, slackWebhookUrl })).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The one that matters. This value becomes the target of a POST carrying
|
|
151
|
+
* summarized content, so a schema accepting any URL is an exfiltration
|
|
152
|
+
* endpoint somebody can type into a form.
|
|
153
|
+
*/
|
|
154
|
+
test.each([
|
|
155
|
+
["a non-Slack host", "https://example.com/services/T000/B000/abc"],
|
|
156
|
+
["a lookalike host", "https://hooks.slack.com.evil.test/services/abc"],
|
|
157
|
+
["plain http", "http://hooks.slack.com/services/T000/B000/abc"],
|
|
158
|
+
["no path", "https://hooks.slack.com"],
|
|
159
|
+
])("rejects %s as a webhook", async (_label, slackWebhookUrl) => {
|
|
160
|
+
expect(await issues({ ...VALID, slackWebhookUrl })).toBeDefined();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test.each([
|
|
164
|
+
["a non-URL channel", { podcastChannels: "not a url" }],
|
|
165
|
+
["one bad entry among good ones", { podcastChannels: "https://a.test/f.xml, nope" }],
|
|
166
|
+
["an empty channel list", { podcastChannels: "" }],
|
|
167
|
+
["a variable name that is not an identifier", { slackWorkflowTextParam: "2 text!" }],
|
|
168
|
+
["more episodes than allowed", { maxEpisodesPerDigest: 21 }],
|
|
169
|
+
["a fractional episode count", { maxEpisodesPerDigest: 1.5 }],
|
|
170
|
+
["more digests than allowed", { daysToRun: 31 }],
|
|
171
|
+
["an unknown interval unit", { intervalUnit: "fortnights" }],
|
|
172
|
+
])("rejects %s", async (_label, patch) => {
|
|
173
|
+
expect(await issues({ ...VALID, ...patch })).toBeDefined();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("podcast links", () => {
|
|
178
|
+
test("splits, trims, and keeps the first of any duplicate", () => {
|
|
179
|
+
expect(
|
|
180
|
+
parsePodcastChannels(" https://one.test/rss,https://two.test/feed, https://one.test/rss "),
|
|
181
|
+
).toEqual(["https://one.test/rss", "https://two.test/feed"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test.each([
|
|
185
|
+
["https://podcasts.apple.com/us/podcast/example/id1473872585", true],
|
|
186
|
+
["https://podcasts.apple.com/us/podcast/example", false],
|
|
187
|
+
["https://example.com/us/podcast/id123", false],
|
|
188
|
+
// A host that merely ENDS with the domain is a different host.
|
|
189
|
+
["https://evilpodcasts.apple.com/us/podcast/id123", false],
|
|
190
|
+
["not a url", false],
|
|
191
|
+
])("isApplePodcastUrl(%s) === %s", (url, expected) => {
|
|
192
|
+
expect(isApplePodcastUrl(url)).toBe(expected);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/** The replacement for the `spotify-uri` dependency — see `feeds.ts`. */
|
|
196
|
+
test.each([
|
|
197
|
+
["https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk", true],
|
|
198
|
+
["https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk?si=abc", true],
|
|
199
|
+
["https://open.spotify.com/episode/4rOoJ6Egrf8K2IrywzwOMk", false],
|
|
200
|
+
["https://open.spotify.com/show/", false],
|
|
201
|
+
["https://notspotify.com/show/abc", false],
|
|
202
|
+
["not a url", false],
|
|
203
|
+
])("isSpotifyShowUrl(%s) === %s", (url, expected) => {
|
|
204
|
+
expect(isSpotifyShowUrl(url)).toBe(expected);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("reading a feed", () => {
|
|
209
|
+
const ITEM =
|
|
210
|
+
"<item><title>An Episode</title><link>https://example.com/ep</link>" +
|
|
211
|
+
'<guid isPermaLink="false">guid-1</guid><pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>' +
|
|
212
|
+
'<enclosure url="https://example.com/a.mp3" type="audio/mpeg"/></item>';
|
|
213
|
+
|
|
214
|
+
test("reads the fields a digest needs off an item", () => {
|
|
215
|
+
expect(episodeFromItem(ITEM, "https://example.com/feed.xml", "Show", 0)).toMatchObject({
|
|
216
|
+
title: "An Episode",
|
|
217
|
+
url: "https://example.com/ep",
|
|
218
|
+
audioUrl: "https://example.com/a.mp3",
|
|
219
|
+
published: "Fri, 21 Aug 2026 00:00:00 GMT",
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
/** A feed legitimately mixes text posts in; there is nothing to transcribe. */
|
|
224
|
+
test("drops an item with no audio rather than inventing a URL", () => {
|
|
225
|
+
const noAudio = "<item><title>A Post</title><link>https://example.com/p</link></item>";
|
|
226
|
+
expect(episodeFromItem(noAudio, "https://example.com/feed.xml", "Show", 0)).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("gives the same episode the same id on every run", () => {
|
|
230
|
+
// Replay depends on this: a fresh id per run would remount every card.
|
|
231
|
+
const first = episodeFromItem(ITEM, "https://example.com/feed.xml", "Show", 0);
|
|
232
|
+
const again = episodeFromItem(ITEM, "https://example.com/feed.xml", "Show", 0);
|
|
233
|
+
expect(first?.id).toBe(again?.id);
|
|
234
|
+
expect(stableEpisodeId("a", "b")).not.toBe(stableEpisodeId("a", "c"));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("requires BOTH an rss root and an enclosure to call it a podcast feed", () => {
|
|
238
|
+
expect(looksLikePodcastFeed('<rss><enclosure url="https://a.test/a.mp3"/></rss>')).toBe(true);
|
|
239
|
+
// A blog feed: RSS, but nothing to transcribe.
|
|
240
|
+
expect(looksLikePodcastFeed("<rss><item><title>Post</title></item></rss>")).toBe(false);
|
|
241
|
+
expect(looksLikePodcastFeed("<html></html>")).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("finds an advertised feed in either attribute order, and resolves it", () => {
|
|
245
|
+
const hrefFirst = '<link href="/feed.xml" type="application/rss+xml">';
|
|
246
|
+
const typeFirst = '<link type="application/rss+xml" href="/feed.xml">';
|
|
247
|
+
for (const html of [hrefFirst, typeFirst]) {
|
|
248
|
+
expect(discoverFeedUrl(html, "https://example.com/show")).toBe(
|
|
249
|
+
"https://example.com/feed.xml",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
expect(discoverFeedUrl("<html></html>", "https://example.com/show")).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("extracts an Apple feed URL, and only for the show that was asked for", () => {
|
|
256
|
+
const html =
|
|
257
|
+
'<script id="serialized-server-data">' +
|
|
258
|
+
'{"data":[{"model":{"adamId":"999","feedUrl":"https://other.test/feed"}},' +
|
|
259
|
+
'{"model":{"adamId":"1473872585","title":"Apple News Today","feedUrl":"https://apple.test/feed"}}]}' +
|
|
260
|
+
"</script>";
|
|
261
|
+
expect(extractAppleSerializedFeed(html, "1473872585")).toEqual({
|
|
262
|
+
feedUrl: "https://apple.test/feed",
|
|
263
|
+
title: "Apple News Today",
|
|
264
|
+
});
|
|
265
|
+
expect(extractAppleSerializedFeed(html, "0000")).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("survives a page with no serialized blob, or an unparseable one", () => {
|
|
269
|
+
expect(extractAppleSerializedFeed("<html></html>", "1")).toBeUndefined();
|
|
270
|
+
expect(
|
|
271
|
+
extractAppleSerializedFeed('<script id="serialized-server-data">{not json</script>', "1"),
|
|
272
|
+
).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("refuses a Spotify match that is merely a near neighbour", () => {
|
|
276
|
+
expect(titleMatchesSpotify("The Daily", "The New York Times", { title: "The Daily" })).toBe(
|
|
277
|
+
true,
|
|
278
|
+
);
|
|
279
|
+
expect(
|
|
280
|
+
titleMatchesSpotify("The Daily", "The New York Times", {
|
|
281
|
+
title: "The Daily",
|
|
282
|
+
artist: "The New York Times",
|
|
283
|
+
}),
|
|
284
|
+
).toBe(true);
|
|
285
|
+
// A different show whose title merely resembles it.
|
|
286
|
+
expect(
|
|
287
|
+
titleMatchesSpotify("The Daily", "The New York Times", { title: "Daily Tech News" }),
|
|
288
|
+
).toBe(false);
|
|
289
|
+
expect(titleMatchesSpotify("The Daily", "", {})).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("Slack delivery", () => {
|
|
294
|
+
test.each([
|
|
295
|
+
["https://hooks.slack.com/triggers/E0A/118/abc", true],
|
|
296
|
+
["https://hooks.slack.com/services/T000/B000/abc", false],
|
|
297
|
+
["https://example.com/triggers/T000/B000/abc", false],
|
|
298
|
+
])("isSlackWorkflowTriggerUrl(%s) === %s", (url, expected) => {
|
|
299
|
+
expect(isSlackWorkflowTriggerUrl(url)).toBe(expected);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("the host check and the schema agree on what a webhook is", () => {
|
|
303
|
+
expect(isSlackWebhookUrl("https://hooks.slack.com/services/T/B/a")).toBe(true);
|
|
304
|
+
expect(isSlackWebhookUrl("https://hooks.slack.com.evil.test/services/a")).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
/** The distinction the module exists for — the two URLs take different bodies. */
|
|
308
|
+
test("sends Block Kit to an incoming webhook", () => {
|
|
309
|
+
const payload = renderSlackPayload(slackInput("https://hooks.slack.com/services/T/B/a"));
|
|
310
|
+
expect(payload).toHaveProperty("blocks");
|
|
311
|
+
// Without `text`, Slack notifies as "[no preview]".
|
|
312
|
+
expect(payload.text).toContain("Podcast digest 1/2");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("sends flat variables to a workflow trigger, under the configured name", () => {
|
|
316
|
+
const payload = renderSlackPayload({
|
|
317
|
+
...slackInput("https://hooks.slack.com/triggers/T/B/a"),
|
|
318
|
+
slackWorkflowTextParam: "digest_body",
|
|
319
|
+
});
|
|
320
|
+
expect(Object.keys(payload)).toEqual(["digest_body"]);
|
|
321
|
+
expect(payload).not.toHaveProperty("blocks");
|
|
322
|
+
expect(String(payload.digest_body)).toContain("Podcast digest 1/2");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("a trigger body carries every episode's summary and points", () => {
|
|
326
|
+
const text = renderPlainTextDigest(slackInput("https://hooks.slack.com/triggers/T/B/a"));
|
|
327
|
+
expect(text).toContain("Example Episode");
|
|
328
|
+
expect(text).toContain("A concise episode summary.");
|
|
329
|
+
expect(text).toContain("- First point");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("escapes only Slack's three reserved characters, ampersand first", () => {
|
|
333
|
+
expect(escapeSlack("Tom & Jerry <b>")).toBe("Tom & Jerry <b>");
|
|
334
|
+
// Apostrophes are not reserved — escaping them would litter every summary.
|
|
335
|
+
expect(escapeSlack("it's fine")).toBe("it's fine");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("names the unpublished-workflow case, which no generic message explains", () => {
|
|
339
|
+
const advice = slackAdvice("https://hooks.slack.com/triggers/T/B/a", "workflow_not_published");
|
|
340
|
+
expect(advice).toContain("not published");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("tells an incoming-webhook caller to check the webhook, not the workflow", () => {
|
|
344
|
+
const advice = slackAdvice("https://hooks.slack.com/services/T/B/a", "invalid_payload");
|
|
345
|
+
expect(advice).toContain("revoked");
|
|
346
|
+
expect(advice).not.toContain("workflow");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("the repeat schedule", () => {
|
|
351
|
+
test("prints an interval a person can read, singular at one", () => {
|
|
352
|
+
expect(formatScheduleInterval(1, "hours")).toBe("1 hour");
|
|
353
|
+
expect(formatScheduleInterval(15, "minutes")).toBe("15 minutes");
|
|
354
|
+
expect(formatScheduleInterval(2, "days")).toBe("2 days");
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("converts an interval to the milliseconds a durable sleep takes", () => {
|
|
358
|
+
expect(scheduleIntervalMs(15, "minutes")).toBe(900_000);
|
|
359
|
+
expect(scheduleIntervalMs(6, "hours")).toBe(21_600_000);
|
|
360
|
+
expect(scheduleIntervalMs(2, "days")).toBe(172_800_000);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ---- The steps ---------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A feed body. `looksLikePodcastFeed` wants BOTH an `<rss` root and an
|
|
368
|
+
* `<enclosure url=`, so these fixtures carry both unless a case is about not.
|
|
369
|
+
*/
|
|
370
|
+
const feedXml = (items: string, title = "Example Show") =>
|
|
371
|
+
`<?xml version="1.0"?><rss><channel><title>${title}</title>${items}</channel></rss>`;
|
|
372
|
+
|
|
373
|
+
const itemXml = (title: string, pubDate: string, audio = `https://cdn.test/${title}.mp3`) =>
|
|
374
|
+
`<item><title>${title}</title><link>https://show.test/${title}</link>` +
|
|
375
|
+
`<guid>${title}</guid><pubDate>${pubDate}</pubDate>` +
|
|
376
|
+
`<enclosure url="${audio}" type="audio/mpeg"/></item>`;
|
|
377
|
+
|
|
378
|
+
const THREE_EPISODES = feedXml(
|
|
379
|
+
itemXml("oldest", "Tue, 01 Jan 2030 00:00:00 GMT") +
|
|
380
|
+
itemXml("newest", "Thu, 03 Jan 2030 00:00:00 GMT") +
|
|
381
|
+
itemXml("middle", "Wed, 02 Jan 2030 00:00:00 GMT"),
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Route by URL, so one stub can answer a whole resolution chain.
|
|
386
|
+
*
|
|
387
|
+
* `installStubStepFetch` unpublishes on `onTestFinished`, which is why no
|
|
388
|
+
* describe block below keeps a `restore` of its own — a hand-kept registry is
|
|
389
|
+
* the thing that forgets, and a step fetch left published reaches the next file.
|
|
390
|
+
*/
|
|
391
|
+
function stubRoutes(routes: Record<string, { status?: number; body?: unknown }>) {
|
|
392
|
+
return installStubStepFetch((request) => {
|
|
393
|
+
for (const [fragment, answer] of Object.entries(routes)) {
|
|
394
|
+
if (request.url.includes(fragment)) return answer;
|
|
395
|
+
}
|
|
396
|
+
return { status: 404, body: `no route for ${request.url}` };
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
describe("discoverEpisodes", () => {
|
|
401
|
+
const stub = stubRoutes;
|
|
402
|
+
|
|
403
|
+
test("reads a feed URL directly and returns the newest episodes first", async () => {
|
|
404
|
+
const stubbed = stub({ "show.test/feed.xml": { body: THREE_EPISODES } });
|
|
405
|
+
|
|
406
|
+
const episodes = await discoverEpisodes("https://show.test/feed.xml", 10);
|
|
407
|
+
|
|
408
|
+
// Once. Deciding it is a feed and reading it are the same download.
|
|
409
|
+
expect(stubbed.calls).toHaveLength(1);
|
|
410
|
+
|
|
411
|
+
expect(episodes.map((episode) => episode.title)).toEqual(["newest", "middle", "oldest"]);
|
|
412
|
+
expect(episodes[0]?.podcastTitle).toBe("Example Show");
|
|
413
|
+
expect(episodes[0]?.audioUrl).toBe("https://cdn.test/newest.mp3");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("keeps only maxEpisodes, counted after the sort", async () => {
|
|
417
|
+
stub({ "show.test/feed.xml": { body: THREE_EPISODES } });
|
|
418
|
+
|
|
419
|
+
const episodes = await discoverEpisodes("https://show.test/feed.xml", 2);
|
|
420
|
+
|
|
421
|
+
// The NEWEST two — a slice taken before sorting would give feed order.
|
|
422
|
+
expect(episodes.map((episode) => episode.title)).toEqual(["newest", "middle"]);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("merges several feeds and deduplicates the links first", async () => {
|
|
426
|
+
stub({
|
|
427
|
+
"one.test/feed.xml": { body: feedXml(itemXml("a", "Tue, 01 Jan 2030 00:00:00 GMT"), "One") },
|
|
428
|
+
"two.test/feed.xml": { body: feedXml(itemXml("b", "Wed, 02 Jan 2030 00:00:00 GMT"), "Two") },
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const episodes = await discoverEpisodes(
|
|
432
|
+
"https://one.test/feed.xml, https://two.test/feed.xml, https://one.test/feed.xml",
|
|
433
|
+
10,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
expect(episodes.map((episode) => episode.podcastTitle)).toEqual(["Two", "One"]);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("follows a page that advertises a feed, and verifies what it finds", async () => {
|
|
440
|
+
const stubbed = stub({
|
|
441
|
+
"show.test/home": { body: '<html><link type="application/rss+xml" href="/feed.xml"></html>' },
|
|
442
|
+
"show.test/feed.xml": { body: THREE_EPISODES },
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
expect(await discoverEpisodes("https://show.test/home", 1)).toHaveLength(1);
|
|
446
|
+
// Verified, not trusted — the advertised URL is fetched before it is used.
|
|
447
|
+
// And fetched ONCE: the verified body is carried into the reader rather
|
|
448
|
+
// than the identical document being downloaded again.
|
|
449
|
+
expect(stubbed.calls.map((call) => call.url)).toEqual([
|
|
450
|
+
"https://show.test/home",
|
|
451
|
+
"https://show.test/feed.xml",
|
|
452
|
+
]);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("refuses a page that advertises a feed with no audio in it", async () => {
|
|
456
|
+
// Plenty of pages point `application/rss+xml` at a blog feed.
|
|
457
|
+
stub({
|
|
458
|
+
"show.test/home": { body: '<html><link type="application/rss+xml" href="/blog.xml"></html>' },
|
|
459
|
+
"show.test/blog.xml": {
|
|
460
|
+
body: "<rss><channel><item><title>Post</title></item></channel></rss>",
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
await expect(discoverEpisodes("https://show.test/home", 1)).rejects.toThrow(
|
|
465
|
+
/does not look like a podcast RSS feed/,
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("says what to paste when a page advertises nothing", async () => {
|
|
470
|
+
stub({ "show.test/home": { body: "<html><body>hello</body></html>" } });
|
|
471
|
+
|
|
472
|
+
await expect(discoverEpisodes("https://show.test/home", 1)).rejects.toThrow(
|
|
473
|
+
/Could not find a podcast RSS feed/,
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("resolves an Apple Podcasts link through the lookup API", async () => {
|
|
478
|
+
stub({
|
|
479
|
+
"itunes.apple.com/lookup": {
|
|
480
|
+
body: {
|
|
481
|
+
results: [{ feedUrl: "https://show.test/feed.xml", collectionName: "Example Show" }],
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
"show.test/feed.xml": { body: THREE_EPISODES },
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const episodes = await discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1);
|
|
488
|
+
|
|
489
|
+
expect(episodes[0]?.podcastTitle).toBe("Example Show");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("falls back to Apple's own page when the lookup omits the feed", async () => {
|
|
493
|
+
stub({
|
|
494
|
+
"itunes.apple.com/lookup": { body: { results: [{ collectionName: "Example Show" }] } },
|
|
495
|
+
"podcasts.apple.com": {
|
|
496
|
+
body:
|
|
497
|
+
'<script id="serialized-server-data">' +
|
|
498
|
+
'{"d":[{"model":{"adamId":"123","title":"Example Show","feedUrl":"https://show.test/feed.xml"}}]}' +
|
|
499
|
+
"</script>",
|
|
500
|
+
},
|
|
501
|
+
"show.test/feed.xml": { body: THREE_EPISODES },
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(await discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1)).toHaveLength(
|
|
505
|
+
1,
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("gives up on an Apple show with no public feed anywhere", async () => {
|
|
510
|
+
stub({
|
|
511
|
+
"itunes.apple.com/lookup": { body: { results: [] } },
|
|
512
|
+
"podcasts.apple.com": { body: "<html></html>" },
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
await expect(
|
|
516
|
+
discoverEpisodes("https://podcasts.apple.com/us/podcast/x/id123", 1),
|
|
517
|
+
).rejects.toThrow(/did not expose a public RSS feed/);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("maps a Spotify show to a feed through Apple's search", async () => {
|
|
521
|
+
stub({
|
|
522
|
+
"open.spotify.com": {
|
|
523
|
+
body:
|
|
524
|
+
'<html><meta property="og:title" content="Example Show">' +
|
|
525
|
+
'<meta property="og:description" content="A show about examples"></html>',
|
|
526
|
+
},
|
|
527
|
+
"itunes.apple.com/search": {
|
|
528
|
+
body: {
|
|
529
|
+
results: [{ feedUrl: "https://show.test/feed.xml", collectionName: "Example Show" }],
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
"show.test/feed.xml": { body: THREE_EPISODES },
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
expect(await discoverEpisodes("https://open.spotify.com/show/abc", 1)).toHaveLength(1);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
/** Silently digesting a neighbouring show is the failure nobody reports. */
|
|
539
|
+
test("refuses a Spotify search hit that is a different show", async () => {
|
|
540
|
+
stub({
|
|
541
|
+
"open.spotify.com": {
|
|
542
|
+
body: '<html><meta property="og:title" content="Example Show"></html>',
|
|
543
|
+
},
|
|
544
|
+
"itunes.apple.com/search": {
|
|
545
|
+
body: {
|
|
546
|
+
results: [{ feedUrl: "https://other.test/feed.xml", collectionName: "Something Else" }],
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
await expect(discoverEpisodes("https://open.spotify.com/show/abc", 1)).rejects.toThrow(
|
|
552
|
+
/Could not map the Spotify show/,
|
|
553
|
+
);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("asks for a link rather than failing obscurely on an empty list", async () => {
|
|
557
|
+
stub({});
|
|
558
|
+
await expect(discoverEpisodes(" ", 1)).rejects.toThrow(/Add at least one podcast link/);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("names the four accepted shapes when a feed carries no episode audio", async () => {
|
|
562
|
+
// An `<enclosure>` outside any `<item>`: it looks like a podcast feed and
|
|
563
|
+
// yields nothing, which is the case a bare "no episodes" would not explain.
|
|
564
|
+
stub({
|
|
565
|
+
"show.test/feed.xml": {
|
|
566
|
+
body:
|
|
567
|
+
'<rss><channel><title>T</title><enclosure url="https://cdn.test/x.mp3"/>' +
|
|
568
|
+
"<item><title>No audio</title></item></channel></rss>",
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
await expect(discoverEpisodes("https://show.test/feed.xml", 5)).rejects.toThrow(
|
|
573
|
+
/Apple Podcasts show link, a Spotify show link, an RSS feed URL, or a podcast homepage/,
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("propagates a feed that will not load, so the run can retry it", async () => {
|
|
578
|
+
stub({ "show.test/feed.xml": { status: 503, body: "busy" } });
|
|
579
|
+
await expect(discoverEpisodes("https://show.test/feed.xml", 1)).rejects.toThrow(/503/);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe("transcribing one episode", () => {
|
|
584
|
+
beforeEach(() => vi.stubEnv("ASSEMBLYAI_API_KEY", "test-key"));
|
|
585
|
+
|
|
586
|
+
const stub = stubRoutes;
|
|
587
|
+
|
|
588
|
+
const EPISODE_IN = {
|
|
589
|
+
id: "episode-1",
|
|
590
|
+
feedUrl: "https://show.test/feed.xml",
|
|
591
|
+
podcastTitle: "Example Show",
|
|
592
|
+
title: "An Episode",
|
|
593
|
+
url: "https://show.test/ep",
|
|
594
|
+
audioUrl: "https://cdn.test/a.mp3",
|
|
595
|
+
published: "Thu, 03 Jan 2030 00:00:00 GMT",
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
test("submits the audio and keeps the id the rest of the run polls", async () => {
|
|
599
|
+
stub({ "/v2/transcript": { body: { id: "t-1" } } });
|
|
600
|
+
|
|
601
|
+
expect(await submitTranscript(EPISODE_IN)).toMatchObject({
|
|
602
|
+
transcriptStatus: "submitted",
|
|
603
|
+
transcriptId: "t-1",
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
/** The partial-failure policy: this-episode-is-broken degrades, it does not throw. */
|
|
608
|
+
test("degrades a terminal submit failure to an unavailable episode", async () => {
|
|
609
|
+
stub({ "/v2/transcript": { status: 400, body: { error: "audio_url is not reachable" } } });
|
|
610
|
+
|
|
611
|
+
const job = await submitTranscript(EPISODE_IN);
|
|
612
|
+
|
|
613
|
+
expect(job.transcriptStatus).toBe("unavailable");
|
|
614
|
+
expect(job).toMatchObject({ reason: expect.stringContaining("audio_url") });
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
/** The other half: a transport problem is the DevKit's to retry. */
|
|
618
|
+
test("re-throws a retryable submit failure instead of degrading it", async () => {
|
|
619
|
+
stub({ "/v2/transcript": { status: 503, body: "busy" } });
|
|
620
|
+
await expect(submitTranscript(EPISODE_IN)).rejects.toThrow();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("returns the job unchanged while the provider is still working", async () => {
|
|
624
|
+
stub({ "/v2/transcript/t-1": { body: { status: "processing" } } });
|
|
625
|
+
const job = { ...EPISODE_IN, transcriptStatus: "submitted" as const, transcriptId: "t-1" };
|
|
626
|
+
|
|
627
|
+
// Unchanged, so `waitForTranscripts` can keep it in `pending` without a
|
|
628
|
+
// second vocabulary for "still going".
|
|
629
|
+
expect(await pollTranscript(job)).toBe(job);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("reads the transcript once the provider is done", async () => {
|
|
633
|
+
stub({
|
|
634
|
+
"/v2/transcript/t-1": {
|
|
635
|
+
body: { status: "completed", text: "Otters use tools.", audio_duration: 61 },
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
expect(
|
|
640
|
+
await pollTranscript({ ...EPISODE_IN, transcriptStatus: "submitted", transcriptId: "t-1" }),
|
|
641
|
+
).toMatchObject({ transcriptStatus: "done", transcript: "Otters use tools." });
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("degrades a job the provider gave up on", async () => {
|
|
645
|
+
stub({ "/v2/transcript/t-1": { body: { status: "error", error: "corrupt media" } } });
|
|
646
|
+
|
|
647
|
+
expect(
|
|
648
|
+
await pollTranscript({ ...EPISODE_IN, transcriptStatus: "submitted", transcriptId: "t-1" }),
|
|
649
|
+
).toMatchObject({
|
|
650
|
+
transcriptStatus: "unavailable",
|
|
651
|
+
reason: expect.stringContaining("corrupt"),
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("passes an already-unavailable episode straight through without a request", async () => {
|
|
656
|
+
const stubbed = stub({});
|
|
657
|
+
const job = { ...EPISODE_IN, transcriptStatus: "unavailable" as const, reason: "no audio" };
|
|
658
|
+
|
|
659
|
+
expect(await pollTranscript(job)).toBe(job);
|
|
660
|
+
expect(stubbed.calls).toHaveLength(0);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("summarizes a finished transcript into the digest entry", async () => {
|
|
664
|
+
stubGateway('{"summary":"Otters are clever.","keyPoints":["They use tools","They float"]}');
|
|
665
|
+
|
|
666
|
+
expect(
|
|
667
|
+
await summarizeTranscript({
|
|
668
|
+
...EPISODE_IN,
|
|
669
|
+
transcriptStatus: "done",
|
|
670
|
+
transcriptId: "t-1",
|
|
671
|
+
transcript: "Otters use tools.",
|
|
672
|
+
durationMs: 61_000,
|
|
673
|
+
}),
|
|
674
|
+
).toMatchObject({
|
|
675
|
+
transcriptSource: "assemblyai",
|
|
676
|
+
summary: "Otters are clever.",
|
|
677
|
+
keyPoints: ["They use tools", "They float"],
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
/** One bad episode must not sink a digest of five. */
|
|
682
|
+
test("still produces an entry for an episode that could not be transcribed", async () => {
|
|
683
|
+
const digest = await summarizeTranscript({
|
|
684
|
+
...EPISODE_IN,
|
|
685
|
+
transcriptStatus: "unavailable",
|
|
686
|
+
reason: "corrupt media",
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
expect(digest.transcriptSource).toBe("unavailable");
|
|
690
|
+
// The reason reaches the reader, rather than the episode vanishing.
|
|
691
|
+
expect(digest.summary).toContain("corrupt media");
|
|
692
|
+
expect(digest.keyPoints).toHaveLength(1);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("reads the clock in a step, so a replay sees the same timestamp", async () => {
|
|
696
|
+
expect(Date.parse(await timestamp())).not.toBeNaN();
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
describe("posting the digest", () => {
|
|
701
|
+
const stub = (answer: { status?: number; body?: unknown }) => installStubStepFetch(() => answer);
|
|
702
|
+
|
|
703
|
+
const INPUT = slackInput("https://hooks.slack.com/services/T/B/a");
|
|
704
|
+
|
|
705
|
+
test("posts the rendered payload and answers with what Slack said", async () => {
|
|
706
|
+
const stubbed = stub({ body: "ok" });
|
|
707
|
+
|
|
708
|
+
expect(await sendDigestToSlack(INPUT)).toBe("ok");
|
|
709
|
+
expect(stubbed.calls[0]?.method).toBe("POST");
|
|
710
|
+
expect(String(stubbed.calls[0]?.body)).toContain("Podcast digest 1/2");
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test("treats an empty 200 as success rather than an empty status", async () => {
|
|
714
|
+
stub({ body: "" });
|
|
715
|
+
expect(await sendDigestToSlack(INPUT)).toBe("ok");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* The 4xx/5xx split is the reason this is not a one-line `stepFetchOk`: a
|
|
720
|
+
* revoked webhook answers 4xx identically on every retry, so retrying it
|
|
721
|
+
* burns the DevKit's attempts and delays the real error by minutes.
|
|
722
|
+
*/
|
|
723
|
+
test("makes a 4xx FATAL, with advice a person can act on", async () => {
|
|
724
|
+
stub({ status: 403, body: { error: "invalid_token" } });
|
|
725
|
+
|
|
726
|
+
await expect(sendDigestToSlack(INPUT)).rejects.toThrow(/revoked/);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test("names the unpublished workflow behind a trigger URL", async () => {
|
|
730
|
+
stub({ status: 400, body: { error: "workflow_not_published" } });
|
|
731
|
+
|
|
732
|
+
await expect(
|
|
733
|
+
sendDigestToSlack(slackInput("https://hooks.slack.com/triggers/T/B/a")),
|
|
734
|
+
).rejects.toThrow(/not published/);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("leaves a 5xx retryable, because that is Slack having a bad minute", async () => {
|
|
738
|
+
stub({ status: 503, body: "busy" });
|
|
739
|
+
|
|
740
|
+
const err = await sendDigestToSlack(INPUT).catch((thrown: unknown) => thrown);
|
|
741
|
+
|
|
742
|
+
expect((err as Error).message).toContain("503");
|
|
743
|
+
// NOT fatal — a `FatalError` here would drop a digest over a blip.
|
|
744
|
+
expect((err as Error).name).not.toBe("FatalError");
|
|
745
|
+
});
|
|
746
|
+
});
|