@teamscala/service-boot 0.23.523 → 0.23.525
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/package.json +1 -9
- package/src/agreement-gate/agreement-lookup.ts +0 -7
- package/src/agreement-gate/authorized-emails.ts +0 -14
- package/src/agreement-gate/placeholder-strip.ts +0 -10
- package/src/agreement-gate/resolution-caches.ts +0 -5
- package/src/agreement-gate/settings.ts +0 -22
- package/src/agreement-gate/signing-session.ts +0 -8
- package/src/agreement-gate/signing-shell.ts +0 -9
- package/src/agreement-gate.ts +0 -52
- package/src/apply-logger-config.ts +1 -9
- package/src/asset-cache-key.ts +1 -5
- package/src/asset-optimize.ts +1 -7
- package/src/asset-proxy.ts +1 -10
- package/src/asset-social-card.ts +1 -9
- package/src/attach-agreement-magic.ts +0 -14
- package/src/attach-portal-members.ts +0 -17
- package/src/attach-proposal-accept.ts +1 -21
- package/src/attach-proposal-magic.ts +1 -13
- package/src/attach-transfers-upload.ts +0 -21
- package/src/auth/sign-out.ts +1 -44
- package/src/auth-gate/public-paths.ts +1 -96
- package/src/auth-gate.ts +1 -41
- package/src/bind-page-handler.ts +1 -83
- package/src/build-client-assets/build-lock.ts +1 -11
- package/src/build-client-assets/hashed-output.ts +1 -11
- package/src/build-client-assets/hydrate-bundle.ts +1 -137
- package/src/build-client-assets/manifest.ts +1 -30
- package/src/build-client-assets/source-candidates.ts +0 -51
- package/src/build-client-assets/sourcemaps-upload.ts +1 -52
- package/src/build-client-assets/styles-build.ts +1 -71
- package/src/build-client-assets.ts +1 -30
- package/src/capability-slug.ts +1 -45
- package/src/compose-page-title.ts +1 -34
- package/src/content-gate/access.ts +0 -34
- package/src/content-gate/catalog.ts +0 -52
- package/src/content-gate/config-types.ts +3 -90
- package/src/content-gate/gate-entry-clears.ts +0 -18
- package/src/content-gate/gate.ts +0 -27
- package/src/content-gate/parse-cookie.ts +0 -4
- package/src/content-page-floor.ts +0 -28
- package/src/csrf-protection.ts +1 -28
- package/src/error-page-logo.ts +2 -35
- package/src/expand-roster.ts +1 -25
- package/src/gate-ttls.ts +0 -12
- package/src/gpt-gate.ts +1 -19
- package/src/hono-error-handler.ts +0 -19
- package/src/install-harness.ts +1 -12
- package/src/install-logging.ts +1 -47
- package/src/load-page-manifest.ts +1 -29
- package/src/load-service-install-row.ts +42 -10
- package/src/module-not-found.ts +0 -17
- package/src/mount-orpc-router.ts +1 -94
- package/src/mount-server-packages.ts +1 -37
- package/src/mount.ts +1 -4
- package/src/mutable-app-ref.ts +0 -21
- package/src/orpc-contract-rows.json +289 -0
- package/src/orpc-openapi-document.ts +1 -19
- package/src/orpc-openapi-gate.ts +1 -23
- package/src/page-route-shell-types.ts +1 -8
- package/src/page-server-loader-graph.ts +0 -10
- package/src/page-server-orpc/server-client.ts +1 -23
- package/src/pg-boss-handler-registry.ts +1 -24
- package/src/pg-boss-mount.ts +1 -109
- package/src/proposal-authorized-emails.ts +0 -32
- package/src/proposal-gate.ts +1 -52
- package/src/quiz-gate.ts +1 -31
- package/src/read-html-template.ts +1 -49
- package/src/readiness-gate.ts +0 -8
- package/src/register-api-routes.ts +1 -11
- package/src/register-crash-handlers.ts +1 -6
- package/src/register-page-routes.ts +1 -12
- package/src/register-page-server-client.ts +1 -66
- package/src/register-registry-page-route/ai-chat-prefetch.ts +1 -24
- package/src/register-registry-page-route/apply-page-branding.ts +1 -100
- package/src/register-registry-page-route/captured-loader-config.ts +1 -12
- package/src/register-registry-page-route/graph-cache.ts +1 -38
- package/src/register-registry-page-route/live-loader-config.ts +1 -22
- package/src/register-registry-page-route/meta-title.ts +1 -36
- package/src/register-registry-page-route/page-data-shared.ts +1 -18
- package/src/register-registry-page-route/prefetch-org-id.ts +1 -24
- package/src/register-registry-page-route/proposal-approval-email-token.ts +1 -22
- package/src/register-registry-page-route/proposal-brand-stamping.ts +1 -31
- package/src/register-registry-page-route/public-shell-page-data.ts +1 -34
- package/src/register-registry-page-route/region-slots-page-data.ts +1 -8
- package/src/register-registry-page-route/resolve-page-data.ts +1 -10
- package/src/register-registry-page-route/resolve-panel-page.ts +1 -10
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -68
- package/src/register-registry-page-route/run-content-gates.ts +3 -64
- package/src/register-registry-page-route/single-page-data.ts +1 -9
- package/src/register-registry-page-route/stamp-session-context.ts +1 -10
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +1 -18
- package/src/register-registry-page-route.ts +1 -93
- package/src/register-static-assets.ts +1 -53
- package/src/register-ws-routes.ts +1 -21
- package/src/resolve-brand-favicon.ts +0 -22
- package/src/resolve-service-port.ts +1 -61
- package/src/resolve-theme-mode.ts +1 -30
- package/src/resolvers/app-header.ts +1 -20
- package/src/resolvers/fetch-org-brand.ts +1 -11
- package/src/resolvers/portal-route-paths.ts +1 -29
- package/src/resolvers/website-by-host-map.ts +1 -7
- package/src/resource-not-found.ts +0 -48
- package/src/run-boot-hook-list.ts +1 -13
- package/src/run-boot-hooks.ts +1 -19
- package/src/scala-tools-currency-watchdog.ts +1 -7
- package/src/security-headers.ts +1 -7
- package/src/select-service-routes.ts +1 -31
- package/src/server-error-page.ts +0 -18
- package/src/theme-fonts-link.ts +1 -21
- package/src/transfers/presigned-upload.ts +1 -27
- package/src/two-phase-full-app.ts +0 -82
- package/src/two-phase-minimal-app.ts +1 -111
- package/src/two-phase-readiness.ts +0 -6
- package/src/types.ts +0 -17
- package/src/ui-service.ts +2 -48
- package/src/ws-boot-self-test.ts +1 -14
- package/src/content-gate/gate-entry-marker.test.ts +0 -111
- package/src/content-gate/token.segmentation.test.ts +0 -76
- package/src/content-gate/token.test.ts +0 -65
- package/src/content-gate/token.ts +0 -219
- package/src/is-react-element-type.test.ts +0 -45
- package/src/is-react-element-type.ts +0 -20
- package/src/page-server-orpc/hub-mcp-client.ts +0 -20
- package/src/pg-boss-mount.test.ts +0 -101
- package/src/resource-chat-inject.page-assistant.test.ts +0 -145
- package/src/resource-chat-inject.test.ts +0 -240
- package/src/resource-chat-inject.ts +0 -374
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
9
|
-
|
|
10
|
-
import { expect, test } from "bun:test";
|
|
11
|
-
import { forwardRef, lazy, memo } from "react";
|
|
12
|
-
import { isReactElementType } from "./is-react-element-type.ts";
|
|
13
|
-
|
|
14
|
-
function Plain() {
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
test("accepts a plain function component", () => {
|
|
19
|
-
expect(isReactElementType(Plain)).toBe(true);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("accepts memo() — the PresenceAvatars shape that rendered blank", () => {
|
|
23
|
-
expect(isReactElementType(memo(Plain))).toBe(true);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("accepts forwardRef()", () => {
|
|
27
|
-
expect(isReactElementType(forwardRef(() => null))).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("accepts lazy()", () => {
|
|
31
|
-
expect(isReactElementType(lazy(async () => ({ default: Plain })))).toBe(true);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("still rejects what genuinely cannot render", () => {
|
|
35
|
-
expect(isReactElementType(undefined)).toBe(false);
|
|
36
|
-
expect(isReactElementType(null)).toBe(false);
|
|
37
|
-
expect(isReactElementType({})).toBe(false);
|
|
38
|
-
expect(isReactElementType("PresenceAvatarsBlock")).toBe(false);
|
|
39
|
-
expect(isReactElementType(42)).toBe(false);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("rejects a plain object wearing a non-symbol $$typeof", () => {
|
|
43
|
-
// A data blob with the key but not the symbol must not pass as renderable.
|
|
44
|
-
expect(isReactElementType({ $$typeof: "react.memo" })).toBe(false);
|
|
45
|
-
});
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system ui-service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Is a resolved module export something React can render?
|
|
7
|
-
*
|
|
8
|
-
* `typeof x === "function"` is NOT that question. memo(), forwardRef() and
|
|
9
|
-
* lazy() all return OBJECTS carrying a `$$typeof` symbol, so a shape check on
|
|
10
|
-
* `function` rejects three of React's own idioms as "not a component".
|
|
11
|
-
* Measured 2026-09-01: scala-projects skipped PresenceAvatars for exactly this
|
|
12
|
-
* reason — the block is `memo(function PresenceAvatarsBlock(...))` — and every
|
|
13
|
-
* page rendering it came out blank with no error beyond one boot WARN.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
export function isReactElementType(value: unknown): boolean {
|
|
17
|
-
if (typeof value === "function") return true;
|
|
18
|
-
if (typeof value !== "object" || value === null) return false;
|
|
19
|
-
return typeof (value as { $$typeof?: unknown }).$$typeof === "symbol";
|
|
20
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system page-server
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Module-level singleton for the hub MCP client. setHubMcpClient(client)
|
|
7
|
-
* is called once at boot by the host service after creating the multi-
|
|
8
|
-
* tenant MCP client; createPageHandler's resolve-page-state reads it via
|
|
9
|
-
* getHubMcpClient to pass through to data loaders.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
let _hubMcpClient: unknown | null = null;
|
|
13
|
-
|
|
14
|
-
export function setHubMcpClient(client: unknown): void {
|
|
15
|
-
_hubMcpClient = client;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function getHubMcpClient(): unknown | undefined {
|
|
19
|
-
return _hubMcpClient ?? undefined;
|
|
20
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system server-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* The mount's poll-only contract: pg-boss v12 dispatches by polling, so the
|
|
7
|
-
* mount dials the INJECTED transaction-pooler URL unchanged (no endpoint
|
|
8
|
-
* derivation of its own — the session-port mapping died with the door,
|
|
9
|
-
* 2026-09-14) and constructs with useListenNotify false spelled explicitly
|
|
10
|
-
* rather than inherited from the supplier default, so a pg-boss bump cannot
|
|
11
|
-
* silently re-introduce a LISTEN dependency. The mock captures the opts
|
|
12
|
-
* PgBoss is constructed with — the claim is about what the mount DIALS and
|
|
13
|
-
* how it dispatches, so the assertions are on the captured opts.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
17
|
-
|
|
18
|
-
interface CapturedOpts {
|
|
19
|
-
connectionString?: string;
|
|
20
|
-
max?: number;
|
|
21
|
-
useListenNotify?: boolean;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const POOLED_URL =
|
|
25
|
-
"postgresql://scala_container_gbg.gbg:s3cret@10.205.50.1:6543/scala_container_gbg?sslmode=require";
|
|
26
|
-
|
|
27
|
-
function seedScheduledJob() {
|
|
28
|
-
return {
|
|
29
|
-
id: "test-job-1",
|
|
30
|
-
type: "scheduled_job",
|
|
31
|
-
slug: "scheduled_job:test-sync",
|
|
32
|
-
label: "Test Sync Job",
|
|
33
|
-
description: null,
|
|
34
|
-
config: {
|
|
35
|
-
handler_slug: "@teamscala/sync-worker:processSyncQueue",
|
|
36
|
-
cron: "*/5 * * * *",
|
|
37
|
-
queue_name: "sync-worker",
|
|
38
|
-
},
|
|
39
|
-
is_active: true,
|
|
40
|
-
sort_order: 0,
|
|
41
|
-
created_at: new Date(),
|
|
42
|
-
updated_at: new Date(),
|
|
43
|
-
noun: "scheduled_job",
|
|
44
|
-
kind: "scheduled_job",
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
describe("mountPgBoss poll-only contract", () => {
|
|
49
|
-
afterEach(() => {
|
|
50
|
-
mock.restore();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("dials the injected transaction pooler unchanged and dispatches poll-only", async () => {
|
|
54
|
-
let captured: CapturedOpts | null = null;
|
|
55
|
-
|
|
56
|
-
mock.module("pg-boss", () => ({
|
|
57
|
-
PgBoss: mock((opts: CapturedOpts) => {
|
|
58
|
-
captured = opts;
|
|
59
|
-
return {
|
|
60
|
-
start: mock(() => Promise.resolve()),
|
|
61
|
-
stop: mock(() => Promise.resolve()),
|
|
62
|
-
on: mock(() => undefined),
|
|
63
|
-
createQueue: mock(() => Promise.resolve()),
|
|
64
|
-
work: mock(() => Promise.resolve("worker-id")),
|
|
65
|
-
schedule: mock(() => Promise.resolve()),
|
|
66
|
-
getSchedules: mock(() => Promise.resolve([])),
|
|
67
|
-
unschedule: mock(() => Promise.resolve()),
|
|
68
|
-
};
|
|
69
|
-
}),
|
|
70
|
-
}));
|
|
71
|
-
mock.module("@teamscala/db/registry/accessors", () => ({
|
|
72
|
-
getAllEntries: () => [seedScheduledJob()],
|
|
73
|
-
}));
|
|
74
|
-
mock.module("@teamscala/event-log/record-event", () => ({
|
|
75
|
-
recordEvent: () => undefined,
|
|
76
|
-
}));
|
|
77
|
-
|
|
78
|
-
const { mountPgBoss } = await import("./pg-boss-mount.ts");
|
|
79
|
-
await mountPgBoss({
|
|
80
|
-
databaseUrl: POOLED_URL,
|
|
81
|
-
serviceSlug: "scala-sync-worker",
|
|
82
|
-
packages: ["@teamscala/sync-worker"],
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
const capturedOpts = captured as unknown as CapturedOpts | undefined;
|
|
86
|
-
const connectionString = capturedOpts?.connectionString ?? "";
|
|
87
|
-
// The transaction pooler, exactly as injected — the mount adds no
|
|
88
|
-
// endpoint derivation.
|
|
89
|
-
expect(connectionString).toContain(":6543");
|
|
90
|
-
// NEVER the session port: the door is closing, not widening.
|
|
91
|
-
expect(connectionString).not.toContain(":5999");
|
|
92
|
-
// The tenant suffix the pooler routes by is kept.
|
|
93
|
-
expect(connectionString).toContain("scala_container_gbg.gbg:");
|
|
94
|
-
// The pg sslmode dialect rewrite still applies.
|
|
95
|
-
expect(connectionString).toContain("sslmode=no-verify");
|
|
96
|
-
// Poll-only, spelled: no LISTEN dependency, now and after a bump.
|
|
97
|
-
expect(capturedOpts?.useListenNotify).toBe(false);
|
|
98
|
-
// pg-boss stays a single sibling connection (pool sizing doctrine).
|
|
99
|
-
expect(capturedOpts?.max).toBe(1);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
9
|
-
|
|
10
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
11
|
-
|
|
12
|
-
/** The ONE active launcher block_schema slug — the Puck registry key. */
|
|
13
|
-
const REGISTERED_LAUNCHER_SLUG = "ResourceLauncher";
|
|
14
|
-
|
|
15
|
-
mock.module("@teamscala/db/registry/load-config", () => ({
|
|
16
|
-
loadRegistryConfig: async () => ({ agentId: "agent-1" }),
|
|
17
|
-
}));
|
|
18
|
-
mock.module("./page-server-orpc/server-client.ts", () => ({
|
|
19
|
-
getServerClient: () => ({
|
|
20
|
-
ai_agents: {
|
|
21
|
-
findFirst: async () => ({
|
|
22
|
-
name: "Results Assistant",
|
|
23
|
-
welcome_message: "Hi! Ask me anything about your results.",
|
|
24
|
-
conversation_starters: ["What are my strengths?"],
|
|
25
|
-
primary_color: "#6366f1",
|
|
26
|
-
}),
|
|
27
|
-
},
|
|
28
|
-
}),
|
|
29
|
-
}));
|
|
30
|
-
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
31
|
-
getAppLogger: () => ({ error: (...args: unknown[]) => void args }),
|
|
32
|
-
}));
|
|
33
|
-
|
|
34
|
-
const { injectResourceChatLauncher } = await import("./resource-chat-inject.ts");
|
|
35
|
-
|
|
36
|
-
const ORG = "org-1";
|
|
37
|
-
const AGENT_OPTS = {
|
|
38
|
-
agentConfigSlug: "assessment-assistant-agent" as never,
|
|
39
|
-
launchLabel: "Ask about your results",
|
|
40
|
-
placeholder: "Ask about your results…",
|
|
41
|
-
regionSlots: ["header", "body", "footer"] as readonly string[],
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/** A resolved response row as the loader's response source selects it. */
|
|
45
|
-
const RESPONSE_ROW = {
|
|
46
|
-
id: "resp-1",
|
|
47
|
-
assessment_id: "asm-1",
|
|
48
|
-
organisation_id: ORG,
|
|
49
|
-
total_score: 64,
|
|
50
|
-
first_name: "Anna",
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/** The paired key as resolveReportHalves returns it — present on EVERY link
|
|
54
|
-
* shape, carrying the anchor half's organisation. */
|
|
55
|
-
const PAIRED = {
|
|
56
|
-
response: RESPONSE_ROW,
|
|
57
|
-
research: { id: "research-live", assessment_id: "asm-1", organisation_id: ORG },
|
|
58
|
-
assessment_id: "asm-1",
|
|
59
|
-
organisation_id: ORG,
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
function pageGraph(
|
|
63
|
-
responseSource: unknown,
|
|
64
|
-
pairSource: Record<string, unknown>,
|
|
65
|
-
): Record<string, unknown> {
|
|
66
|
-
return {
|
|
67
|
-
response: responseSource,
|
|
68
|
-
pair: pairSource,
|
|
69
|
-
quiz: { id: "asm-1", organisation_id: ORG, title: "Authority Index" },
|
|
70
|
-
brand: { primaryColor: "#123456" },
|
|
71
|
-
body: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
|
|
72
|
-
header: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
|
|
73
|
-
footer: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** The launcher renders `position: fixed`, so it may land in whichever
|
|
78
|
-
* region slot is declared first — count across EVERY slot, not one. */
|
|
79
|
-
function launcherBlocks(
|
|
80
|
-
resolved: Record<string, unknown>,
|
|
81
|
-
): Array<{ type: string; props?: Record<string, unknown> }> {
|
|
82
|
-
const out: Array<{ type: string; props?: Record<string, unknown> }> = [];
|
|
83
|
-
for (const slot of AGENT_OPTS.regionSlots) {
|
|
84
|
-
const content = (
|
|
85
|
-
resolved[slot] as {
|
|
86
|
-
puck_data?: { content?: Array<{ type: string; props?: Record<string, unknown> }> };
|
|
87
|
-
}
|
|
88
|
-
).puck_data?.content;
|
|
89
|
-
for (const block of content ?? []) {
|
|
90
|
-
if (block.type === REGISTERED_LAUNCHER_SLUG) out.push(block);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return out;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function launcherCount(
|
|
97
|
-
responseSource: unknown,
|
|
98
|
-
pairSource: Record<string, unknown>,
|
|
99
|
-
resourceKey: string,
|
|
100
|
-
): Promise<number> {
|
|
101
|
-
const resolved = pageGraph(responseSource, pairSource);
|
|
102
|
-
await injectResourceChatLauncher(resolved, "", { ...AGENT_OPTS, resourceKey });
|
|
103
|
-
return launcherBlocks(resolved).length;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
describe("page assistant renders on every link shape", () => {
|
|
107
|
-
test("response-id link: raw hit exists, launcher renders", async () => {
|
|
108
|
-
expect(await launcherCount(RESPONSE_ROW, PAIRED, "pair")).toBe(1);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("live research-anchor link: raw hit is null, launcher still renders", async () => {
|
|
112
|
-
expect(await launcherCount(null, PAIRED, "pair")).toBe(1);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("superseded research-anchor link: raw hit is null, launcher still renders", async () => {
|
|
116
|
-
// identical graph shape to the live anchor — resolution to the live
|
|
117
|
-
// pass happens upstream in the pair resolver, so the injection surface
|
|
118
|
-
// is the same and is exercised identically
|
|
119
|
-
expect(await launcherCount(null, PAIRED, "pair")).toBe(1);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("the launcher block carries the agent, org scope and context", async () => {
|
|
123
|
-
const resolved = pageGraph(null, PAIRED);
|
|
124
|
-
await injectResourceChatLauncher(resolved, "viewer@example.com", {
|
|
125
|
-
...AGENT_OPTS,
|
|
126
|
-
resourceKey: "pair",
|
|
127
|
-
});
|
|
128
|
-
const launchers = launcherBlocks(resolved);
|
|
129
|
-
expect(launchers.length).toBe(1);
|
|
130
|
-
const launcher = launchers[0];
|
|
131
|
-
expect(launcher.props.agentId).toBe("agent-1");
|
|
132
|
-
expect(launcher.props.organisationId).toBe(ORG);
|
|
133
|
-
expect(typeof launcher.props.runtimeContext).toBe("string");
|
|
134
|
-
expect((launcher.props.runtimeContext as string).length).toBeGreaterThan(0);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test("the DEFECT SHAPE: keying the raw response hit on an anchor link silently renders none", async () => {
|
|
138
|
-
// resource_key "response" + a null raw hit is the 2026-09-08 regression.
|
|
139
|
-
// The injector contract is fail-soft for the render, so the absence is
|
|
140
|
-
// quiet — this pins the shape with the reason spelled out, so the row
|
|
141
|
-
// can never go back to a key that is null on anchor links without the
|
|
142
|
-
// next reader finding this.
|
|
143
|
-
expect(await launcherCount(null, PAIRED, "response")).toBe(0);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
9
|
-
|
|
10
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
11
|
-
|
|
12
|
-
/** The ONE active launcher block_schema slug — the Puck registry key. */
|
|
13
|
-
const REGISTERED_LAUNCHER_SLUG = "ResourceLauncher";
|
|
14
|
-
|
|
15
|
-
mock.module("@teamscala/db/registry/load-config", () => ({
|
|
16
|
-
loadRegistryConfig: async () => ({ agentId: "agent-1" }),
|
|
17
|
-
}));
|
|
18
|
-
mock.module("./page-server-orpc/server-client.ts", () => ({
|
|
19
|
-
getServerClient: () => ({
|
|
20
|
-
ai_agents: {
|
|
21
|
-
findFirst: async () => ({
|
|
22
|
-
name: "Agreement Assistant",
|
|
23
|
-
welcome_message: "Hi! Ask me anything about this agreement.",
|
|
24
|
-
conversation_starters: ["What are the key terms?", "How do I sign this?"],
|
|
25
|
-
primary_color: "#6366f1",
|
|
26
|
-
}),
|
|
27
|
-
},
|
|
28
|
-
}),
|
|
29
|
-
}));
|
|
30
|
-
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
31
|
-
getAppLogger: () => ({ error: () => {} }),
|
|
32
|
-
}));
|
|
33
|
-
|
|
34
|
-
const { injectResourceChatLauncher } = await import("./resource-chat-inject.ts");
|
|
35
|
-
|
|
36
|
-
function makeResolved(resourceKey: string): Record<string, unknown> {
|
|
37
|
-
return {
|
|
38
|
-
[resourceKey]: { organisation_id: "org-1", name: "Demo" },
|
|
39
|
-
brand: { primaryColor: "#123456" },
|
|
40
|
-
page: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function pushedBlocks(resolved: Record<string, unknown>) {
|
|
45
|
-
const pd = (
|
|
46
|
-
resolved.page as {
|
|
47
|
-
puck_data: { content: Array<{ type: string; props?: Record<string, unknown> }> };
|
|
48
|
-
}
|
|
49
|
-
).puck_data;
|
|
50
|
-
return pd.content;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
describe("chat injectors — pushed block name resolves to a registered block", () => {
|
|
54
|
-
test("the generic resource injector pushes the registered launcher slug", async () => {
|
|
55
|
-
const resolved = makeResolved("agreement");
|
|
56
|
-
await injectResourceChatLauncher(resolved, "viewer@example.com", {
|
|
57
|
-
resourceKey: "agreement",
|
|
58
|
-
agentConfigSlug: "agreement-assistant-agent",
|
|
59
|
-
launchLabel: "Ask about this agreement",
|
|
60
|
-
});
|
|
61
|
-
const blocks = pushedBlocks(resolved);
|
|
62
|
-
expect(blocks).toHaveLength(1);
|
|
63
|
-
expect(blocks[0].type).toBe(REGISTERED_LAUNCHER_SLUG);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("neither injector pushes a pre-consolidation launcher name", async () => {
|
|
67
|
-
const a = makeResolved("agreement");
|
|
68
|
-
await injectResourceChatLauncher(a, "viewer@example.com", {
|
|
69
|
-
resourceKey: "agreement",
|
|
70
|
-
agentConfigSlug: "agreement-assistant-agent",
|
|
71
|
-
launchLabel: "Ask about this agreement",
|
|
72
|
-
});
|
|
73
|
-
const p = makeResolved("proposal");
|
|
74
|
-
await injectResourceChatLauncher(p, "viewer@example.com", {
|
|
75
|
-
resourceKey: "proposal",
|
|
76
|
-
agentConfigSlug: "proposal-assistant-agent",
|
|
77
|
-
launchLabel: "Ask about this proposal",
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const dead = ["ResourceChatLauncher", "ChatLauncherBlock"];
|
|
81
|
-
for (const blocks of [pushedBlocks(a), pushedBlocks(p)]) {
|
|
82
|
-
for (const block of blocks) {
|
|
83
|
-
expect(dead).not.toContain(block.type);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("the assistant's words come from the agent row, not the caller", async () => {
|
|
89
|
-
// THE REGRESSION THIS PINS: title, welcome message and starters used to
|
|
90
|
-
// be string literals in the injectors while ai_agents held a blank
|
|
91
|
-
// column for each. Whoever changed the row saw no change on the page.
|
|
92
|
-
const resolved = makeResolved("agreement");
|
|
93
|
-
await injectResourceChatLauncher(resolved, "viewer@example.com", {
|
|
94
|
-
resourceKey: "agreement",
|
|
95
|
-
agentConfigSlug: "agreement-assistant-agent",
|
|
96
|
-
launchLabel: "Ask about this agreement",
|
|
97
|
-
});
|
|
98
|
-
const props = pushedBlocks(resolved)[0].props ?? {};
|
|
99
|
-
expect(props.title).toBe("Agreement Assistant");
|
|
100
|
-
expect(props.welcomeMessage).toBe("Hi! Ask me anything about this agreement.");
|
|
101
|
-
expect(props.conversationStarters).toEqual([
|
|
102
|
-
"What are the key terms?",
|
|
103
|
-
"How do I sign this?",
|
|
104
|
-
]);
|
|
105
|
-
// launchLabel has no column yet, so it is still the caller's.
|
|
106
|
-
expect(props.launchLabel).toBe("Ask about this agreement");
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
/** A region-slot page — the shape a quiz report resolves (no `page` source). */
|
|
111
|
-
function makeAssessmentResolved(): Record<string, unknown> {
|
|
112
|
-
return {
|
|
113
|
-
response: {
|
|
114
|
-
organisation_id: "org-1",
|
|
115
|
-
email: "reader@example.com",
|
|
116
|
-
total_score: 95,
|
|
117
|
-
category_scores: { "Be Seen": 100, "Be Chosen": 83 },
|
|
118
|
-
},
|
|
119
|
-
quiz: { id: "quiz-1", report_title: "The Launcha Authority Index" },
|
|
120
|
-
reportData: { narrative: "You are strongest at being seen." },
|
|
121
|
-
brand: { primaryColor: "#123456" },
|
|
122
|
-
header: { puck_data: { content: [{ type: "SiteHeader" }] } },
|
|
123
|
-
body: { puck_data: { root: { props: {} }, zones: {}, content: [] } },
|
|
124
|
-
footer: { puck_data: { content: [{ type: "SiteFooter" }] } },
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** The launcher's props, wherever in the graph it was attached. */
|
|
129
|
-
function launcherProps(resolved: Record<string, unknown>): Record<string, unknown> {
|
|
130
|
-
for (const value of Object.values(resolved)) {
|
|
131
|
-
const content = (value as { puck_data?: { content?: unknown } } | null)?.puck_data
|
|
132
|
-
?.content;
|
|
133
|
-
if (!Array.isArray(content)) continue;
|
|
134
|
-
const launcher = (content as Array<{ type: string; props?: Record<string, unknown> }>).find(
|
|
135
|
-
(block) => block.type === REGISTERED_LAUNCHER_SLUG,
|
|
136
|
-
);
|
|
137
|
-
if (launcher) return launcher.props ?? {};
|
|
138
|
-
}
|
|
139
|
-
throw new Error("no launcher block was attached to any region");
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function bodyBlocks(resolved: Record<string, unknown>) {
|
|
143
|
-
return (
|
|
144
|
-
resolved.body as {
|
|
145
|
-
puck_data: { content: Array<{ type: string; props?: Record<string, unknown> }> };
|
|
146
|
-
}
|
|
147
|
-
).puck_data.content;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* REGION SLOTS COME FROM THE LOADER ROW, exactly as the caller reads them —
|
|
152
|
-
* the injector is never allowed to guess which resolved keys hold content.
|
|
153
|
-
*/
|
|
154
|
-
const assessmentOpts = {
|
|
155
|
-
resourceKey: "response",
|
|
156
|
-
agentConfigSlug: "assessment-assistant-agent" as never,
|
|
157
|
-
launchLabel: "Ask about your results",
|
|
158
|
-
regionSlots: ["header", "body", "footer"],
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
describe("region-slot pages (a generated report, not an authored document)", () => {
|
|
162
|
-
test("attaches to a declared region when there is no `page` source", async () => {
|
|
163
|
-
// THE DEFECT THIS PINS: the injector read resolved.page.puck_data.content
|
|
164
|
-
// and returned early when absent. A generated report resolves region
|
|
165
|
-
// slots instead, so the early return fired and the assistant was
|
|
166
|
-
// silently missing — fail-soft, so nothing was logged.
|
|
167
|
-
const resolved = makeAssessmentResolved();
|
|
168
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
|
|
169
|
-
// The first declared slot holding a content tree. The launcher is
|
|
170
|
-
// position:fixed, so any rendered region shows it identically.
|
|
171
|
-
const header = (
|
|
172
|
-
resolved.header as { puck_data: { content: Array<{ type: string }> } }
|
|
173
|
-
).puck_data.content;
|
|
174
|
-
expect(header.map((block) => block.type)).toContain(REGISTERED_LAUNCHER_SLUG);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test("follows the loader's slot order rather than a hardcoded key", async () => {
|
|
178
|
-
// Reordering the row must move the launcher. If this passes while the
|
|
179
|
-
// code names "body", the derivation is a literal wearing a costume.
|
|
180
|
-
const resolved = makeAssessmentResolved();
|
|
181
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", {
|
|
182
|
-
...assessmentOpts,
|
|
183
|
-
regionSlots: ["body", "header", "footer"],
|
|
184
|
-
});
|
|
185
|
-
expect(bodyBlocks(resolved).map((block) => block.type)).toContain(
|
|
186
|
-
REGISTERED_LAUNCHER_SLUG,
|
|
187
|
-
);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
test("sends the assessment RESULT, not just the rendered page", async () => {
|
|
191
|
-
// A report's body page is a shared template — one builda_pages row serves
|
|
192
|
-
// every respondent — so the content tree holds zero personal facts. An
|
|
193
|
-
// assistant given only the page would know nothing about this reader.
|
|
194
|
-
const resolved = makeAssessmentResolved();
|
|
195
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
|
|
196
|
-
const context = String(launcherProps(resolved).runtimeContext ?? "");
|
|
197
|
-
expect(context).toContain("total_score");
|
|
198
|
-
expect(context).toContain("95");
|
|
199
|
-
expect(context).toContain("Be Chosen");
|
|
200
|
-
expect(context).toContain("You are strongest at being seen.");
|
|
201
|
-
expect(context).toContain("The Launcha Authority Index");
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("sends the rendered document too, not just the result", async () => {
|
|
205
|
-
// The mirror of the test above: the reader is asking about what is on
|
|
206
|
-
// screen, so the assistant needs the document as well as the data.
|
|
207
|
-
const resolved = makeAssessmentResolved();
|
|
208
|
-
(
|
|
209
|
-
resolved.body as { puck_data: { content: unknown[] } }
|
|
210
|
-
).puck_data.content.push({ type: "ScoreDial", props: { label: "Authority" } });
|
|
211
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
|
|
212
|
-
const context = String(launcherProps(resolved).runtimeContext ?? "");
|
|
213
|
-
expect(context).toContain("ScoreDial");
|
|
214
|
-
expect(context).toContain("Authority");
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
test("names no resolved key — it sends whatever the loader produced", async () => {
|
|
218
|
-
// THE REGRESSION THIS PINS: a draft excluded brand/website/header/footer
|
|
219
|
-
// by name, which is a loader row copied into TypeScript. Those keys are
|
|
220
|
-
// database facts, so the serialiser must not have opinions about them —
|
|
221
|
-
// it walks the graph and a loader edit propagates with no code change.
|
|
222
|
-
const resolved = makeAssessmentResolved();
|
|
223
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
|
|
224
|
-
const context = String(launcherProps(resolved).runtimeContext ?? "");
|
|
225
|
-
for (const key of Object.keys(resolved)) {
|
|
226
|
-
expect(context).toContain(key);
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
test("carries a source a future loader adds, with no edit here", async () => {
|
|
231
|
-
// The serialiser walks the graph rather than naming keys, so a loader
|
|
232
|
-
// resolving a new source reaches the assistant for free.
|
|
233
|
-
const resolved = makeAssessmentResolved();
|
|
234
|
-
resolved.benchmarks = { industryMedian: 62 };
|
|
235
|
-
await injectResourceChatLauncher(resolved, "reader@example.com", assessmentOpts);
|
|
236
|
-
const context = String(launcherProps(resolved).runtimeContext ?? "");
|
|
237
|
-
expect(context).toContain("industryMedian");
|
|
238
|
-
expect(context).toContain("62");
|
|
239
|
-
});
|
|
240
|
-
});
|