@nodaro/shared 3.11.0 → 3.12.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/index.cjs +2047 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1556 -27
- package/dist/index.d.ts +1556 -27
- package/dist/index.js +1861 -85
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/caption-styles.test.ts +207 -0
- package/src/__tests__/edl-multicam.test.ts +304 -0
- package/src/__tests__/edl.test.ts +822 -0
- package/src/__tests__/fan-out-rows.test.ts +208 -0
- package/src/__tests__/instagram-scrape.test.ts +66 -0
- package/src/__tests__/llm-models.test.ts +48 -11
- package/src/__tests__/meta-ads-scrape.test.ts +284 -0
- package/src/__tests__/node-runtime-keys.test.ts +15 -0
- package/src/__tests__/presentation-utils.test.ts +67 -0
- package/src/__tests__/producer-types.test.ts +19 -0
- package/src/__tests__/schedule-rules.test.ts +265 -0
- package/src/__tests__/speaker-layouts.test.ts +203 -0
- package/src/__tests__/transcribe-capabilities.test.ts +104 -0
- package/src/__tests__/transcribe-preflight.test.ts +60 -0
- package/src/__tests__/trigger-feeds.test.ts +39 -0
- package/src/__tests__/video-duration-auto.test.ts +65 -0
- package/src/__tests__/video-duration.test.ts +56 -0
- package/src/__tests__/video-link.test.ts +137 -0
- package/src/__tests__/workflow-export-strip.test.ts +59 -1
- package/src/caption-styles.ts +240 -0
- package/src/credit-identifiers.ts +31 -0
- package/src/edit-plan-contract.ts +96 -0
- package/src/edl-multicam.ts +185 -0
- package/src/edl.ts +747 -0
- package/src/entity-image-handle.ts +24 -1
- package/src/fan-out-rows.ts +213 -0
- package/src/index.ts +206 -3
- package/src/instagram-scrape.ts +204 -0
- package/src/llm-models.ts +80 -3
- package/src/meta-ads-scrape.ts +463 -0
- package/src/model-catalog.ts +48 -5
- package/src/model-constants.ts +148 -5
- package/src/node-mappable-fields.ts +2 -0
- package/src/node-runtime-keys.ts +28 -0
- package/src/presentation-utils.ts +49 -0
- package/src/producer-types.ts +20 -0
- package/src/schedule-rules.ts +484 -0
- package/src/speaker-layouts.ts +220 -0
- package/src/transcribe-preflight.ts +101 -0
- package/src/trigger-feeds.ts +59 -0
- package/src/trigger-node-types.ts +20 -0
- package/src/video-duration-auto.ts +18 -0
- package/src/video-duration.ts +32 -0
- package/src/video-link.ts +167 -0
- package/src/workflow-export.ts +37 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { LLM_FEATURE_DEFAULTS, STRUCTURED_VISION_MODELS, getLlmModel } from "../llm-models.js"
|
|
3
|
+
import {
|
|
4
|
+
META_ADS_ANALYSIS_CREDITS_PER_AD,
|
|
5
|
+
META_ADS_ANALYSIS_TIERS,
|
|
6
|
+
META_ADS_SCRAPE_CREDIT_COSTS,
|
|
7
|
+
adCreativeAnalysisFrom,
|
|
8
|
+
metaAdsAnalysisCreditId,
|
|
9
|
+
metaAdsAnalysisTier,
|
|
10
|
+
metaAdsScrapeCreditIdFromNode,
|
|
11
|
+
META_ADS_SCRAPE_FALLBACK_CREDIT_ID,
|
|
12
|
+
META_ADS_SCRAPE_MAX_COUNT,
|
|
13
|
+
META_ADS_SCRAPE_MAX_SOURCES,
|
|
14
|
+
META_ADS_SCRAPE_TIERS,
|
|
15
|
+
buildMetaAdsScrapeCreditId,
|
|
16
|
+
classifyCreativeFormat,
|
|
17
|
+
clampMetaAdsFeaturedIndex,
|
|
18
|
+
featuredMetaAdOutputs,
|
|
19
|
+
isFacebookPageUrl,
|
|
20
|
+
isMetaAdsScrapeCount,
|
|
21
|
+
META_ADS_NODE_MODES,
|
|
22
|
+
META_ADS_SCRAPE_MODES,
|
|
23
|
+
metaAdsAdvertisersFrom,
|
|
24
|
+
metaAdsNodeMode,
|
|
25
|
+
splitMetaAdsAdvertiserNames,
|
|
26
|
+
metaAdsScrapeSources,
|
|
27
|
+
metaAdsScrapeTier,
|
|
28
|
+
metaAdsScrapeWireSources,
|
|
29
|
+
resolveMetaAdsScrapeCreditId,
|
|
30
|
+
splitMetaAdsPageUrls,
|
|
31
|
+
} from "../meta-ads-scrape.js"
|
|
32
|
+
import { resolveEffectiveSourceType } from "../entity-image-handle.js"
|
|
33
|
+
|
|
34
|
+
describe("meta-ads-scrape credit identifiers", () => {
|
|
35
|
+
it("the top tier is exactly the Zod ceiling (max count × max sources)", () => {
|
|
36
|
+
expect(META_ADS_SCRAPE_TIERS[META_ADS_SCRAPE_TIERS.length - 1]).toBe(
|
|
37
|
+
META_ADS_SCRAPE_MAX_COUNT * META_ADS_SCRAPE_MAX_SOURCES,
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("buckets a requested total to the smallest tier that fits", () => {
|
|
42
|
+
expect(metaAdsScrapeTier(1)).toBe(10)
|
|
43
|
+
expect(metaAdsScrapeTier(10)).toBe(10)
|
|
44
|
+
expect(metaAdsScrapeTier(11)).toBe(20)
|
|
45
|
+
expect(metaAdsScrapeTier(20)).toBe(20)
|
|
46
|
+
expect(metaAdsScrapeTier(21)).toBe(50)
|
|
47
|
+
expect(metaAdsScrapeTier(100)).toBe(100)
|
|
48
|
+
expect(metaAdsScrapeTier(101)).toBe(200)
|
|
49
|
+
expect(metaAdsScrapeTier(500)).toBe(500)
|
|
50
|
+
expect(metaAdsScrapeTier(9_999)).toBe(500)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("search = one source; pages multiply by URL count", () => {
|
|
54
|
+
expect(buildMetaAdsScrapeCreditId({ count: 20, sources: 1 })).toBe("meta-ads-scrape:20")
|
|
55
|
+
expect(buildMetaAdsScrapeCreditId({ count: 20, sources: 3 })).toBe("meta-ads-scrape:100")
|
|
56
|
+
expect(buildMetaAdsScrapeCreditId({ count: 100, sources: 5 })).toBe("meta-ads-scrape:500")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("clamps out-of-range count / sources instead of producing an unpriced id", () => {
|
|
60
|
+
expect(buildMetaAdsScrapeCreditId({ count: 1_000, sources: 50 })).toBe("meta-ads-scrape:500")
|
|
61
|
+
expect(buildMetaAdsScrapeCreditId({ count: 0, sources: 0 })).toBe("meta-ads-scrape:10")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("every id the builder can produce is priced (1 credit per requested ad)", () => {
|
|
65
|
+
for (const tier of META_ADS_SCRAPE_TIERS) {
|
|
66
|
+
expect(META_ADS_SCRAPE_CREDIT_COSTS[`meta-ads-scrape:${tier}`]).toBe(tier)
|
|
67
|
+
}
|
|
68
|
+
expect(META_ADS_SCRAPE_CREDIT_COSTS["meta-ads-scrape"]).toBe(20)
|
|
69
|
+
expect(META_ADS_SCRAPE_CREDIT_COSTS[META_ADS_SCRAPE_FALLBACK_CREDIT_ID]).toBeDefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe("per-ad AI analysis pricing (folded into the same identifier)", () => {
|
|
73
|
+
it("the analysis SKU is tier × (1 + per-ad credits of the model's tier), and every combination is priced", () => {
|
|
74
|
+
expect(buildMetaAdsScrapeCreditId({ count: 20, sources: 1, analysis: "standard" })).toBe("meta-ads-scrape:20:analysis")
|
|
75
|
+
expect(buildMetaAdsScrapeCreditId({ count: 20, sources: 1, analysis: "economy" })).toBe("meta-ads-scrape:20:analysis:economy")
|
|
76
|
+
expect(buildMetaAdsScrapeCreditId({ count: 30, sources: 2, analysis: "premium" })).toBe("meta-ads-scrape:100:analysis:premium")
|
|
77
|
+
expect(buildMetaAdsScrapeCreditId({ count: 20, sources: 1, analysis: null })).toBe("meta-ads-scrape:20")
|
|
78
|
+
for (const tier of META_ADS_SCRAPE_TIERS) {
|
|
79
|
+
for (const a of META_ADS_ANALYSIS_TIERS) {
|
|
80
|
+
const suffix = a === "standard" ? ":analysis" : `:analysis:${a}`
|
|
81
|
+
const id = `meta-ads-scrape:${tier}${suffix}`
|
|
82
|
+
expect(META_ADS_SCRAPE_CREDIT_COSTS[id], id).toBe(tier * (1 + META_ADS_ANALYSIS_CREDITS_PER_AD[a]))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// The per-ad settlement rows.
|
|
86
|
+
expect(META_ADS_SCRAPE_CREDIT_COSTS[metaAdsAnalysisCreditId("economy")]).toBe(1)
|
|
87
|
+
expect(META_ADS_SCRAPE_CREDIT_COSTS[metaAdsAnalysisCreditId("standard")]).toBe(3)
|
|
88
|
+
expect(META_ADS_SCRAPE_CREDIT_COSTS[metaAdsAnalysisCreditId("premium")]).toBe(4)
|
|
89
|
+
expect(metaAdsAnalysisCreditId("standard")).toBe("meta-ads-analysis")
|
|
90
|
+
// Worked example the docs quote: 20 ads, economy model → 20 + 20 × 1 = 40.
|
|
91
|
+
expect(META_ADS_SCRAPE_CREDIT_COSTS["meta-ads-scrape:20:analysis:economy"]).toBe(40)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("the analysis tier follows the model; the default model is economy and image-capable with structured output", () => {
|
|
95
|
+
expect(metaAdsAnalysisTier(undefined)).toBe("economy")
|
|
96
|
+
expect(metaAdsAnalysisTier("claude-sonnet-4.6")).toBe("standard")
|
|
97
|
+
expect(metaAdsAnalysisTier("claude-opus-5")).toBe("premium")
|
|
98
|
+
const def = getLlmModel(LLM_FEATURE_DEFAULTS["meta-ads-analysis"])
|
|
99
|
+
expect(def?.supportsImages).toBe(true)
|
|
100
|
+
expect(def?.structuredOutputMode).toBeTruthy()
|
|
101
|
+
expect(STRUCTURED_VISION_MODELS.some((m) => m.id === def?.id)).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("metaAdsScrapeCreditIdFromNode: the ONE identifier a node's settings quote (also the wire resolver's answer)", () => {
|
|
105
|
+
expect(metaAdsScrapeCreditIdFromNode({})).toBe("meta-ads-scrape:20")
|
|
106
|
+
expect(metaAdsScrapeCreditIdFromNode({ mode: "search", count: 50, analyze: true })).toBe("meta-ads-scrape:50:analysis:economy")
|
|
107
|
+
expect(metaAdsScrapeCreditIdFromNode({ mode: "pages", pageUrls: "a\nb", count: 30, analyze: true, analysisModel: "claude-sonnet-4.6" })).toBe("meta-ads-scrape:100:analysis")
|
|
108
|
+
expect(metaAdsScrapeCreditIdFromNode({ mode: "search", count: 20, analyze: false, analysisModel: "claude-opus-5" })).toBe("meta-ads-scrape:20")
|
|
109
|
+
// The wire resolver lands on the same SKU for the request the node would send.
|
|
110
|
+
expect(resolveMetaAdsScrapeCreditId({ mode: "search", query: "x", count: 50, analyze: true })).toBe("meta-ads-scrape:50:analysis:economy")
|
|
111
|
+
expect(resolveMetaAdsScrapeCreditId({ mode: "search", query: "x", analyze: true, analysisModel: "claude-opus-5" })).toBe("meta-ads-scrape:20:analysis:premium")
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("adCreativeAnalysisFrom reads a stored analysis defensively", () => {
|
|
115
|
+
const raw = { assetType: "motion", format: "reel", visualHooks: ["face", 3, ""], audiences: [], graphicIdentity: "", copywritingHooks: ["urgency"], usps: [], cta: "Install", summary: "Sells an app." }
|
|
116
|
+
expect(adCreativeAnalysisFrom(raw)).toEqual({ ...raw, visualHooks: ["face"] })
|
|
117
|
+
expect(adCreativeAnalysisFrom({ ...raw, assetType: "gif" })?.assetType).toBe("unknown")
|
|
118
|
+
expect(adCreativeAnalysisFrom({ ...raw, summary: "" })).toBeNull()
|
|
119
|
+
expect(adCreativeAnalysisFrom(null)).toBeNull()
|
|
120
|
+
expect(adCreativeAnalysisFrom("x")).toBeNull()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("featuredMetaAdOutputs: the featured ad's copy, first image (else poster) and first video", () => {
|
|
125
|
+
const ads = [
|
|
126
|
+
{ title: "Air Max", text: "Just do it.", images: ["https://img/1.jpg"], videos: [], videoPreviews: [] },
|
|
127
|
+
{ title: "", text: "Video ad", images: [], videos: ["https://vid/2.mp4"], videoPreviews: ["https://vid/2.jpg"] },
|
|
128
|
+
]
|
|
129
|
+
expect(featuredMetaAdOutputs(ads, 0)).toEqual({ text: "Air Max\n\nJust do it.", imageUrl: "https://img/1.jpg" })
|
|
130
|
+
expect(featuredMetaAdOutputs(ads, 1)).toEqual({ text: "Video ad", imageUrl: "https://vid/2.jpg", videoUrl: "https://vid/2.mp4" })
|
|
131
|
+
expect(featuredMetaAdOutputs(ads, 99)).toEqual(featuredMetaAdOutputs(ads, 1)) // clamped
|
|
132
|
+
expect(featuredMetaAdOutputs([], 0)).toEqual({})
|
|
133
|
+
expect(featuredMetaAdOutputs("nope", 0)).toEqual({})
|
|
134
|
+
expect(clampMetaAdsFeaturedIndex(-3, 2)).toBe(0)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("the typed output handles behave as the canonical single-media producers on canvas; json keeps the raw type", () => {
|
|
138
|
+
expect(resolveEffectiveSourceType("meta-ads-scrape", "text")).toBe("combine-text")
|
|
139
|
+
expect(resolveEffectiveSourceType("meta-ads-scrape", "image")).toBe("upload-image")
|
|
140
|
+
expect(resolveEffectiveSourceType("meta-ads-scrape", "video")).toBe("upload-video")
|
|
141
|
+
expect(resolveEffectiveSourceType("meta-ads-scrape", "json")).toBe("meta-ads-scrape")
|
|
142
|
+
expect(resolveEffectiveSourceType("meta-ads-scrape", undefined)).toBe("meta-ads-scrape")
|
|
143
|
+
// Not a dynamic producer: an audio input must never accept it.
|
|
144
|
+
expect(resolveEffectiveSourceType("meta-ads-scrape", "audio")).toBe("meta-ads-scrape")
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("classifyCreativeFormat: vertical below 0.95, square within ±5 %, horizontal above, unknown without pixels", () => {
|
|
148
|
+
expect(classifyCreativeFormat(1080, 1920)).toBe("vertical") // 9:16
|
|
149
|
+
expect(classifyCreativeFormat(1080, 1350)).toBe("vertical") // 4:5
|
|
150
|
+
expect(classifyCreativeFormat(1080, 1080)).toBe("square")
|
|
151
|
+
expect(classifyCreativeFormat(1000, 960)).toBe("square") // 1.04
|
|
152
|
+
expect(classifyCreativeFormat(1920, 1080)).toBe("horizontal") // 16:9
|
|
153
|
+
expect(classifyCreativeFormat(1200, 628)).toBe("horizontal") // 1.91:1
|
|
154
|
+
expect(classifyCreativeFormat(0, 100)).toBe("unknown")
|
|
155
|
+
expect(classifyCreativeFormat(undefined, 100)).toBe("unknown")
|
|
156
|
+
expect(classifyCreativeFormat(Number.NaN, 100)).toBe("unknown")
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("splitMetaAdsPageUrls accepts one-per-line text, commas, or an array", () => {
|
|
160
|
+
expect(splitMetaAdsPageUrls("https://www.facebook.com/nike\n facebook.com/adidas ,https://facebook.com/puma\n\n")).toEqual([
|
|
161
|
+
"https://www.facebook.com/nike",
|
|
162
|
+
"facebook.com/adidas",
|
|
163
|
+
"https://facebook.com/puma",
|
|
164
|
+
])
|
|
165
|
+
expect(splitMetaAdsPageUrls(["a", " b ", "", 3])).toEqual(["a", "b"])
|
|
166
|
+
expect(splitMetaAdsPageUrls(undefined)).toEqual([])
|
|
167
|
+
expect(splitMetaAdsPageUrls("")).toEqual([])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("isMetaAdsScrapeCount accepts 1..100 integers only", () => {
|
|
171
|
+
expect(isMetaAdsScrapeCount(1)).toBe(true)
|
|
172
|
+
expect(isMetaAdsScrapeCount(100)).toBe(true)
|
|
173
|
+
expect(isMetaAdsScrapeCount(0)).toBe(false)
|
|
174
|
+
expect(isMetaAdsScrapeCount(101)).toBe(false)
|
|
175
|
+
expect(isMetaAdsScrapeCount(2.5)).toBe(false)
|
|
176
|
+
expect(isMetaAdsScrapeCount("20")).toBe(false)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe("advertiser mode (node-only; runs as pages)", () => {
|
|
180
|
+
const openart = { pageId: "61562658466287", name: "OpenArt AI", url: "https://www.facebook.com/people/OpenArt-AI/61562658466287/", imageUrl: "https://scontent.fbcdn.net/a.png", verified: true }
|
|
181
|
+
const nike = { pageId: "15087023444", name: "Nike", url: "https://www.facebook.com/nike" }
|
|
182
|
+
|
|
183
|
+
it("the wire modes stay search / pages; the node adds advertiser", () => {
|
|
184
|
+
expect([...META_ADS_SCRAPE_MODES]).toEqual(["search", "pages"])
|
|
185
|
+
expect([...META_ADS_NODE_MODES]).toEqual(["search", "pages", "advertiser"])
|
|
186
|
+
expect(metaAdsNodeMode("advertiser")).toBe("advertiser")
|
|
187
|
+
expect(metaAdsNodeMode("pages")).toBe("pages")
|
|
188
|
+
expect(metaAdsNodeMode("nope")).toBe("search")
|
|
189
|
+
expect(metaAdsNodeMode(undefined)).toBe("search")
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it("metaAdsAdvertisersFrom sanitizes, dedupes by page id and caps at MAX_SOURCES", () => {
|
|
193
|
+
expect(metaAdsAdvertisersFrom([openart, nike])).toEqual([openart, nike])
|
|
194
|
+
expect(metaAdsAdvertisersFrom([openart, { ...openart, name: "dup" }])).toHaveLength(1)
|
|
195
|
+
expect(metaAdsAdvertisersFrom([{ pageId: 15087023444, name: " Nike ", url: nike.url, imageUrl: "javascript:x", verified: "yes" }])).toEqual([nike])
|
|
196
|
+
// An avatar is stored only from Meta's CDN — a workflow JSON anyone can write must not smuggle a third-party image in.
|
|
197
|
+
expect(metaAdsAdvertisersFrom([{ ...nike, imageUrl: "https://evil.example/x.png?fbcdn.net" }])).toEqual([nike])
|
|
198
|
+
expect(metaAdsAdvertisersFrom([{ ...nike, imageUrl: "https://scontent-atl3-1.xx.fbcdn.net/v/a.png" }])[0].imageUrl).toBe("https://scontent-atl3-1.xx.fbcdn.net/v/a.png")
|
|
199
|
+
expect(metaAdsAdvertisersFrom([{ ...nike, pageId: Number.NaN }])).toEqual([])
|
|
200
|
+
expect(metaAdsAdvertisersFrom([{ ...nike, url: `https://www.facebook.com/${"n".repeat(2100)}` }])).toEqual([])
|
|
201
|
+
expect(metaAdsAdvertisersFrom([{ pageId: "1", name: "Elsewhere", url: "https://www.instagram.com/x" }])).toEqual([])
|
|
202
|
+
expect(metaAdsAdvertisersFrom([{ pageId: "", name: "No id", url: nike.url }, { pageId: "2", name: "", url: nike.url }, null, "x"])).toEqual([])
|
|
203
|
+
expect(metaAdsAdvertisersFrom("nope")).toEqual([])
|
|
204
|
+
const many = Array.from({ length: 7 }, (_, i) => ({ pageId: String(i), name: `P${i}`, url: `https://www.facebook.com/p${i}` }))
|
|
205
|
+
expect(metaAdsAdvertisersFrom(many)).toHaveLength(META_ADS_SCRAPE_MAX_SOURCES)
|
|
206
|
+
expect(metaAdsAdvertisersFrom(many, 8)).toHaveLength(7) // lookup results may show more than the pick cap
|
|
207
|
+
expect(isFacebookPageUrl("https://m.facebook.com/nike")).toBe(true)
|
|
208
|
+
expect(isFacebookPageUrl("https://notfacebook.com/nike")).toBe(false)
|
|
209
|
+
expect(isFacebookPageUrl("ftp://www.facebook.com/nike")).toBe(false)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it("metaAdsScrapeSources: the ONE source count every quote reads", () => {
|
|
213
|
+
expect(metaAdsScrapeSources({})).toBe(1)
|
|
214
|
+
expect(metaAdsScrapeSources({ mode: "search", query: "x" })).toBe(1)
|
|
215
|
+
expect(metaAdsScrapeSources({ mode: "pages", pageUrls: "a\nb\nc" })).toBe(3)
|
|
216
|
+
expect(metaAdsScrapeSources({ mode: "pages", pageUrls: "" })).toBe(1)
|
|
217
|
+
expect(metaAdsScrapeSources({ mode: "pages", pageUrls: Array.from({ length: 9 }, (_, i) => `u${i}`) })).toBe(META_ADS_SCRAPE_MAX_SOURCES)
|
|
218
|
+
expect(metaAdsScrapeSources({ mode: "advertiser", advertisers: [openart, nike] })).toBe(2)
|
|
219
|
+
expect(metaAdsScrapeSources({ mode: "advertiser", advertisers: [] })).toBe(1)
|
|
220
|
+
// A pages-mode node keeps its page count even if stale advertiser picks are around, and vice versa.
|
|
221
|
+
expect(metaAdsScrapeSources({ mode: "pages", pageUrls: "a", advertisers: [openart, nike] })).toBe(1)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("splitMetaAdsAdvertiserNames: one per line or comma, keeps spaces, dedupes, caps at MAX_SOURCES", () => {
|
|
225
|
+
expect(splitMetaAdsAdvertiserNames("OpenArt AI\nNike, Adidas")).toEqual(["OpenArt AI", "Nike", "Adidas"])
|
|
226
|
+
expect(splitMetaAdsAdvertiserNames("Nike\nnike\nNIKE")).toEqual(["Nike"]) // case-insensitive dedupe
|
|
227
|
+
expect(splitMetaAdsAdvertiserNames("x")).toEqual([]) // under 2 chars
|
|
228
|
+
expect(splitMetaAdsAdvertiserNames([" Meta ", "", "Threads"])).toEqual(["Meta", "Threads"])
|
|
229
|
+
expect(splitMetaAdsAdvertiserNames(Array.from({ length: 9 }, (_, i) => `Brand ${i}`))).toHaveLength(META_ADS_SCRAPE_MAX_SOURCES)
|
|
230
|
+
expect(splitMetaAdsAdvertiserNames(undefined)).toEqual([])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("metaAdsScrapeWireSources: advertiser picks run as their Page urls; the route never sees 'advertiser'", () => {
|
|
234
|
+
expect(metaAdsScrapeWireSources({ mode: "advertiser", advertisers: [openart, nike] })).toEqual({ mode: "pages", pageUrls: [openart.url, nike.url] })
|
|
235
|
+
// No picks + upstream text → the `in` value is advertiser NAME(s) to resolve at run time.
|
|
236
|
+
expect(metaAdsScrapeWireSources({ mode: "advertiser", advertisers: [] }, "OpenArt AI, Nike")).toEqual({ mode: "pages", pageUrls: [], advertiserNames: ["OpenArt AI", "Nike"] })
|
|
237
|
+
// No picks, no upstream → empty (the editor blocks the run before here).
|
|
238
|
+
expect(metaAdsScrapeWireSources({ mode: "advertiser", advertisers: [] })).toEqual({ mode: "pages", pageUrls: [], advertiserNames: [] })
|
|
239
|
+
// Picks win over upstream text.
|
|
240
|
+
expect(metaAdsScrapeWireSources({ mode: "advertiser", advertisers: [nike] }, "OpenArt AI")).toEqual({ mode: "pages", pageUrls: [nike.url] })
|
|
241
|
+
expect(metaAdsScrapeWireSources({ mode: "pages", pageUrls: "" }, "facebook.com/a, facebook.com/b")).toEqual({ mode: "pages", pageUrls: ["facebook.com/a", "facebook.com/b"] })
|
|
242
|
+
expect(metaAdsScrapeWireSources({ mode: "pages", pageUrls: "https://www.facebook.com/own" }, "facebook.com/up")).toEqual({ mode: "pages", pageUrls: ["https://www.facebook.com/own"] })
|
|
243
|
+
expect(metaAdsScrapeWireSources({ mode: "search", query: "" }, "shoes")).toEqual({ mode: "search", query: "shoes" })
|
|
244
|
+
expect(metaAdsScrapeWireSources({ mode: "search", query: "own" }, "shoes")).toEqual({ mode: "search", query: "own" })
|
|
245
|
+
expect(metaAdsScrapeWireSources({})).toEqual({ mode: "search", query: undefined })
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe("resolveMetaAdsScrapeCreditId (raw, pre-Zod body)", () => {
|
|
250
|
+
it("reads mode + count + pageUrls", () => {
|
|
251
|
+
expect(resolveMetaAdsScrapeCreditId({ mode: "search", query: "nike", count: 50 })).toBe("meta-ads-scrape:50")
|
|
252
|
+
expect(resolveMetaAdsScrapeCreditId({ mode: "pages", pageUrls: ["a", "b"], count: 30 })).toBe("meta-ads-scrape:100")
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("counts advertiser names as sources (they resolve to Page urls at run time)", () => {
|
|
256
|
+
expect(resolveMetaAdsScrapeCreditId({ mode: "pages", pageUrls: [], advertiserNames: ["OpenArt AI", "Nike"], count: 30 })).toBe("meta-ads-scrape:100")
|
|
257
|
+
expect(resolveMetaAdsScrapeCreditId({ mode: "pages", pageUrls: ["a"], advertiserNames: ["Nike"], count: 20 })).toBe("meta-ads-scrape:50") // (1+1)×20=40→50 tier
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it("an OMITTED count is the route default, so the guard lands on the reservation's tier", () => {
|
|
261
|
+
// Zod defaults count to 20 and the reservation multiplies by sources —
|
|
262
|
+
// the guard must not reserve the flat fallback for a 3-page body.
|
|
263
|
+
expect(resolveMetaAdsScrapeCreditId({ mode: "search", query: "x" })).toBe("meta-ads-scrape:20")
|
|
264
|
+
expect(resolveMetaAdsScrapeCreditId({ mode: "pages", pageUrls: ["a", "b", "c"] })).toBe("meta-ads-scrape:100")
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it("falls back to the fixed mid tier on a malformed body", () => {
|
|
268
|
+
expect(resolveMetaAdsScrapeCreditId(undefined)).toBe(META_ADS_SCRAPE_FALLBACK_CREDIT_ID)
|
|
269
|
+
expect(resolveMetaAdsScrapeCreditId(null)).toBe(META_ADS_SCRAPE_FALLBACK_CREDIT_ID)
|
|
270
|
+
expect(resolveMetaAdsScrapeCreditId({ count: "20" })).toBe(META_ADS_SCRAPE_FALLBACK_CREDIT_ID)
|
|
271
|
+
expect(resolveMetaAdsScrapeCreditId({ count: 0 })).toBe(META_ADS_SCRAPE_FALLBACK_CREDIT_ID)
|
|
272
|
+
expect(resolveMetaAdsScrapeCreditId({ mode: "pages", count: 20 })).toBe(META_ADS_SCRAPE_FALLBACK_CREDIT_ID)
|
|
273
|
+
expect(resolveMetaAdsScrapeCreditId({ mode: "pages", pageUrls: [], count: 20 })).toBe(META_ADS_SCRAPE_FALLBACK_CREDIT_ID)
|
|
274
|
+
expect(resolveMetaAdsScrapeCreditId({ mode: "pages", pageUrls: new Array(6).fill("u"), count: 20 })).toBe(
|
|
275
|
+
META_ADS_SCRAPE_FALLBACK_CREDIT_ID,
|
|
276
|
+
)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it("the fallback is a mid tier, never the max", () => {
|
|
280
|
+
const max = META_ADS_SCRAPE_TIERS[META_ADS_SCRAPE_TIERS.length - 1]
|
|
281
|
+
expect(META_ADS_SCRAPE_CREDIT_COSTS[META_ADS_SCRAPE_FALLBACK_CREDIT_ID]).toBeLessThan(max)
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
})
|
|
@@ -32,6 +32,8 @@ describe("TRANSIENT_RUNTIME_KEYS", () => {
|
|
|
32
32
|
"__listRunning",
|
|
33
33
|
"_upstreamRefresh",
|
|
34
34
|
"__upstreamCount",
|
|
35
|
+
"downloadPercent",
|
|
36
|
+
"downloadPhase",
|
|
35
37
|
]) {
|
|
36
38
|
expect(TRANSIENT_RUNTIME_KEYS.has(key), `${key} should be transient`).toBe(true)
|
|
37
39
|
}
|
|
@@ -52,12 +54,25 @@ describe("TRANSIENT_RUNTIME_KEYS", () => {
|
|
|
52
54
|
"shots",
|
|
53
55
|
"result",
|
|
54
56
|
"zoom",
|
|
57
|
+
// A Video URL node's download OUTCOME persists — only its ticks are transient.
|
|
58
|
+
"downloadStatus",
|
|
59
|
+
"downloadedVideoUrl",
|
|
60
|
+
"downloadId",
|
|
61
|
+
// "Clear results" stamps it for the NEXT load to read — stripping it on
|
|
62
|
+
// save would bring every cleared result back on reload.
|
|
63
|
+
"resultsClearedAt",
|
|
55
64
|
]) {
|
|
56
65
|
expect(TRANSIENT_RUNTIME_KEYS.has(key), `${key} must stay persisted`).toBe(false)
|
|
57
66
|
}
|
|
58
67
|
})
|
|
59
68
|
})
|
|
60
69
|
|
|
70
|
+
describe("EXECUTION_DATA_KEYS", () => {
|
|
71
|
+
it("files the clear-results watermark as runtime bookkeeping — never preset / template / copilot-visible config", () => {
|
|
72
|
+
expect(EXECUTION_DATA_KEYS.has("resultsClearedAt")).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
61
76
|
describe("stripTransientRuntimeData", () => {
|
|
62
77
|
it("removes transient keys, keeps results, does not mutate input", () => {
|
|
63
78
|
const nodes = [
|
|
@@ -1232,3 +1232,70 @@ describe("legacy presentationVisible rules for a media producer", () => {
|
|
|
1232
1232
|
expect(getOutputNodes(nodes, [mkEdge("txt", "gv")])).toEqual([])
|
|
1233
1233
|
})
|
|
1234
1234
|
})
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
// ---------------------------------------------------------------------------
|
|
1238
|
+
// mergeNodeInputOverrides — media-bound `metadata` must not outlive its media
|
|
1239
|
+
// ---------------------------------------------------------------------------
|
|
1240
|
+
import { mergeNodeInputOverrides, INPUT_FIELD_MAP } from "../presentation-utils"
|
|
1241
|
+
|
|
1242
|
+
describe("mergeNodeInputOverrides", () => {
|
|
1243
|
+
const saved = {
|
|
1244
|
+
label: "Episode",
|
|
1245
|
+
extractedAudioUrl: "https://cdn/publisher-10min.mp3",
|
|
1246
|
+
extractionStatus: "ready",
|
|
1247
|
+
metadata: { durationSeconds: 600 },
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
it("drops the saved metadata when an override SWAPS the node's media", () => {
|
|
1251
|
+
const merged = mergeNodeInputOverrides("reference-audio", saved, {
|
|
1252
|
+
extractedAudioUrl: "https://cdn/caller-60min.mp3",
|
|
1253
|
+
})
|
|
1254
|
+
expect(merged.extractedAudioUrl).toBe("https://cdn/caller-60min.mp3")
|
|
1255
|
+
expect("metadata" in merged).toBe(false)
|
|
1256
|
+
// …and nothing else is touched.
|
|
1257
|
+
expect(merged.label).toBe("Episode")
|
|
1258
|
+
expect(merged.extractionStatus).toBe("ready")
|
|
1259
|
+
})
|
|
1260
|
+
|
|
1261
|
+
it("keeps the metadata when the override leaves the media alone", () => {
|
|
1262
|
+
expect(mergeNodeInputOverrides("reference-audio", saved, { label: "Renamed" }).metadata).toEqual({ durationSeconds: 600 })
|
|
1263
|
+
// Same url re-sent (an app run that did not change this input) is not a swap.
|
|
1264
|
+
expect(
|
|
1265
|
+
mergeNodeInputOverrides("reference-audio", saved, { extractedAudioUrl: saved.extractedAudioUrl }).metadata,
|
|
1266
|
+
).toEqual({ durationSeconds: 600 })
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
it("an override that brings its OWN metadata wins (the caller measured the new media)", () => {
|
|
1270
|
+
const merged = mergeNodeInputOverrides("reference-audio", saved, {
|
|
1271
|
+
extractedAudioUrl: "https://cdn/caller-60min.mp3",
|
|
1272
|
+
metadata: { durationSeconds: 3600 },
|
|
1273
|
+
})
|
|
1274
|
+
expect(merged.metadata).toEqual({ durationSeconds: 3600 })
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
it("is schema-driven: every media input node in INPUT_FIELD_MAP is covered, no list to remember", () => {
|
|
1278
|
+
const mediaTypes = Object.entries(INPUT_FIELD_MAP).filter(([, f]) => /-url$/.test(f.type))
|
|
1279
|
+
expect(mediaTypes.length).toBeGreaterThanOrEqual(4) // upload-image/video/audio + reference-audio
|
|
1280
|
+
for (const [nodeType, field] of mediaTypes) {
|
|
1281
|
+
const data = { [field.key]: "https://cdn/old", metadata: { durationSeconds: 600, width: 1920 } }
|
|
1282
|
+
const merged = mergeNodeInputOverrides(nodeType, data, { [field.key]: "https://cdn/new" })
|
|
1283
|
+
expect("metadata" in merged, `${nodeType} kept stale metadata`).toBe(false)
|
|
1284
|
+
}
|
|
1285
|
+
})
|
|
1286
|
+
|
|
1287
|
+
it("never touches metadata on a non-media input node, or an unknown type", () => {
|
|
1288
|
+
const data = { text: "a", metadata: { note: "mine" } }
|
|
1289
|
+
expect(mergeNodeInputOverrides("text-prompt", data, { text: "b" }).metadata).toEqual({ note: "mine" })
|
|
1290
|
+
expect(mergeNodeInputOverrides(undefined, data, { text: "b" }).metadata).toEqual({ note: "mine" })
|
|
1291
|
+
expect(mergeNodeInputOverrides("not-a-node", data, { text: "b" }).metadata).toEqual({ note: "mine" })
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
it("does not mutate its inputs", () => {
|
|
1295
|
+
const data = { ...saved }
|
|
1296
|
+
const overrides = { extractedAudioUrl: "https://cdn/new.mp3" }
|
|
1297
|
+
mergeNodeInputOverrides("reference-audio", data, overrides)
|
|
1298
|
+
expect(data.metadata).toEqual({ durationSeconds: 600 })
|
|
1299
|
+
expect(overrides).toEqual({ extractedAudioUrl: "https://cdn/new.mp3" })
|
|
1300
|
+
})
|
|
1301
|
+
})
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
VIDEO_PRODUCER_TYPES,
|
|
5
5
|
DYNAMIC_PRODUCER_TYPES,
|
|
6
6
|
} from "../producer-types.js"
|
|
7
|
+
import { getOutputType } from "../presentation-utils.js"
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Producer-set membership is what every downstream node's typed-handle
|
|
@@ -77,4 +78,22 @@ describe("producer-types", () => {
|
|
|
77
78
|
).toBe(set.has("voice-changer"))
|
|
78
79
|
}
|
|
79
80
|
})
|
|
81
|
+
|
|
82
|
+
// apply-edl is the FIRST node with BOTH a dynamic media output handle (its
|
|
83
|
+
// `output` setting decides video|audio) AND a fixed `json` handle (the
|
|
84
|
+
// remapped Transcript). The dynamic media half MUST be a DYNAMIC producer so
|
|
85
|
+
// its default handle is accepted on both audio and video inputs; explicit
|
|
86
|
+
// assertion because the suite does not fail on omission.
|
|
87
|
+
it("registers apply-edl as a dynamic producer (video|audio decided at run time)", () => {
|
|
88
|
+
expect(DYNAMIC_PRODUCER_TYPES.has("apply-edl")).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// getOutputType deliberately ignores DYNAMIC_PRODUCER_TYPES and answers
|
|
92
|
+
// "data" for its members, so a published app would render apply-edl's cut as
|
|
93
|
+
// a JSON blob. apply-edl is therefore ALSO in the literal VIDEO_OUTPUT_TYPES
|
|
94
|
+
// (presentation-utils.ts), mirroring voice-changer/dubbing — this pins that
|
|
95
|
+
// the classifier answers "video", not "data".
|
|
96
|
+
it("classifies apply-edl as a video output (literal VIDEO_OUTPUT_TYPES wins over DYNAMIC 'data')", () => {
|
|
97
|
+
expect(getOutputType("apply-edl")).toBe("video")
|
|
98
|
+
})
|
|
80
99
|
})
|