@synap-core/workspace-templates 0.12.0 → 0.24.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/README.md +509 -0
- package/automations/bookmark-enrichment.automation.json +321 -0
- package/automations/github-pr-events.automation.json +505 -0
- package/automations/mail-feed.automation.json +86 -0
- package/automations/ship-a-change-checks-green.automation.json +106 -0
- package/automations/ship-a-change-checks-red.automation.json +106 -0
- package/automations/ship-a-change-deployed.automation.json +56 -0
- package/automations/ship-a-change-pr-merged.automation.json +110 -0
- package/automations/ship-a-change-pr-opened.automation.json +104 -0
- package/automations/url-bookmark-capture.automation.json +132 -0
- package/capabilities/agency-skills.capability.json +157 -0
- package/capabilities/apify.capability.json +265 -0
- package/capabilities/apollo.capability.json +120 -0
- package/capabilities/arch-backend.capability.json +97 -0
- package/capabilities/arch-client-intelligence.capability.json +401 -0
- package/capabilities/backblaze-b2.capability.json +205 -0
- package/capabilities/cal-com.capability.json +266 -0
- package/capabilities/canva.capability.json +274 -0
- package/capabilities/claude-code-routines.capability.json +172 -0
- package/capabilities/claude-managed-agents.capability.json +215 -0
- package/capabilities/client-comms-report.capability.json +246 -0
- package/capabilities/content-idea-to-production.capability.json +29 -0
- package/capabilities/datagouv.capability.json +315 -0
- package/capabilities/discord-bot.capability.json +157 -0
- package/capabilities/dropbox.capability.json +281 -0
- package/capabilities/exa.capability.json +109 -0
- package/capabilities/fal-ai.capability.json +83 -0
- package/capabilities/fal-media-lab.capability.json +158 -0
- package/capabilities/fireflies.capability.json +191 -0
- package/capabilities/freellmapi.capability.json +114 -0
- package/capabilities/generic-apikey.capability.json +92 -0
- package/capabilities/github-merge.capability.json +99 -0
- package/capabilities/github-read.capability.json +325 -0
- package/capabilities/governance-tighten-recommender.capability.json +81 -0
- package/capabilities/jev.capability.json +226 -0
- package/capabilities/lead-conversion-enrollment.capability.json +246 -0
- package/capabilities/mail-feed.capability.json +277 -0
- package/capabilities/mailgun.capability.json +52 -0
- package/capabilities/nango-google.capability.json +639 -0
- package/capabilities/notion.capability.json +417 -0
- package/capabilities/omniroute.capability.json +137 -0
- package/capabilities/posthog-analytics.capability.json +258 -0
- package/capabilities/postiz.capability.json +467 -0
- package/capabilities/proactive-digest.capability.json +190 -0
- package/capabilities/proton.capability.json +288 -0
- package/capabilities/replicate-rvm.capability.json +97 -0
- package/capabilities/research-methods.capability.json +57 -0
- package/capabilities/shopify.capability.json +395 -0
- package/capabilities/slack.capability.json +369 -0
- package/capabilities/stripe.capability.json +1496 -0
- package/capabilities/telegram-bridge.capability.json +56 -0
- package/capabilities/treg.capability.json +102 -0
- package/capabilities/unipile-linkedin.capability.json +494 -0
- package/capabilities/vercel.capability.json +424 -0
- package/dist/define-DqoItRiR.d.ts +1370 -0
- package/dist/index.d.ts +174 -900
- package/dist/index.js +26447 -24075
- package/dist/index.js.map +1 -1
- package/dist/wire.d.ts +222 -0
- package/dist/wire.js +77 -0
- package/dist/wire.js.map +1 -0
- package/package.json +27 -4
- package/retired.yaml +136 -0
- package/skills/app-factory-build-slice/SKILL.md +70 -0
- package/skills/app-factory-build-slice.catalog.json +17 -0
- package/skills/app-factory-design-canvas/SKILL.md +96 -0
- package/skills/app-factory-design-canvas.catalog.json +17 -0
- package/skills/app-factory-design-review/SKILL.md +105 -0
- package/skills/app-factory-design-review.catalog.json +18 -0
- package/skills/app-factory-research/SKILL.md +123 -0
- package/skills/app-factory-research.catalog.json +17 -0
- package/skills/bookmark-enrichment/SKILL.md +40 -0
- package/skills/carousel-format/carousel-format.skill.json +18 -0
- package/skills/collage-broll/collage-broll.skill.json +20 -0
- package/skills/content-taste/content-taste.skill.json +18 -0
- package/skills/creative-director/creative-director.skill.json +18 -0
- package/skills/design-critique/SKILL.md +138 -0
- package/skills/design-critique.catalog.json +12 -0
- package/skills/designing-with-real-components/SKILL.md +140 -0
- package/skills/designing-with-real-components.catalog.json +12 -0
- package/skills/edit-loop/edit-loop.skill.json +25 -0
- package/skills/facecam-edit/facecam-edit.skill.json +24 -0
- package/skills/mapping-the-server/SKILL.md +54 -0
- package/skills/math-explainer-manimgx/math-explainer-manimgx.skill.json +20 -0
- package/skills/motion-quality-gates/motion-quality-gates.skill.json +20 -0
- package/skills/open-design-edit/open-design-edit.skill.json +24 -0
- package/skills/reading-client-progress/SKILL.md +65 -0
- package/skills/routing-to-channels/SKILL.md +54 -0
- package/skills/stellar-scf-grants-advisor/ASSOCIATE-CHEATSHEET.md +133 -0
- package/skills/stellar-scf-grants-advisor/ASSOCIATE-ONBOARDING-GUIDE.md +761 -0
- package/skills/stellar-scf-grants-advisor/CLIENT-FAQ.md +181 -0
- package/skills/stellar-scf-grants-advisor/PARTNER-HANDOVER.md +130 -0
- package/skills/stellar-scf-grants-advisor/SKILL.md +102 -0
- package/skills/stellar-scf-grants-advisor/examples/README.md +43 -0
- package/skills/stellar-scf-grants-advisor/examples/chat-summary-templates.md +158 -0
- package/skills/stellar-scf-grants-advisor/examples/corpus-comparable-rows.md +136 -0
- package/skills/stellar-scf-grants-advisor/examples/q10-team-rewrites.md +109 -0
- package/skills/stellar-scf-grants-advisor/examples/q2-description-rewrites.md +88 -0
- package/skills/stellar-scf-grants-advisor/examples/q6-integration-rewrites.md +107 -0
- package/skills/stellar-scf-grants-advisor/examples/tranche-structures.md +176 -0
- package/skills/stellar-scf-grants-advisor/reference/01-methodology.md +96 -0
- package/skills/stellar-scf-grants-advisor/reference/02-scoring-framework.md +199 -0
- package/skills/stellar-scf-grants-advisor/reference/03-rejection-patterns.md +241 -0
- package/skills/stellar-scf-grants-advisor/reference/04-winner-patterns.md +120 -0
- package/skills/stellar-scf-grants-advisor/reference/05-comeback-patterns.md +92 -0
- package/skills/stellar-scf-grants-advisor/reference/06-budget-benchmarks.md +125 -0
- package/skills/stellar-scf-grants-advisor/reference/07-integration-list.md +147 -0
- package/skills/stellar-scf-grants-advisor/reference/08-abstract-fields.md +258 -0
- package/skills/stellar-scf-grants-advisor/reference/09-build-fields.md +259 -0
- package/skills/stellar-scf-grants-advisor/reference/10-corpus-winners.md +135 -0
- package/skills/stellar-scf-grants-advisor/reference/11-corpus-rejects.md +267 -0
- package/skills/stellar-scf-grants-advisor/reference/12-client-communication.md +131 -0
- package/skills/stellar-scf-grants-advisor/reference/13-excel-output-spec.md +199 -0
- package/skills/stellar-scf-grants-advisor/reference/14-worked-examples.md +160 -0
- package/skills/stellar-scf-grants-advisor/templates/abstract-review-builder.py +253 -0
- package/skills/stellar-scf-grants-advisor/templates/build-review-builder.py +213 -0
- package/skills/video-direction/video-direction.skill.json +20 -0
- package/skills/video-style-vocabulary/video-style-vocabulary.skill.json +20 -0
- package/skills/video-toolbox/video-toolbox.skill.json +20 -0
- package/src/__fixtures__/lifecycle-presets.flat-before.json +205 -0
- package/src/agency-os.yaml +122 -0
- package/src/agent-os-skill-slugs.test.ts +65 -0
- package/src/apify-social-reader.test.ts +317 -0
- package/src/base.yaml +85 -39
- package/src/blockchain-ecosystem.yaml +1 -0
- package/src/brand-library.yaml +525 -14
- package/src/builder-dev-fold.template.test.ts +113 -0
- package/src/builder.yaml +779 -91
- package/src/business-model-catalog-description.test.ts +47 -0
- package/src/business-model-grp.template.test.ts +214 -0
- package/src/business-model.yaml +296 -49
- package/src/catalog-safety.test.ts +198 -0
- package/src/catalog.template-items.test.ts +75 -0
- package/src/catalog.ts +66 -2
- package/src/concept-guard.test.ts +28 -8
- package/src/concept-guard.ts +2 -2
- package/src/content-creation.template.test.ts +52 -0
- package/src/content-creation.yaml +192 -0
- package/src/content-os.edit-loop.template.test.ts +210 -0
- package/src/content-os.publish-due-posts.template.test.ts +261 -0
- package/src/content-os.template.test.ts +349 -0
- package/src/content-os.video-takes.template.test.ts +338 -0
- package/src/content-os.work-a-take.template.test.ts +125 -0
- package/src/content-os.yaml +1873 -39
- package/src/content-studio-retired.template.test.ts +82 -0
- package/src/corpus-flow-validity.test.ts +4 -5
- package/src/crm-absorbs-outreach.template.test.ts +117 -0
- package/src/crm-build-plan.template.test.ts +128 -0
- package/src/crm-lists.template.test.ts +96 -0
- package/src/crm-messages-cadence.template.test.ts +246 -0
- package/src/crm-one-send-door.template.test.ts +65 -0
- package/src/crm.yaml +1563 -181
- package/src/decisions-wave.template.test.ts +186 -0
- package/src/define.ts +146 -17
- package/src/design-to-implementation.template.test.ts +94 -0
- package/src/domain-template-type.test.ts +75 -0
- package/src/ecosystem.yaml +27 -66
- package/src/enterprise-os.yaml +10 -134
- package/src/facecam-edit.audio-studio.test.ts +188 -0
- package/src/fal-generate-image.test.ts +200 -0
- package/src/fal-media-lab-pod-files.test.ts +180 -0
- package/src/finance.yaml +254 -1
- package/src/foundation.yaml +78 -38
- package/src/frame-host-modules.d.mts +6 -0
- package/src/frame-host-modules.mjs +58 -0
- package/src/generate.mjs +9 -3
- package/src/github-merge.test.ts +210 -0
- package/src/grants.yaml +46 -17
- package/src/hr.yaml +39 -6
- package/src/index.ts +36 -1
- package/src/intent.test.ts +161 -0
- package/src/internal-runbook.yaml +19 -1
- package/src/jev.test.ts +356 -0
- package/src/legal.yaml +33 -1
- package/src/life-os.yaml +51 -240
- package/src/lifecycle-coverage.test.ts +124 -0
- package/src/lifecycle-declarations.test.ts +252 -0
- package/src/lifecycle-presets.test.ts +468 -0
- package/src/lifecycle-presets.ts +494 -0
- package/src/lifecycle-stuck.test.ts +46 -0
- package/src/lifecycle-waits.parity.test.ts +319 -0
- package/src/marketing.yaml +108 -4
- package/src/meetings-knowledge.yaml +18 -0
- package/src/networking.yaml +23 -2
- package/src/operations.yaml +304 -4
- package/src/pipeline-views.template.test.ts +88 -0
- package/src/playbook-goal-placeholders.test.ts +57 -0
- package/src/postiz-contract.test.ts +134 -0
- package/src/presentation-declarations.test.ts +174 -0
- package/src/process-contract.validate.test.ts +352 -0
- package/src/project-management.yaml +39 -740
- package/src/publish-body.d.mts +29 -0
- package/src/publish-body.mjs +74 -0
- package/src/publish-body.test.ts +74 -0
- package/src/publish-official-lib.test.ts +176 -0
- package/src/replicate-rvm.test.ts +504 -0
- package/src/research-base.yaml +119 -20
- package/src/research-decision.yaml +60 -3
- package/src/reserved-profile-slugs.test.ts +81 -0
- package/src/retired-goal-kinds.test.ts +52 -0
- package/src/role-reference-and-step-domain.test.ts +192 -0
- package/src/seed-obligation.test.ts +1 -6
- package/src/send-message-maps.test.ts +227 -0
- package/src/shared-role-ssot.test.ts +40 -2
- package/src/ship-a-change.template.test.ts +177 -0
- package/src/skills.test.ts +170 -0
- package/src/social.yaml +85 -27
- package/src/space-packages.freshness.test.ts +97 -0
- package/src/space-packages.generated.ts +40 -0
- package/src/space-packages.test.ts +125 -0
- package/src/space-packages.ts +73 -0
- package/src/start.test.ts +188 -0
- package/src/stripe-capability.test.ts +192 -0
- package/src/task-intents.test.ts +293 -0
- package/src/template-documents.test.ts +158 -0
- package/src/templates.ts +20907 -21464
- package/src/types.ts +475 -61
- package/src/unipile-linkedin-find-profile.test.ts +183 -0
- package/src/unipile-publish-post.test.ts +162 -0
- package/src/validate-flow.drift.test.ts +2 -3
- package/src/validate-flow.ts +22 -2
- package/src/validate-onboarding.test.ts +59 -0
- package/src/validate.test.ts +71 -4
- package/src/validate.ts +1416 -7
- package/src/wire-core.ts +231 -0
- package/src/wire.test.ts +159 -0
- package/src/wire.ts +107 -0
- package/views/bookmark-views-pack.view.json +39 -0
- package/views/event-views-pack.view.json +45 -0
- package/views/task-views-pack.view.json +53 -0
- package/src/agent-fleet.yaml +0 -1645
- package/src/business-developer.template.test.ts +0 -351
- package/src/business-developer.yaml +0 -860
- package/src/content-studio.yaml +0 -361
- package/src/dev-dashboard.yaml +0 -990
- package/src/outreach-comms.yaml +0 -486
- package/src/the-arch.yaml +0 -201
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unipile — finding ONE known person's LinkedIn profile, and paging the people
|
|
3
|
+
* search (CRM wave 2, S4b, build map 6f6c197f).
|
|
4
|
+
*
|
|
5
|
+
* - `linkedin_find_profile` answers a profile only on a STRICT match: the
|
|
6
|
+
* result's name equals the full name (case- and accent-insensitive), the
|
|
7
|
+
* company is in its headline as whole words, and exactly one result is
|
|
8
|
+
* both. Otherwise no match, with up to 3 candidates for a person to choose
|
|
9
|
+
* from. It writes nothing: the match's fields are the caller's proposal
|
|
10
|
+
* (founder decision 7).
|
|
11
|
+
* - `linkedin_search_people` and `linkedin_capture_profiles` page by
|
|
12
|
+
* `cursor`, the reply's `next_cursor` (Unipile v2 performClassicPeopleSearch
|
|
13
|
+
* takes a `cursor` query param and no offset or limit).
|
|
14
|
+
*
|
|
15
|
+
* SEAM: each verb's REAL `code` body, run the way the pod's executor wraps it
|
|
16
|
+
* (`async (args, context) => { <code> }` with `callProvider` and `propose` in
|
|
17
|
+
* scope), against a recorded `callProvider` that answers page by page.
|
|
18
|
+
*
|
|
19
|
+
* NOT covered: what a live LinkedIn search returns (ranking, how the
|
|
20
|
+
* `advanced_keywords.company` filter matches, headline wording), and the pod's
|
|
21
|
+
* governance of the read (finding 4c31855b: a read once came back proposed;
|
|
22
|
+
* the verb then returns `proposed`, tested below with a stubbed answer).
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import { describe, expect, it } from "vitest";
|
|
27
|
+
|
|
28
|
+
const capability = JSON.parse(
|
|
29
|
+
readFileSync(join(import.meta.dirname, "..", "capabilities", "unipile-linkedin.capability.json"), "utf8"),
|
|
30
|
+
) as { spec: { skills: Array<Record<string, any>> } };
|
|
31
|
+
const skill = (name: string) => capability.spec.skills.find((s) => s.name === name)!;
|
|
32
|
+
|
|
33
|
+
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as new (
|
|
34
|
+
...args: string[]
|
|
35
|
+
) => (...a: unknown[]) => Promise<any>;
|
|
36
|
+
|
|
37
|
+
type Call = { tool: string; method: string; path: string; body: Record<string, unknown> };
|
|
38
|
+
type Page = { data: Array<Record<string, unknown>>; next_cursor?: string | null };
|
|
39
|
+
|
|
40
|
+
/** Runs `name`'s code; `pages` answers the provider calls in order (the last one repeats). */
|
|
41
|
+
async function run(name: string, args: Record<string, unknown>, pages: Array<Page | Record<string, unknown>>) {
|
|
42
|
+
const calls: Call[] = [];
|
|
43
|
+
const proposals: Array<Record<string, unknown>> = [];
|
|
44
|
+
const callProvider = async (tool: string, method: string, path: string, body: Record<string, unknown>) => {
|
|
45
|
+
calls.push({ tool, method, path, body });
|
|
46
|
+
const reply = pages[Math.min(calls.length - 1, pages.length - 1)];
|
|
47
|
+
return "proposed" in reply ? reply : { status: 200, body: reply };
|
|
48
|
+
};
|
|
49
|
+
const propose = { entity: async (input: Record<string, unknown>) => (proposals.push(input), { status: "proposed" }) };
|
|
50
|
+
const body = new AsyncFunction("args", "context", "callProvider", "propose", skill(name).code);
|
|
51
|
+
const result = await body(args, {}, callProvider, propose);
|
|
52
|
+
return { result, calls, proposals };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const row = (id: string, display_name: string, headline: string) => ({
|
|
56
|
+
object: "PeopleSearchResult",
|
|
57
|
+
id,
|
|
58
|
+
display_name,
|
|
59
|
+
headline,
|
|
60
|
+
public_identifier: `${id}-slug`,
|
|
61
|
+
profile_url: `https://www.linkedin.com/in/${id}-slug`,
|
|
62
|
+
network_distance: "SECOND_DEGREE",
|
|
63
|
+
});
|
|
64
|
+
const ADA = row("u-ada", "Adèle Lovelace", "CEO at Analytical Engines · Paris");
|
|
65
|
+
const ARGS = { accountId: "acc_1", fullName: "adele LOVELACE", company: "Analytical Engines" };
|
|
66
|
+
|
|
67
|
+
describe("linkedin_find_profile — a strict match or none", () => {
|
|
68
|
+
const verb = skill("linkedin_find_profile");
|
|
69
|
+
|
|
70
|
+
it("is a read the pod runs at once, routed by enrich_entity, and carries no install-time value", () => {
|
|
71
|
+
expect(verb.kind).toBe("code");
|
|
72
|
+
expect(verb.intent).toBe("enrich_entity");
|
|
73
|
+
expect(verb.metadata).toEqual({ verbType: "read" });
|
|
74
|
+
expect(verb.parameters).toEqual({ fullName: "string", company: "string", accountId: "string" });
|
|
75
|
+
expect(verb.requires).toEqual(["unipile_linkedin"]);
|
|
76
|
+
expect(verb.code).not.toMatch(/\{\{\w+\}\}/);
|
|
77
|
+
// It writes nothing: the match is the caller's proposal (decision 7).
|
|
78
|
+
expect(verb.code).not.toMatch(/propose\.entity|callProvider\([^)]*'(PATCH|PUT|DELETE)'/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("one result with the same name (case and accents aside) and the company in its headline: the match", async () => {
|
|
82
|
+
const other = row("u-2", "Ada Byron", "Engineer at Analytical Engines");
|
|
83
|
+
const { result, calls, proposals } = await run("linkedin_find_profile", ARGS, [{ data: [other, ADA], next_cursor: null }]);
|
|
84
|
+
expect(result.matched).toBe(true);
|
|
85
|
+
expect(result.profile).toMatchObject({ name: "Adèle Lovelace", providerId: "u-ada", publicIdentifier: "u-ada-slug" });
|
|
86
|
+
expect(result.properties).toEqual({ linkedinUrl: "https://www.linkedin.com/in/u-ada-slug", linkedinProviderId: "u-ada" });
|
|
87
|
+
expect(result.candidates).toEqual([]);
|
|
88
|
+
expect(proposals).toEqual([]);
|
|
89
|
+
expect(calls).toEqual([
|
|
90
|
+
{
|
|
91
|
+
tool: "unipile_linkedin",
|
|
92
|
+
method: "POST",
|
|
93
|
+
path: "/acc_1/linkedin/search/people",
|
|
94
|
+
body: { keywords: "adele LOVELACE", advanced_keywords: { company: "Analytical Engines" } },
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("two results that both match: no match, both offered as candidates", async () => {
|
|
100
|
+
const twin = row("u-twin", "Adele Lovelace", "Analytical Engines, board member");
|
|
101
|
+
const { result } = await run("linkedin_find_profile", ARGS, [{ data: [ADA, twin] }]);
|
|
102
|
+
expect(result).toMatchObject({ matched: false, reason: "several" });
|
|
103
|
+
expect(result.candidates.map((c: any) => c.providerId)).toEqual(["u-ada", "u-twin"]);
|
|
104
|
+
expect(result.properties).toBeUndefined();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("the name matches but the company is not in the headline, or only inside another word: no match", async () => {
|
|
108
|
+
const elsewhere = row("u-3", "Adèle Lovelace", "Founder at Difference Engines");
|
|
109
|
+
const inside = row("u-4", "Adèle Lovelace", "CTO at AnalyticalEnginesCo");
|
|
110
|
+
const { result } = await run("linkedin_find_profile", ARGS, [{ data: [elsewhere, inside] }]);
|
|
111
|
+
expect(result).toMatchObject({ matched: false, reason: "none" });
|
|
112
|
+
expect(result.candidates.map((c: any) => [c.providerId, c.nameMatches, c.companyInHeadline])).toEqual([
|
|
113
|
+
["u-3", true, false],
|
|
114
|
+
["u-4", true, false],
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("a different name with the right company is never a match, and at most 3 candidates come back, closest first", async () => {
|
|
119
|
+
const rows = [
|
|
120
|
+
row("a", "Ada Lovelace", "CEO at Analytical Engines"),
|
|
121
|
+
row("b", "Bob Smith", "Sales"),
|
|
122
|
+
row("c", "Adèle Lovelace", "Poet"),
|
|
123
|
+
row("d", "Carol King", "Analytical Engines"),
|
|
124
|
+
];
|
|
125
|
+
const { result } = await run("linkedin_find_profile", ARGS, [{ data: rows }]);
|
|
126
|
+
expect(result).toMatchObject({ matched: false, reason: "none" });
|
|
127
|
+
expect(result.candidates.map((c: any) => c.providerId)).toEqual(["c", "a", "d"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("pages by cursor: a match on page 2 with no page after it is the match", async () => {
|
|
131
|
+
const { result, calls } = await run("linkedin_find_profile", ARGS, [
|
|
132
|
+
{ data: [row("x", "Someone Else", "Analytical Engines")], next_cursor: "c2" },
|
|
133
|
+
{ data: [ADA], next_cursor: null },
|
|
134
|
+
]);
|
|
135
|
+
expect(result.matched).toBe(true);
|
|
136
|
+
expect(calls.map((c) => c.path)).toEqual(["/acc_1/linkedin/search/people", "/acc_1/linkedin/search/people?cursor=c2"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("a third page left unread could hold another match: no match (more_results), and it stops after 2 pages", async () => {
|
|
140
|
+
const { result, calls } = await run("linkedin_find_profile", ARGS, [
|
|
141
|
+
{ data: [ADA], next_cursor: "c2" },
|
|
142
|
+
{ data: [row("y", "Other", "x")], next_cursor: "c3" },
|
|
143
|
+
]);
|
|
144
|
+
expect(result).toMatchObject({ matched: false, reason: "more_results" });
|
|
145
|
+
expect(result.searched).toMatchObject({ pages: 2, moreUnread: true });
|
|
146
|
+
expect(calls).toHaveLength(2);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("a search the pod held for review is no match and says so", async () => {
|
|
150
|
+
const { result } = await run("linkedin_find_profile", ARGS, [{ proposed: true, proposalId: "p-1" }]);
|
|
151
|
+
expect(result).toEqual({ matched: false, reason: "proposed", proposed: true, proposalId: "p-1", candidates: [] });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("a failed search throws; a missing company or name is refused before any call", async () => {
|
|
155
|
+
await expect(run("linkedin_find_profile", ARGS, [{ proposed: false, status: 429 } as any])).rejects.toThrow(/status 429/);
|
|
156
|
+
await expect(run("linkedin_find_profile", { ...ARGS, company: " " }, [{ data: [] }])).rejects.toThrow(/company is required/);
|
|
157
|
+
await expect(run("linkedin_find_profile", { ...ARGS, fullName: "" }, [{ data: [] }])).rejects.toThrow(/fullName/);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("the people search pages by cursor", () => {
|
|
162
|
+
it.each(["linkedin_search_people", "linkedin_capture_profiles"])("%s takes a cursor and returns the next one", async (name) => {
|
|
163
|
+
expect(skill(name).parameters.cursor).toBe("string?");
|
|
164
|
+
const first = await run(name, { accountId: "acc_1", keywords: "founders lyon" }, [{ data: [ADA], next_cursor: "n2" }]);
|
|
165
|
+
expect(first.calls[0].path).toBe("/acc_1/linkedin/search/people");
|
|
166
|
+
expect(first.calls[0].body).toEqual({ keywords: "founders lyon" });
|
|
167
|
+
expect(first.result.cursor).toBe("n2");
|
|
168
|
+
|
|
169
|
+
const second = await run(name, { accountId: "acc_1", keywords: "founders lyon", cursor: "n2" }, [{ data: [ADA], next_cursor: null }]);
|
|
170
|
+
expect(second.calls[0].path).toBe("/acc_1/linkedin/search/people?cursor=n2");
|
|
171
|
+
expect(second.result.cursor).toBeNull();
|
|
172
|
+
// v2 takes no offset or limit: page 2 is reached by the cursor alone (the
|
|
173
|
+
// exact paths above carry no other query param).
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("capture still proposes each row of the page, and search still returns them", async () => {
|
|
177
|
+
const capture = await run("linkedin_capture_profiles", { accountId: "acc_1", keywords: "k", cursor: "n2" }, [{ data: [ADA] }]);
|
|
178
|
+
expect(capture.proposals).toHaveLength(1);
|
|
179
|
+
expect(capture.proposals[0]).toMatchObject({ profileSlug: "person", title: "Adèle Lovelace" });
|
|
180
|
+
const search = await run("linkedin_search_people", { accountId: "acc_1", keywords: "k", limit: 1 }, [{ data: [ADA, ADA] }]);
|
|
181
|
+
expect(search.result.count).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unipile — what a pod app may ask it to publish (intent actions, decision
|
|
3
|
+
* ef0e917c, 2026-10-28; the Postiz precedent is postiz-contract.test.ts).
|
|
4
|
+
*
|
|
5
|
+
* A pod app asks the person's tools for `publish_post` with ONE input
|
|
6
|
+
* `{ text, media?, link?, when? }`. Unipile reaches several accounts through one
|
|
7
|
+
* key, so `unipile_publish_post` names an account source and the pod offers one
|
|
8
|
+
* action per account: the shared `unipile_list_accounts`, every provider, so
|
|
9
|
+
* LinkedIn and Instagram each get their own action (founder, 28 Oct, on
|
|
10
|
+
* decision 34103d1b). The post's images and videos come as `file[]:bytes`
|
|
11
|
+
* (phase 2c of plan e7c3c98e): the code holds one handle per pod file and the
|
|
12
|
+
* pod streams the bytes into a multipart part, so Instagram can post.
|
|
13
|
+
*
|
|
14
|
+
* SEAM: the verb's REAL `code` body, run the way the pod's executor wraps it
|
|
15
|
+
* (`async (args, context) => { <code> }` with `callProvider` in scope), against
|
|
16
|
+
* a recorded `callProvider`.
|
|
17
|
+
*
|
|
18
|
+
* NOT covered: the pod's own checks of the map and of `file[]:bytes`
|
|
19
|
+
* (backend `intent-input.ts`, `file-parameters.ts`), the handles turning into
|
|
20
|
+
* bytes (backend `pod-file-body.ts`), and whether Unipile accepts the request
|
|
21
|
+
* (not exercised against a live Unipile).
|
|
22
|
+
*/
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { describe, expect, it } from "vitest";
|
|
26
|
+
|
|
27
|
+
const capability = JSON.parse(
|
|
28
|
+
readFileSync(join(import.meta.dirname, "..", "capabilities", "unipile-linkedin.capability.json"), "utf8"),
|
|
29
|
+
) as { spec: { skills: Array<Record<string, any>>; provides?: string[] } };
|
|
30
|
+
|
|
31
|
+
const skill = (name: string) => capability.spec.skills.find((s) => s.name === name)!;
|
|
32
|
+
const verb = skill("unipile_publish_post");
|
|
33
|
+
|
|
34
|
+
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as new (
|
|
35
|
+
...args: string[]
|
|
36
|
+
) => (...a: unknown[]) => Promise<unknown>;
|
|
37
|
+
|
|
38
|
+
type Call = { tool: string; method: string; path: string; body: Record<string, unknown>; opts?: Record<string, unknown> };
|
|
39
|
+
async function run(args: Record<string, unknown>, reply: Record<string, unknown> = { status: 201, body: { id: "p1", share_url: "https://x/p1" } }) {
|
|
40
|
+
const calls: Call[] = [];
|
|
41
|
+
const callProvider = async (tool: string, method: string, path: string, body: Record<string, unknown>, opts?: Record<string, unknown>) => {
|
|
42
|
+
calls.push({ tool, method, path, body, opts });
|
|
43
|
+
return reply;
|
|
44
|
+
};
|
|
45
|
+
const body = new AsyncFunction("args", "context", "callProvider", "propose", verb.code);
|
|
46
|
+
const result = await body(args, {}, callProvider, {});
|
|
47
|
+
return { result, calls };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("Unipile — what a pod app may ask it to publish", () => {
|
|
51
|
+
it("unipile_publish_post maps the text, the media and the account picked from unipile_list_accounts", () => {
|
|
52
|
+
expect(verb.intent).toBe("publish_post");
|
|
53
|
+
expect(verb.metadata).toEqual({ verbType: "write" });
|
|
54
|
+
expect(verb.inputMap).toEqual({
|
|
55
|
+
params: { text: "$.text", accountId: "$account.id", provider: "$account.provider", media: "$.media" },
|
|
56
|
+
accounts: { verb: "unipile_list_accounts", id: "id", label: "name", provider: "provider" },
|
|
57
|
+
});
|
|
58
|
+
expect(capability.spec.provides).toContain("publish_post");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("Unipile has no scheduling and links stay out: the map never reads when or link", () => {
|
|
62
|
+
const read = Object.values(verb.inputMap.params).filter((s) => !String(s).startsWith("$account."));
|
|
63
|
+
expect(read.sort()).toEqual(["$.media", "$.text"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("the media are pod files sent as bytes, so the verb is code run in the pod, toward its own tool", () => {
|
|
67
|
+
expect(verb.kind).toBe("code");
|
|
68
|
+
expect(verb.executionMode).not.toBe("box");
|
|
69
|
+
expect(verb.parameters).toEqual({
|
|
70
|
+
accountId: "string",
|
|
71
|
+
text: "string",
|
|
72
|
+
provider: "string?",
|
|
73
|
+
media: "file[]:bytes?",
|
|
74
|
+
postAs: "string?",
|
|
75
|
+
quotedPostId: "string?",
|
|
76
|
+
});
|
|
77
|
+
expect(verb.requires).toEqual(["unipile_linkedin"]);
|
|
78
|
+
// An install-time value in the code would stop the pod updating Unipile.
|
|
79
|
+
expect(verb.code).not.toMatch(/\{\{\w+\}\}/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("text alone is a JSON post of exactly { text } to /{accountId}/posts", async () => {
|
|
83
|
+
const { result, calls } = await run({ accountId: "acc_1", text: "Hello", provider: "linkedin" });
|
|
84
|
+
expect(calls).toEqual([{ tool: "unipile_linkedin", method: "POST", path: "/acc_1/posts", body: { text: "Hello" }, opts: undefined }]);
|
|
85
|
+
expect(result).toEqual({ postId: "p1", shareUrl: "https://x/p1", attachments: 0 });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("with media, a multipart form: the text field and one attachments[i] part per pod file", async () => {
|
|
89
|
+
const { result, calls } = await run({ accountId: "acc_2", text: "Caption", provider: "instagram", media: ["podfile:a", "podfile:b"] });
|
|
90
|
+
expect(calls).toEqual([
|
|
91
|
+
{
|
|
92
|
+
tool: "unipile_linkedin",
|
|
93
|
+
method: "POST",
|
|
94
|
+
path: "/acc_2/posts",
|
|
95
|
+
body: { text: "Caption", "attachments[0]": "podfile:a", "attachments[1]": "podfile:b" },
|
|
96
|
+
opts: { multipart: true },
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
expect(result).toMatchObject({ postId: "p1", attachments: 2 });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("refuses before any call: Instagram with no media, an account with no posts, no text", async () => {
|
|
103
|
+
await expect(run({ accountId: "acc_2", text: "Caption", provider: "instagram" })).rejects.toThrow(/Instagram needs at least one image or video/);
|
|
104
|
+
await expect(run({ accountId: "acc_3", text: "Hi", provider: "whatsapp" })).rejects.toThrow(/whatsapp account has no posts/);
|
|
105
|
+
await expect(run({ accountId: "acc_1", text: " ", provider: "linkedin" })).rejects.toThrow(/text is required/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("as a LinkedIn company page: post_as beside the text, as JSON and in the multipart form", async () => {
|
|
109
|
+
const { calls } = await run({ accountId: "acc_1", text: "Hello", provider: "linkedin", postAs: "12345" });
|
|
110
|
+
expect(calls[0]).toMatchObject({ path: "/acc_1/posts", body: { text: "Hello", post_as: "12345" }, opts: undefined });
|
|
111
|
+
const multi = await run({ accountId: "acc_1", text: "Hi", provider: "linkedin", postAs: "12345", media: ["podfile:a"] });
|
|
112
|
+
expect(multi.calls[0]).toMatchObject({ body: { text: "Hi", post_as: "12345", "attachments[0]": "podfile:a" }, opts: { multipart: true } });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("a repost: quoted_post_id, with empty text a plain repost, with text a quote", async () => {
|
|
116
|
+
const plain = await run({ accountId: "acc_1", text: "", provider: "linkedin", quotedPostId: "WzEy" });
|
|
117
|
+
expect(plain.calls[0].body).toEqual({ text: "", quoted_post_id: "WzEy" });
|
|
118
|
+
const quote = await run({ accountId: "acc_1", text: "Worth a read", quotedPostId: "WzEy", postAs: "12345" });
|
|
119
|
+
expect(quote.calls[0].body).toEqual({ text: "Worth a read", post_as: "12345", quoted_post_id: "WzEy" });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("an empty company page or original is left out, and Instagram refuses both before any call", async () => {
|
|
123
|
+
const { calls } = await run({ accountId: "acc_1", text: "Hello", provider: "linkedin", postAs: " ", quotedPostId: "" });
|
|
124
|
+
expect(calls[0].body).toEqual({ text: "Hello" });
|
|
125
|
+
await expect(run({ accountId: "acc_2", text: "C", provider: "instagram", media: ["podfile:a"], quotedPostId: "WzEy" })).rejects.toThrow(/Instagram has no company page post or repost/);
|
|
126
|
+
await expect(run({ accountId: "acc_2", text: "C", provider: "instagram", media: ["podfile:a"], postAs: "1" })).rejects.toThrow(/Instagram has no company page post or repost/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("linkedin_list_company_pages: a read of the same tool, listing each page's id and name", () => {
|
|
130
|
+
const pages = skill("linkedin_list_company_pages");
|
|
131
|
+
expect(pages).toMatchObject({ intent: "list_records", kind: "declarative", metadata: { readOnly: true }, parameters: { accountId: "string" } });
|
|
132
|
+
expect(pages.requires).toEqual(verb.requires);
|
|
133
|
+
expect(pages.providerSpec).toMatchObject({
|
|
134
|
+
tool: "unipile_linkedin",
|
|
135
|
+
method: "GET",
|
|
136
|
+
pathTemplate: "/{{accountId}}/linkedin/company/pages",
|
|
137
|
+
responseShape: { collectionPath: "data", item: { id: "id", name: "name" } },
|
|
138
|
+
});
|
|
139
|
+
// A read never maps into an app's intent: nothing publishes through it.
|
|
140
|
+
expect(pages.inputMap).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("a refused post throws, and a proposed call is never reported as published", async () => {
|
|
144
|
+
await expect(run({ accountId: "acc_1", text: "Hello" }, { status: 422, body: {} })).rejects.toThrow(/status 422/);
|
|
145
|
+
const { result } = await run({ accountId: "acc_1", text: "Hello" }, { proposed: true, proposalId: "pr1" });
|
|
146
|
+
expect(result).toEqual({ success: false, proposed: true, proposalId: "pr1" });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("the account source is a read of the same tool that lists every account with the fields it names", () => {
|
|
150
|
+
const list = skill("unipile_list_accounts");
|
|
151
|
+
expect(list.metadata).toEqual({ readOnly: true });
|
|
152
|
+
expect(list.requires).toEqual(verb.requires);
|
|
153
|
+
expect(list.providerSpec).toMatchObject({ method: "GET", pathTemplate: "/accounts/" });
|
|
154
|
+
expect(list.providerSpec.query).toBeUndefined();
|
|
155
|
+
expect(Object.keys(list.providerSpec.responseShape.item)).toEqual(expect.arrayContaining(["id", "name", "provider"]));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("one Unipile verb publishes for apps", () => {
|
|
159
|
+
const mapped = capability.spec.skills.filter((s) => s.inputMap && s.intent === "publish_post").map((s) => s.name);
|
|
160
|
+
expect(mapped).toEqual(["unipile_publish_post"]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -22,6 +22,7 @@ import { describe, expect, it } from "vitest";
|
|
|
22
22
|
import { readFileSync, existsSync } from "node:fs";
|
|
23
23
|
import { dirname, join } from "node:path";
|
|
24
24
|
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { stripComments } from "../../design-tokens/scripts/strip-comments.mjs";
|
|
25
26
|
|
|
26
27
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
27
28
|
const OWN_COPY = join(HERE, "validate-flow.ts");
|
|
@@ -40,11 +41,9 @@ const BACKEND_COPY = join(
|
|
|
40
41
|
* normalize whitespace.
|
|
41
42
|
*/
|
|
42
43
|
function normalizeFlowValidatorSource(src: string): string {
|
|
43
|
-
return src
|
|
44
|
+
return stripComments(src, "ts")
|
|
44
45
|
.replace(/import type \{[^}]*\} from ["']@synap\/database["'];/g, "")
|
|
45
46
|
.replace(/type AssertExact<[\s\S]*?void _outputTypesInSync;/g, "")
|
|
46
|
-
.replace(/\/\*[\s\S]*?\*\//g, "") // block comments (incl. JSDoc)
|
|
47
|
-
.replace(/\/\/.*$/gm, "") // line + trailing comments
|
|
48
47
|
.split("\n")
|
|
49
48
|
.map((l) => l.replace(/\s+/g, " ").trim())
|
|
50
49
|
.filter((l) => l.length > 0)
|
package/src/validate-flow.ts
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
* Until then: keep the constant arrays below identical to `@synap/database`
|
|
36
36
|
* `schema/automations.ts`.
|
|
37
37
|
*/
|
|
38
|
+
import { PLAYBOOK_RUN_MODES } from "@synap-core/types/automations";
|
|
38
39
|
|
|
39
40
|
// ── Contract constants (mirror schema/automations.ts — keep in sync) ──────────
|
|
40
41
|
|
|
@@ -65,9 +66,10 @@ export const FLOW_NODE_TYPES = [
|
|
|
65
66
|
"playbook_run",
|
|
66
67
|
] as const;
|
|
67
68
|
|
|
68
|
-
/** The
|
|
69
|
+
/** The 15 output types — `OutputNodeDef.data.outputType` (schema/automations.ts). */
|
|
69
70
|
export const FLOW_OUTPUT_TYPES = [
|
|
70
71
|
"notification",
|
|
72
|
+
"record_observation",
|
|
71
73
|
"entity_create",
|
|
72
74
|
"entity_update",
|
|
73
75
|
"facet_attach",
|
|
@@ -77,6 +79,9 @@ export const FLOW_OUTPUT_TYPES = [
|
|
|
77
79
|
"webhook",
|
|
78
80
|
"channel_message",
|
|
79
81
|
"session_update",
|
|
82
|
+
"complete_outcome",
|
|
83
|
+
"post_evidence",
|
|
84
|
+
"reevaluate_criterion",
|
|
80
85
|
"set_state",
|
|
81
86
|
] as const;
|
|
82
87
|
|
|
@@ -380,6 +385,21 @@ export function validateFlowDefinition(
|
|
|
380
385
|
}) that does not exist.`,
|
|
381
386
|
});
|
|
382
387
|
}
|
|
388
|
+
// An unknown `mode` would silently RUN (`readPlaybookRunMode` reads it
|
|
389
|
+
// as the default) — the opposite of what an author who typed
|
|
390
|
+
// "propose" wanted. Refused by name at the door instead.
|
|
391
|
+
if (
|
|
392
|
+
data.mode !== undefined &&
|
|
393
|
+
!(PLAYBOOK_RUN_MODES as readonly unknown[]).includes(data.mode)
|
|
394
|
+
) {
|
|
395
|
+
errors.push({
|
|
396
|
+
nodeId,
|
|
397
|
+
code: "playbook_run_bad_mode",
|
|
398
|
+
message: `Playbook-run node "${nodeId}" has mode ${JSON.stringify(
|
|
399
|
+
data.mode
|
|
400
|
+
)}; expected one of ${PLAYBOOK_RUN_MODES.join(", ")}.`,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
383
403
|
break;
|
|
384
404
|
}
|
|
385
405
|
|
|
@@ -671,7 +691,7 @@ function collectLoopBindingRefs(data: unknown): string[] {
|
|
|
671
691
|
// never evaluates (`_filterNote` and friends explain what DOWNSTREAM
|
|
672
692
|
// nodes do, and so legitimately quote `{{loop.item...}}` in prose).
|
|
673
693
|
// Reading them as live references made a correct flow fail validation:
|
|
674
|
-
//
|
|
694
|
+
// a (since-removed) grant capability was reported as "references {{loop.item}} but is
|
|
675
695
|
// not inside a loop body" purely because its note DESCRIBED the nodes
|
|
676
696
|
// that do. Kept identical in all three copies of this walker.
|
|
677
697
|
for (const [k, x] of Object.entries(v)) {
|
|
@@ -85,4 +85,63 @@ describe("validateTemplate onboarding contract", () => {
|
|
|
85
85
|
])
|
|
86
86
|
);
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
it("a steady-state brief needs no interview: purpose + anchors + fetch is valid", () => {
|
|
90
|
+
const result = validateTemplate(
|
|
91
|
+
templateWithOnboarding({
|
|
92
|
+
purpose: "Where records live.",
|
|
93
|
+
anchors: [{ profileSlug: "record", role: "root", limit: 1 }],
|
|
94
|
+
fetch: [{ profileSlug: "record", note: "the live ones" }],
|
|
95
|
+
}),
|
|
96
|
+
{}
|
|
97
|
+
);
|
|
98
|
+
expect(result, JSON.stringify(result.errors)).toEqual({ ok: true, errors: [] });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("an interview still requires its persona and collect targets", () => {
|
|
102
|
+
const result = validateTemplate(
|
|
103
|
+
templateWithOnboarding({ goal: "Set it up.", openingQuestions: ["Why?"] }),
|
|
104
|
+
{}
|
|
105
|
+
);
|
|
106
|
+
expect(result.errors.map((e) => e.rule)).toEqual(
|
|
107
|
+
expect.arrayContaining(["onboarding-framing-empty", "onboarding-collect-empty"])
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("rejects a brief that says nothing, bad anchors, bad fetch hints and bad minimums", () => {
|
|
112
|
+
const result = validateTemplate(
|
|
113
|
+
templateWithOnboarding({
|
|
114
|
+
anchors: [
|
|
115
|
+
{ profileSlug: "record", role: "root", seedRef: "Nope" },
|
|
116
|
+
{ profileSlug: "record", role: "root" },
|
|
117
|
+
{ profileSlug: "ghost", role: "context" },
|
|
118
|
+
],
|
|
119
|
+
fetch: [{ note: "no target" }],
|
|
120
|
+
collect: [{ profileSlug: "record", what: "x", min: 0 }],
|
|
121
|
+
}),
|
|
122
|
+
{}
|
|
123
|
+
);
|
|
124
|
+
expect(result.errors.map((e) => e.rule)).toEqual(
|
|
125
|
+
expect.arrayContaining([
|
|
126
|
+
"onboarding-brief-empty",
|
|
127
|
+
"onboarding-anchor-seedref-unknown",
|
|
128
|
+
"onboarding-anchor-invalid",
|
|
129
|
+
"onboarding-fetch-invalid",
|
|
130
|
+
"onboarding-collect-min-invalid",
|
|
131
|
+
])
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("template rules need a unique kebab key and an intent", () => {
|
|
136
|
+
const tpl = templateWithOnboarding(undefined);
|
|
137
|
+
tpl.rules = [
|
|
138
|
+
{ key: "use-approved", intent: "Use approved assets first." },
|
|
139
|
+
{ key: "use-approved", intent: "Dup." },
|
|
140
|
+
{ key: "Bad Key", intent: "" },
|
|
141
|
+
];
|
|
142
|
+
const rules = validateTemplate(tpl, {}).errors.map((e) => e.rule);
|
|
143
|
+
expect(rules).toEqual(
|
|
144
|
+
expect.arrayContaining(["rule-key-duplicate", "rule-key-invalid", "rule-intent-empty"])
|
|
145
|
+
);
|
|
146
|
+
});
|
|
88
147
|
});
|
package/src/validate.test.ts
CHANGED
|
@@ -15,7 +15,7 @@ import type { WorkspaceYaml } from "./types.js";
|
|
|
15
15
|
describe("validateTemplate — fires on a bad template", () => {
|
|
16
16
|
const bad = {
|
|
17
17
|
// meta.slug MISSING — required field.
|
|
18
|
-
meta: { name: "Bad", description: "d", icon: "x", color: "
|
|
18
|
+
meta: { name: "Bad", description: "d", icon: "x", color: "violet" },
|
|
19
19
|
workspace: { name: "Bad", description: "d" },
|
|
20
20
|
profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
|
|
21
21
|
automations: [
|
|
@@ -65,7 +65,7 @@ describe("validateTemplate — clean template", () => {
|
|
|
65
65
|
name: "Good",
|
|
66
66
|
description: "d",
|
|
67
67
|
icon: "x",
|
|
68
|
-
color: "
|
|
68
|
+
color: "violet",
|
|
69
69
|
},
|
|
70
70
|
workspace: { name: "Good", description: "d" },
|
|
71
71
|
profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
|
|
@@ -88,7 +88,7 @@ describe("validateTemplate — system Knowledge contract", () => {
|
|
|
88
88
|
name: "Knowledge consumer",
|
|
89
89
|
description: "d",
|
|
90
90
|
icon: "brain",
|
|
91
|
-
color: "
|
|
91
|
+
color: "violet",
|
|
92
92
|
},
|
|
93
93
|
workspace: { name: "Knowledge consumer", description: "d" },
|
|
94
94
|
profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
|
|
@@ -178,7 +178,7 @@ describe("validateTemplate — automation flow node contract", () => {
|
|
|
178
178
|
name: "Flowy",
|
|
179
179
|
description: "d",
|
|
180
180
|
icon: "x",
|
|
181
|
-
color: "
|
|
181
|
+
color: "violet",
|
|
182
182
|
},
|
|
183
183
|
workspace: { name: "Flowy", description: "d" },
|
|
184
184
|
profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
|
|
@@ -286,3 +286,70 @@ describe("validateTemplate — automation flow node contract", () => {
|
|
|
286
286
|
});
|
|
287
287
|
});
|
|
288
288
|
});
|
|
289
|
+
|
|
290
|
+
describe("validateTemplate — a pack needs no profile shell (D8)", () => {
|
|
291
|
+
const pack = (tags: string[], deps: unknown[]) =>
|
|
292
|
+
({
|
|
293
|
+
meta: {
|
|
294
|
+
slug: "acme-suite",
|
|
295
|
+
name: "Acme",
|
|
296
|
+
description: "d",
|
|
297
|
+
icon: "x",
|
|
298
|
+
color: "violet",
|
|
299
|
+
tags,
|
|
300
|
+
},
|
|
301
|
+
workspace: { name: "Acme", description: "d" },
|
|
302
|
+
profiles: [],
|
|
303
|
+
dependencies: deps,
|
|
304
|
+
}) as unknown as WorkspaceYaml;
|
|
305
|
+
const profileErrors = (t: WorkspaceYaml) =>
|
|
306
|
+
validateTemplate(t, {}).errors.filter((e) => e.rule === "profiles-required");
|
|
307
|
+
|
|
308
|
+
it("a suite that requires a workspace is valid without profiles", () => {
|
|
309
|
+
expect(
|
|
310
|
+
profileErrors(pack(["suite"], [{ slug: "crm", kind: "workspace", relation: "require" }]))
|
|
311
|
+
).toEqual([]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("a suite requiring NO workspace still needs a profile", () => {
|
|
315
|
+
expect(profileErrors(pack(["suite"], []))).toHaveLength(1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("a non-suite template without profiles is still refused", () => {
|
|
319
|
+
expect(
|
|
320
|
+
profileErrors(pack([], [{ slug: "crm", kind: "workspace", relation: "require" }]))
|
|
321
|
+
).toHaveLength(1);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("validateTemplate — entityLinks[].required", () => {
|
|
326
|
+
const withLinks = (entityLinks: unknown[]) =>
|
|
327
|
+
({
|
|
328
|
+
meta: { slug: "links", name: "Links", description: "d", icon: "x", color: "violet" },
|
|
329
|
+
workspace: { name: "Links", description: "d" },
|
|
330
|
+
profiles: [
|
|
331
|
+
{ slug: "gadget", displayName: "Gadget", properties: [] },
|
|
332
|
+
{ slug: "widget", displayName: "Widget", properties: [] },
|
|
333
|
+
],
|
|
334
|
+
entityLinks,
|
|
335
|
+
}) as unknown as WorkspaceYaml;
|
|
336
|
+
const link = { sourceProfileSlug: "gadget", targetProfileSlug: "widget", type: "relates_to" };
|
|
337
|
+
const requiredErrors = (t: WorkspaceYaml) =>
|
|
338
|
+
validateTemplate(t, {}).errors.filter((e) => e.rule === "entitylink-required-not-boolean");
|
|
339
|
+
|
|
340
|
+
it("accepts required: true, required: false and no required", () => {
|
|
341
|
+
const result = validateTemplate(
|
|
342
|
+
withLinks([{ ...link, required: true }, { ...link, type: "part_of", required: false }, { ...link, type: "mentions" }]),
|
|
343
|
+
{}
|
|
344
|
+
);
|
|
345
|
+
expect(result, JSON.stringify(result.errors)).toEqual({ ok: true, errors: [] });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("refuses a required that is not a boolean, naming the link", () => {
|
|
349
|
+
for (const bad of ["yes", 1, "true", null]) {
|
|
350
|
+
const errors = requiredErrors(withLinks([{ ...link, required: bad }]));
|
|
351
|
+
expect(errors, String(bad)).toHaveLength(1);
|
|
352
|
+
expect(errors[0]!.message).toContain("gadget→widget (relates_to)");
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
});
|