@teamscala/service-boot 0.23.539 → 0.23.540
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 +3 -1
- package/src/attach-portal-members.test.ts +4 -0
- package/src/build-client-assets/sourcemaps-upload.test.ts +51 -6
- package/src/mount-orpc-router.test.ts +8 -0
- package/src/orpc-contract-rows.test.ts +1 -1
- package/src/read-html-template.test.ts +9 -1
- package/src/register-registry-page-route/apply-page-branding.test.ts +13 -1
- package/src/register-registry-page-route/apply-page-branding.ts +1 -1
- package/src/register-registry-page-route/live-loader-config.test.ts +13 -4
- package/src/register-registry-page-route/page-data-shared.test.ts +12 -5
- package/src/register-registry-page-route/resolve-entity-title.test.ts +9 -1
- package/src/register-registry-page-route/resolve-page-data.test.ts +16 -1
- package/src/register-registry-page-route/resolve-request-graph.test.ts +12 -26
- package/src/resource-not-found.test.ts +19 -1
- package/src/scala-tools-currency-watchdog.ts +1 -1
- package/src/theme-fonts-link.test.ts +2 -0
- package/src/two-phase-full-app.test.ts +48 -2
- package/src/two-phase-full-app.ts +25 -6
- package/src/ui-service.ts +7 -0
package/package.json
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"@teamscala/content-gate-token": "*",
|
|
20
20
|
"@teamscala/data-resolvers": "*",
|
|
21
21
|
"@teamscala/db": "*",
|
|
22
|
+
"@teamscala/db-org-branding": "*",
|
|
22
23
|
"@teamscala/document-signing": "*",
|
|
23
24
|
"@teamscala/email-client": "*",
|
|
24
25
|
"@teamscala/encryption": "*",
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
"@teamscala/orpc-openapi": "*",
|
|
36
37
|
"@teamscala/orpc-ws": "*",
|
|
37
38
|
"@teamscala/os": "*",
|
|
39
|
+
"@teamscala/process-spawn": "*",
|
|
38
40
|
"@teamscala/publish-registry": "*",
|
|
39
41
|
"@teamscala/puck-boundary": "*",
|
|
40
42
|
"@teamscala/puck-tree": "*",
|
|
@@ -303,5 +305,5 @@
|
|
|
303
305
|
"repository": "https://github.com/teamgbg/service-boot",
|
|
304
306
|
"sideEffects": false,
|
|
305
307
|
"type": "module",
|
|
306
|
-
"version": "0.23.
|
|
308
|
+
"version": "0.23.540"
|
|
307
309
|
}
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
+
import * as __scalaReal_teamscala_db_query_rows from "@teamscala/db/query-rows";
|
|
10
|
+
import * as __scalaReal_teamscala_auth_get_session from "@teamscala/auth/get-session";
|
|
9
11
|
|
|
10
12
|
import { describe, expect, mock, test } from "bun:test";
|
|
11
13
|
import { Hono } from "hono";
|
|
@@ -19,10 +21,12 @@ let failMemberQuery = false;
|
|
|
19
21
|
const queryCalls: QueryRowsCall[] = [];
|
|
20
22
|
|
|
21
23
|
mock.module("@teamscala/auth/get-session", () => ({
|
|
24
|
+
...__scalaReal_teamscala_auth_get_session,
|
|
22
25
|
getSession: async () => session,
|
|
23
26
|
}));
|
|
24
27
|
|
|
25
28
|
mock.module("@teamscala/db/query-rows", () => ({
|
|
29
|
+
...__scalaReal_teamscala_db_query_rows,
|
|
26
30
|
queryRows: (strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
27
31
|
const sql = strings.join("?");
|
|
28
32
|
queryCalls.push({ sql, values });
|
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
-
|
|
10
9
|
import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises";
|
|
11
10
|
import { tmpdir } from "node:os";
|
|
12
11
|
import { join } from "node:path";
|
|
13
|
-
import { afterAll, afterEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
14
13
|
import {
|
|
15
14
|
releaseFromHydratePath,
|
|
15
|
+
sentryTarget,
|
|
16
|
+
sourceMapArtifactName,
|
|
16
17
|
sourceMapsCacheDir,
|
|
17
18
|
uploadCachedSourceMaps,
|
|
18
19
|
uploadSourceMaps,
|
|
@@ -33,21 +34,37 @@ async function seedMaps(outDir: string, names: string[]): Promise<void> {
|
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
// Full five-method surface: mock.module installs PROCESS-WIDE, so a partial
|
|
38
|
+
// double breaks every suite that imports the logger later (measured 2026-09-30).
|
|
36
39
|
const noopLog = {
|
|
40
|
+
debug: () => {},
|
|
37
41
|
info: () => {},
|
|
38
42
|
warn: () => {},
|
|
39
43
|
error: () => {},
|
|
44
|
+
child: () => noopLog,
|
|
40
45
|
} as unknown as ReturnType<typeof import("@teamscala/logger/app-loggers").getAppLogger>;
|
|
41
46
|
|
|
47
|
+
// The uploader's gate is the DSN the client already reports to, and temps
|
|
48
|
+
// authenticates that endpoint with the DSN PUBLIC KEY — never an API token —
|
|
49
|
+
// so the suite seeds SENTRY_DSN (and clears it) rather than the retired
|
|
50
|
+
// SENTRY_AUTH_TOKEN / SENTRY_URL pair the GlitchTip-era uploader read.
|
|
51
|
+
const UPLOAD_DSN = "https://0123456789abcdef0123456789abcdef@temps.test/12";
|
|
52
|
+
|
|
42
53
|
afterAll(async () => {
|
|
43
54
|
for (const d of dirs) await rm(d, { recursive: true, force: true });
|
|
44
55
|
});
|
|
45
56
|
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
delete process.env.SENTRY_DSN;
|
|
59
|
+
delete process.env.SENTRY_ORG;
|
|
60
|
+
});
|
|
61
|
+
|
|
46
62
|
afterEach(() => {
|
|
47
63
|
delete process.env.SENTRY_DSN;
|
|
64
|
+
delete process.env.SENTRY_ORG;
|
|
48
65
|
});
|
|
49
66
|
|
|
50
|
-
describe("uploadSourceMaps — skip path (absent
|
|
67
|
+
describe("uploadSourceMaps — skip path (absent SENTRY_DSN)", () => {
|
|
51
68
|
const currentOutputs = new Set(["hydrate-abc123.js", "chunk-xyz789.js"]);
|
|
52
69
|
test("maps STILL leave the served root and are retained in the cache", async () => {
|
|
53
70
|
const cwd = await makeDir();
|
|
@@ -80,20 +97,48 @@ describe("uploadSourceMaps — skip path (absent env)", () => {
|
|
|
80
97
|
|
|
81
98
|
describe("uploadCachedSourceMaps — the fingerprint-skip retry path", () => {
|
|
82
99
|
test("no cached maps → failed, naming the release and the reason", async () => {
|
|
83
|
-
process.env.SENTRY_DSN =
|
|
100
|
+
process.env.SENTRY_DSN = UPLOAD_DSN;
|
|
84
101
|
const cwd = await makeDir();
|
|
85
102
|
const result = await uploadCachedSourceMaps(cwd, "deadbeef", noopLog);
|
|
86
103
|
expect(result.status).toBe("failed");
|
|
87
104
|
expect(result.failures[0]).toContain("deadbeef");
|
|
88
105
|
});
|
|
89
106
|
|
|
90
|
-
test("absent
|
|
107
|
+
test("absent SENTRY_DSN → skipped without any upload attempt", async () => {
|
|
91
108
|
const cwd = await makeDir();
|
|
92
109
|
const result = await uploadCachedSourceMaps(cwd, "abc123", noopLog);
|
|
93
110
|
expect(result.status).toBe("skipped");
|
|
94
111
|
});
|
|
95
112
|
});
|
|
96
113
|
|
|
114
|
+
describe("sourceMapArtifactName — the name temps stores and is looked up by", () => {
|
|
115
|
+
test("names the SERVED JS path, with .map NOT appended", () => {
|
|
116
|
+
// temps stores the multipart `name` field verbatim as source_maps.file_path
|
|
117
|
+
// and matches a frame's abs_path against it (normalized to ~/<path>); it
|
|
118
|
+
// never appends ".map", so a .map-suffixed name could not symbolicate.
|
|
119
|
+
expect(sourceMapArtifactName("hydrate-abc123.js.map")).toBe("~/assets/hydrate-abc123.js");
|
|
120
|
+
expect(sourceMapArtifactName("chunk-xyz789.js.map")).toBe("~/assets/chunk-xyz789.js");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("sentryTarget — one DSN, never a second host/token/project triple", () => {
|
|
125
|
+
test("derives origin, credential and project from the DSN", () => {
|
|
126
|
+
expect(sentryTarget(UPLOAD_DSN)).toEqual({
|
|
127
|
+
origin: "https://temps.test",
|
|
128
|
+
credential: "0123456789abcdef0123456789abcdef",
|
|
129
|
+
project: "12",
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a malformed DSN yields null rather than a partial target", () => {
|
|
134
|
+
expect(sentryTarget("")).toBeNull();
|
|
135
|
+
expect(sentryTarget(undefined)).toBeNull();
|
|
136
|
+
expect(sentryTarget("https://temps.test/12")).toBeNull();
|
|
137
|
+
expect(sentryTarget("https://key@temps.test")).toBeNull();
|
|
138
|
+
expect(sentryTarget("https://key@temps.test/")).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
97
142
|
describe("releaseFromHydratePath", () => {
|
|
98
143
|
test("extracts the bare content hash from a manifest hydratePath", () => {
|
|
99
144
|
expect(releaseFromHydratePath("/assets/hydrate-9df3aab732.js")).toBe("9df3aab732");
|
|
@@ -109,4 +154,4 @@ describe("releaseFromHydratePath", () => {
|
|
|
109
154
|
expect(releaseFromHydratePath("/assets/hydrate-9DF3AAB732.js")).toBeNull();
|
|
110
155
|
expect(releaseFromHydratePath("/assets/hydrate-9df3aab73 2.js")).toBeNull();
|
|
111
156
|
});
|
|
112
|
-
});
|
|
157
|
+
});
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
+
import * as __scalaReal_teamscala_event_log_record_event from "@teamscala/event-log/record-event";
|
|
10
|
+
import * as __scalaReal_teamscala_tool_executor_configure from "@teamscala/tool-executor/configure";
|
|
11
|
+
import * as __scalaReal_teamscala_orpc_configure from "@teamscala/orpc/configure";
|
|
12
|
+
import * as __scalaReal_teamscala_orpc_app_router from "@teamscala/orpc/app-router";
|
|
9
13
|
|
|
10
14
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
11
15
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
@@ -72,14 +76,17 @@ describe("mountOrpcRouter generated-artifact seam", () => {
|
|
|
72
76
|
// injectors are absent.
|
|
73
77
|
let seenKeys: Array<string> = [];
|
|
74
78
|
mock.module("@teamscala/orpc/app-router", () => ({
|
|
79
|
+
...__scalaReal_teamscala_orpc_app_router,
|
|
75
80
|
getAppRouter: async () => ({
|
|
76
81
|
healthz: { health: async () => ({ ok: true }) },
|
|
77
82
|
}),
|
|
78
83
|
}));
|
|
79
84
|
mock.module("@teamscala/orpc/configure", () => ({
|
|
85
|
+
...__scalaReal_teamscala_orpc_configure,
|
|
80
86
|
getPrisma: () => ({}),
|
|
81
87
|
}));
|
|
82
88
|
mock.module("@teamscala/tool-executor/configure", () => ({
|
|
89
|
+
...__scalaReal_teamscala_tool_executor_configure,
|
|
83
90
|
configure: (opts: Record<string, unknown>) => {
|
|
84
91
|
seenKeys = Object.keys(opts);
|
|
85
92
|
},
|
|
@@ -137,6 +144,7 @@ describe("mountOrpcRouter procedure error hook", () => {
|
|
|
137
144
|
}),
|
|
138
145
|
}));
|
|
139
146
|
mock.module("@teamscala/event-log/record-event", () => ({
|
|
147
|
+
...__scalaReal_teamscala_event_log_record_event,
|
|
140
148
|
recordEvent: (input: { kind: string; payload: unknown }) => {
|
|
141
149
|
recordedEvents.push(input);
|
|
142
150
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @status generated — ts_function_contract_runner template (scala-test-suites
|
|
3
|
-
* templates/ts_function_contract_runner.pkl @1.
|
|
3
|
+
* templates/ts_function_contract_runner.pkl @1.16.0) over @teamscala/service-boot's
|
|
4
4
|
* test_vector rows: one `it` per function_call row (args -> value or
|
|
5
5
|
* expect_error) and one per fs_flow row (ordered steps in a temp
|
|
6
6
|
* workspace). This runner carries NO facts: every case is the registry
|
|
@@ -6,12 +6,20 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
+
import { afterAll as __scalaRestoreGlobals } from "bun:test";
|
|
10
|
+
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
|
11
|
+
const __scalaDomWasMissing = !globalThis.document;
|
|
12
|
+
if (__scalaDomWasMissing) GlobalRegistrator.register();
|
|
13
|
+
__scalaRestoreGlobals(async () => {
|
|
14
|
+
if (__scalaDomWasMissing) await GlobalRegistrator.unregister();
|
|
15
|
+
});
|
|
16
|
+
|
|
9
17
|
|
|
10
18
|
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
11
19
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
20
|
import { tmpdir } from "node:os";
|
|
13
21
|
import { join } from "node:path";
|
|
14
|
-
|
|
22
|
+
const { readHtmlTemplate } = await import("./read-html-template.ts");
|
|
15
23
|
|
|
16
24
|
let dir: string;
|
|
17
25
|
let origCwd: string;
|
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
+
import * as __scalaReal_teamscala_db_org_branding_fetch_organisation_page_branding from "@teamscala/db-org-branding/fetch-organisation-page-branding";
|
|
10
|
+
import * as __scalaReal_teamscala_db_registry_platform from "@teamscala/db/registry/platform";
|
|
11
|
+
import { afterAll as __scalaRestoreGlobals } from "bun:test";
|
|
12
|
+
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
|
13
|
+
const __scalaDomWasMissing = !globalThis.document;
|
|
14
|
+
if (__scalaDomWasMissing) GlobalRegistrator.register();
|
|
15
|
+
__scalaRestoreGlobals(async () => {
|
|
16
|
+
if (__scalaDomWasMissing) await GlobalRegistrator.unregister();
|
|
17
|
+
});
|
|
18
|
+
|
|
9
19
|
|
|
10
20
|
import { describe, expect, mock, test } from "bun:test";
|
|
11
21
|
|
|
@@ -14,6 +24,7 @@ const TENANT_ORG = "5b742740-0f37-494c-a059-d8399ae4b350";
|
|
|
14
24
|
const VIEWER_ORG = PLATFORM_ORG;
|
|
15
25
|
|
|
16
26
|
mock.module("@teamscala/db/registry/platform", () => ({
|
|
27
|
+
...__scalaReal_teamscala_db_registry_platform,
|
|
17
28
|
getPlatformOrgId: () => PLATFORM_ORG,
|
|
18
29
|
}));
|
|
19
30
|
|
|
@@ -28,7 +39,8 @@ const fetchBranding = mock(async () => ({
|
|
|
28
39
|
company_logo_for_light_theme: "/viewer-light.png",
|
|
29
40
|
company_logo_for_dark_theme: "/viewer-dark.png",
|
|
30
41
|
}));
|
|
31
|
-
mock.module("@teamscala/db/fetch-organisation-page-branding", () => ({
|
|
42
|
+
mock.module("@teamscala/db-org-branding/fetch-organisation-page-branding", () => ({
|
|
43
|
+
...__scalaReal_teamscala_db_org_branding_fetch_organisation_page_branding,
|
|
32
44
|
fetchOrganisationPageBranding: fetchBranding,
|
|
33
45
|
}));
|
|
34
46
|
mock.module("../page-server-orpc/server-client.ts", () => ({
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
import { fetchOrganisationPageBranding } from "@teamscala/db/fetch-organisation-page-branding";
|
|
9
|
+
import { fetchOrganisationPageBranding } from "@teamscala/db-org-branding/fetch-organisation-page-branding";
|
|
10
10
|
import { getPlatformOrgId } from "@teamscala/db/registry/platform";
|
|
11
11
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
12
12
|
import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
+
import * as __scalaReal_teamscala_logger_app_loggers from "@teamscala/logger/app-loggers";
|
|
9
10
|
|
|
10
11
|
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
|
11
12
|
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
@@ -44,16 +45,14 @@ mock.module("@teamscala/db/registry/load-config", () => {
|
|
|
44
45
|
});
|
|
45
46
|
|
|
46
47
|
// ── The heavy collaborators (recorded, not executed) ───────────────────
|
|
47
|
-
const graphCalls: { loaderConfig: Record<string, unknown
|
|
48
|
+
const graphCalls: { loaderConfig: Record<string, unknown> }[] = [];
|
|
48
49
|
|
|
49
50
|
mock.module("./resolve-request-graph.ts", () => ({
|
|
50
51
|
resolveRequestGraph: async (args: {
|
|
51
52
|
loaderConfig: Record<string, unknown>;
|
|
52
|
-
configFingerprint?: string;
|
|
53
53
|
}) => {
|
|
54
54
|
graphCalls.push({
|
|
55
55
|
loaderConfig: args.loaderConfig,
|
|
56
|
-
configFingerprint: args.configFingerprint,
|
|
57
56
|
});
|
|
58
57
|
return {};
|
|
59
58
|
},
|
|
@@ -93,8 +92,18 @@ mock.module("../bind-page-handler.ts", () => ({
|
|
|
93
92
|
_request: Request,
|
|
94
93
|
) => c.json(await handler.loadData(c.req.raw)),
|
|
95
94
|
}));
|
|
95
|
+
// Full five-method surface: mock.module installs PROCESS-WIDE, so a partial
|
|
96
|
+
// double breaks every suite that imports the logger later (measured 2026-09-30).
|
|
97
|
+
const loggerDouble = {
|
|
98
|
+
debug: () => {},
|
|
99
|
+
info: () => {},
|
|
100
|
+
warn: () => {},
|
|
101
|
+
error: () => {},
|
|
102
|
+
child: () => loggerDouble,
|
|
103
|
+
};
|
|
96
104
|
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
97
|
-
|
|
105
|
+
...__scalaReal_teamscala_logger_app_loggers,
|
|
106
|
+
getAppLogger: () => loggerDouble,
|
|
98
107
|
}));
|
|
99
108
|
|
|
100
109
|
const { registerRegistryPageRoute } = await import(
|
|
@@ -6,16 +6,23 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
+
import * as __scalaReal_teamscala_logger_app_loggers from "@teamscala/logger/app-loggers";
|
|
9
10
|
|
|
10
11
|
import { describe, expect, mock, test } from "bun:test";
|
|
11
12
|
|
|
12
13
|
const warns: string[] = [];
|
|
14
|
+
// Full five-method surface: mock.module installs PROCESS-WIDE, so a partial
|
|
15
|
+
// double breaks every suite that imports the logger later (measured 2026-09-30).
|
|
16
|
+
const loggerDouble = {
|
|
17
|
+
debug: () => {},
|
|
18
|
+
info: () => {},
|
|
19
|
+
warn: (message: string) => warns.push(message),
|
|
20
|
+
error: () => {},
|
|
21
|
+
child: () => loggerDouble,
|
|
22
|
+
};
|
|
13
23
|
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
info: () => {},
|
|
17
|
-
error: () => {},
|
|
18
|
-
}),
|
|
24
|
+
...__scalaReal_teamscala_logger_app_loggers,
|
|
25
|
+
getAppLogger: () => loggerDouble,
|
|
19
26
|
}));
|
|
20
27
|
|
|
21
28
|
import {
|
|
@@ -6,9 +6,17 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
+
import { afterAll as __scalaRestoreGlobals } from "bun:test";
|
|
10
|
+
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
|
11
|
+
const __scalaDomWasMissing = !globalThis.document;
|
|
12
|
+
if (__scalaDomWasMissing) GlobalRegistrator.register();
|
|
13
|
+
__scalaRestoreGlobals(async () => {
|
|
14
|
+
if (__scalaDomWasMissing) await GlobalRegistrator.unregister();
|
|
15
|
+
});
|
|
16
|
+
|
|
9
17
|
|
|
10
18
|
import { describe, expect, test } from "bun:test";
|
|
11
|
-
|
|
19
|
+
const { resolveEntityTitle } = await import("./meta-title.ts");
|
|
12
20
|
|
|
13
21
|
describe("resolveEntityTitle", () => {
|
|
14
22
|
test("a report is named by its own document name, not the diagnostic", () => {
|
|
@@ -6,17 +6,32 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
+
import * as __scalaReal_teamscala_service_boot_page_server_orpc_server_client from "@teamscala/service-boot/page-server-orpc/server-client";
|
|
10
|
+
import * as __scalaReal_teamscala_db_registry_load_config from "@teamscala/db/registry/load-config";
|
|
11
|
+
import * as __scalaReal_teamscala_logger_app_loggers from "@teamscala/logger/app-loggers";
|
|
9
12
|
|
|
10
13
|
import { describe, expect, mock, test } from "bun:test";
|
|
11
14
|
|
|
12
15
|
const warn = mock((_message: string) => {});
|
|
16
|
+
// Full five-method surface: mock.module installs PROCESS-WIDE, so a partial
|
|
17
|
+
// double breaks every suite that imports the logger later (measured 2026-09-30).
|
|
18
|
+
const loggerDouble = {
|
|
19
|
+
debug: () => {},
|
|
20
|
+
info: () => {},
|
|
21
|
+
warn,
|
|
22
|
+
error: () => {},
|
|
23
|
+
child: () => loggerDouble,
|
|
24
|
+
};
|
|
13
25
|
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
14
|
-
|
|
26
|
+
...__scalaReal_teamscala_logger_app_loggers,
|
|
27
|
+
getAppLogger: () => loggerDouble,
|
|
15
28
|
}));
|
|
16
29
|
mock.module("@teamscala/db/registry/load-config", () => ({
|
|
30
|
+
...__scalaReal_teamscala_db_registry_load_config,
|
|
17
31
|
loadRegistryConfig: async () => ({}),
|
|
18
32
|
}));
|
|
19
33
|
mock.module("@teamscala/service-boot/page-server-orpc/server-client", () => ({
|
|
34
|
+
...__scalaReal_teamscala_service_boot_page_server_orpc_server_client,
|
|
20
35
|
getServerClient: () => ({}),
|
|
21
36
|
}));
|
|
22
37
|
|
|
@@ -6,18 +6,25 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
+
import * as __scalaReal_teamscala_logger_app_loggers from "@teamscala/logger/app-loggers";
|
|
9
10
|
|
|
10
11
|
import { describe, expect, mock, test } from "bun:test";
|
|
11
12
|
|
|
12
13
|
let loaderGraph: Record<string, unknown> = {};
|
|
13
14
|
const resolveCalls: Array<{ params: Record<string, string> }> = [];
|
|
14
15
|
|
|
16
|
+
// Full five-method surface: mock.module installs PROCESS-WIDE, so a partial
|
|
17
|
+
// double breaks every suite that imports the logger later (measured 2026-09-30).
|
|
18
|
+
const loggerDouble = {
|
|
19
|
+
debug: () => {},
|
|
20
|
+
info: () => {},
|
|
21
|
+
warn: () => {},
|
|
22
|
+
error: () => {},
|
|
23
|
+
child: () => loggerDouble,
|
|
24
|
+
};
|
|
15
25
|
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
info: () => {},
|
|
19
|
-
error: () => {},
|
|
20
|
-
}),
|
|
26
|
+
...__scalaReal_teamscala_logger_app_loggers,
|
|
27
|
+
getAppLogger: () => loggerDouble,
|
|
21
28
|
}));
|
|
22
29
|
|
|
23
30
|
mock.module("../page-server-loader-graph.ts", () => ({
|
|
@@ -60,12 +67,6 @@ mock.module("../content-page-floor.ts", () => ({
|
|
|
60
67
|
},
|
|
61
68
|
}));
|
|
62
69
|
|
|
63
|
-
mock.module("./graph-cache.ts", () => ({
|
|
64
|
-
staticShellGraphCache: { getWithMeta: () => undefined, set: () => {}, refreshInBackground: () => {} },
|
|
65
|
-
paramRouteGraphCache: { getWithMeta: () => undefined, set: () => {}, refreshInBackground: () => {} },
|
|
66
|
-
readCachedGraph: () => undefined,
|
|
67
|
-
}));
|
|
68
|
-
|
|
69
70
|
import { resolveRequestGraph, type ResolveRequestGraphArgs } from "./resolve-request-graph.ts";
|
|
70
71
|
import type { PageRouteConfig } from "../register-registry-page-route.ts";
|
|
71
72
|
|
|
@@ -88,12 +89,9 @@ describe("resolveRequestGraph", () => {
|
|
|
88
89
|
test("a bare root with a resource_type throws the 200 holding-page signal", async () => {
|
|
89
90
|
const err = (await resolveRequestGraph({
|
|
90
91
|
pattern: "/",
|
|
91
|
-
routeSlug: "proposal",
|
|
92
92
|
routeConfig: route({ pattern: "/", resource_type: "proposal" }),
|
|
93
93
|
loaderConfig: loader([{ key: "proposal" }]),
|
|
94
94
|
params: { host: "proposals.scala.business", path: "/" },
|
|
95
|
-
isStaticShell: false,
|
|
96
|
-
serviceSlug: "scala-proposals",
|
|
97
95
|
}).catch((e) => e)) as Error & { httpStatus: number; resourceType: string };
|
|
98
96
|
expect(err).toBeInstanceOf(Error);
|
|
99
97
|
expect((err as { httpStatus?: number }).httpStatus).toBe(200);
|
|
@@ -104,12 +102,9 @@ describe("resolveRequestGraph", () => {
|
|
|
104
102
|
loaderGraph = { proposal: null };
|
|
105
103
|
const err = (await resolveRequestGraph({
|
|
106
104
|
pattern: "/:id",
|
|
107
|
-
routeSlug: "proposal",
|
|
108
105
|
routeConfig: route({ resource_type: "proposal" }),
|
|
109
106
|
loaderConfig: loader([{ key: "proposal" }]),
|
|
110
107
|
params: { host: "proposals.scala.business", path: "/missing" },
|
|
111
|
-
isStaticShell: false,
|
|
112
|
-
serviceSlug: "scala-proposals",
|
|
113
108
|
}).catch((e) => e)) as Error & { httpStatus: number; resourceType: string };
|
|
114
109
|
expect((err as { httpStatus?: number }).httpStatus).toBe(404);
|
|
115
110
|
expect((err as { resourceType?: string }).resourceType).toBe("proposal");
|
|
@@ -119,12 +114,9 @@ describe("resolveRequestGraph", () => {
|
|
|
119
114
|
loaderGraph = { page: null };
|
|
120
115
|
const err = (await resolveRequestGraph({
|
|
121
116
|
pattern: "/:id",
|
|
122
|
-
routeSlug: "public-page",
|
|
123
117
|
routeConfig: route({ resource_type: undefined }),
|
|
124
118
|
loaderConfig: loader([{ key: "page" }]),
|
|
125
119
|
params: { host: "public.scala.business", path: "/deleted" },
|
|
126
|
-
isStaticShell: false,
|
|
127
|
-
serviceSlug: "scala-public",
|
|
128
120
|
}).catch((e) => e)) as Error & { httpStatus: number; empty: boolean };
|
|
129
121
|
expect((err as { httpStatus?: number }).httpStatus).toBe(404);
|
|
130
122
|
expect((err as { empty?: boolean }).empty).toBe(false);
|
|
@@ -134,12 +126,9 @@ describe("resolveRequestGraph", () => {
|
|
|
134
126
|
loaderGraph = { page: { content: [] } };
|
|
135
127
|
const err = (await resolveRequestGraph({
|
|
136
128
|
pattern: "/:id",
|
|
137
|
-
routeSlug: "public-page",
|
|
138
129
|
routeConfig: route({ resource_type: undefined }),
|
|
139
130
|
loaderConfig: loader([{ key: "page" }]),
|
|
140
131
|
params: { host: "public.scala.business", path: "/empty" },
|
|
141
|
-
isStaticShell: false,
|
|
142
|
-
serviceSlug: "scala-public",
|
|
143
132
|
}).catch((e) => e)) as Error & { httpStatus: number; empty: boolean };
|
|
144
133
|
expect((err as { httpStatus?: number }).httpStatus).toBe(200);
|
|
145
134
|
expect((err as { empty?: boolean }).empty).toBe(true);
|
|
@@ -149,12 +138,9 @@ describe("resolveRequestGraph", () => {
|
|
|
149
138
|
loaderGraph = { proposal: { id: "p1", name: "Q3" } };
|
|
150
139
|
const resolved = await resolveRequestGraph({
|
|
151
140
|
pattern: "/:id",
|
|
152
|
-
routeSlug: "proposal",
|
|
153
141
|
routeConfig: route({ resource_type: "proposal" }),
|
|
154
142
|
loaderConfig: loader([{ key: "proposal" }]),
|
|
155
143
|
params: { host: "proposals.scala.business", path: "/p1" },
|
|
156
|
-
isStaticShell: false,
|
|
157
|
-
serviceSlug: "scala-proposals",
|
|
158
144
|
});
|
|
159
145
|
expect((resolved.proposal as { id: string }).id).toBe("p1");
|
|
160
146
|
});
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { describe, expect, it } from "bun:test";
|
|
11
11
|
import { inlineLogoAsDataUri } from "./error-page-logo.ts";
|
|
12
12
|
import { PrimaryResourceMissingError, buildResourceNotFoundResponse } from "./resource-not-found.ts";
|
|
13
|
-
import { buildWakingPageHtml } from "@teamscala/
|
|
13
|
+
import { buildWakingPageHtml } from "@teamscala/brand/brand/waking-page-html";
|
|
14
14
|
|
|
15
15
|
describe("PrimaryResourceMissingError", () => {
|
|
16
16
|
it("is an Error carrying resourceType + httpStatus", () => {
|
|
@@ -51,6 +51,24 @@ describe("buildResourceNotFoundResponse", () => {
|
|
|
51
51
|
expect(logo).toBe("data:image/png;base64,iVBORw==");
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it("passes the resolved logo to the waking page instead of its placeholder", async () => {
|
|
55
|
+
const logo = await inlineLogoAsDataUri(
|
|
56
|
+
"/api/assets/c503629a/logos/cropped.png",
|
|
57
|
+
async () => ({
|
|
58
|
+
file() {
|
|
59
|
+
return {
|
|
60
|
+
stat: async () => ({}),
|
|
61
|
+
arrayBuffer: async () => new Uint8Array([137, 80, 78, 71]).buffer,
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
const html = buildWakingPageHtml({ logoUrl: logo });
|
|
67
|
+
expect(logo).toStartWith("data:image/png;base64,");
|
|
68
|
+
expect(html).toContain('<img src="data:image/png;base64,iVBORw=="');
|
|
69
|
+
expect(html).not.toContain('class="uif-logo-loading-pulse" aria-hidden="true"');
|
|
70
|
+
});
|
|
71
|
+
|
|
54
72
|
it("returns a 404 Response for a dead link", () => {
|
|
55
73
|
const res = buildResourceNotFoundResponse("project");
|
|
56
74
|
expect(res).toBeInstanceOf(Response);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
7
7
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
8
|
-
import { spawn } from "@teamscala/
|
|
8
|
+
import { spawn } from "@teamscala/process-spawn/spawn";
|
|
9
9
|
import { createWatchdog } from "@teamscala/watchdog/create-watchdog";
|
|
10
10
|
import type { WatchdogHandle } from "@teamscala/watchdog/types";
|
|
11
11
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
7
|
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
8
|
// with it into the band.
|
|
9
|
+
import * as __scalaReal_teamscala_db_registry_accessors from "@teamscala/db/registry/accessors";
|
|
9
10
|
|
|
10
11
|
import { describe, expect, mock, test } from "bun:test";
|
|
11
12
|
|
|
@@ -13,6 +14,7 @@ type ConfigRow = { fonts?: Record<string, string> } | undefined;
|
|
|
13
14
|
let injectedConfig: ConfigRow;
|
|
14
15
|
|
|
15
16
|
mock.module("@teamscala/db/registry/accessors", () => ({
|
|
17
|
+
...__scalaReal_teamscala_db_registry_accessors,
|
|
16
18
|
getEntryConfig: (_type: string, _slug: string) => injectedConfig,
|
|
17
19
|
}));
|
|
18
20
|
|
|
@@ -99,11 +99,57 @@ describe("phase-2 step isolation", () => {
|
|
|
99
99
|
expect(markFullAppReadyCall).toBeGreaterThan(gate);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
/*
|
|
103
|
+
* THE 2026-10-13 RULING (client-build failure must not kill the process).
|
|
104
|
+
*
|
|
105
|
+
* The gate used to THROW on a failed verification, and the caller in ui-service
|
|
106
|
+
* turns any phase-2 throw into process.exit(1). Measured on portal-dev: a
|
|
107
|
+
* hydrate build that failed on unresolved @teamscala/ui-foundation block
|
|
108
|
+
* imports exited the process, supervise-daemon respawned until it gave up, and
|
|
109
|
+
* the service was parked stopped — so a broken CLIENT BUNDLE took down the
|
|
110
|
+
* API, MCP and job surfaces too, and nothing rendered for an error boundary to
|
|
111
|
+
* catch. A build failure is a fault to REPORT, not a reason to exit.
|
|
112
|
+
*
|
|
113
|
+
* The gate now returns with clientBuildFailed set and never throws, so the
|
|
114
|
+
* process stays up serving its non-page surfaces while pages stay unready.
|
|
115
|
+
*/
|
|
116
|
+
test("a failed verification returns degraded instead of throwing out of phase 2", () => {
|
|
103
117
|
const body = buildFullAppBody();
|
|
104
118
|
const gate = body.indexOf("verifyClientBuildAssets()");
|
|
105
119
|
const slice = body.slice(gate, gate + 1200);
|
|
106
120
|
expect(slice).toContain("!clientBuild.ok");
|
|
107
|
-
expect(slice).toContain("throw new Error");
|
|
121
|
+
expect(slice).not.toContain("throw new Error");
|
|
122
|
+
expect(slice).toContain("clientBuildFailed");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/*
|
|
126
|
+
* The failure must REACH US. The reason is logged and recorded as a durable
|
|
127
|
+
* boot.client-build-failed event, so the degraded state is observable on our
|
|
128
|
+
* end rather than a silent page outage.
|
|
129
|
+
*/
|
|
130
|
+
test("a failed verification records the fault durably, so the degraded state is reported", () => {
|
|
131
|
+
const body = buildFullAppBody();
|
|
132
|
+
const gate = body.indexOf("verifyClientBuildAssets()");
|
|
133
|
+
const slice = body.slice(gate, gate + 1200);
|
|
134
|
+
expect(slice).toContain("log.error");
|
|
135
|
+
expect(slice).toContain("recordEventDurable");
|
|
136
|
+
expect(slice).toContain("boot.client-build-failed");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/*
|
|
140
|
+
* PAGES STAY UNAVAILABLE. The early return must sit BEFORE both readiness flip
|
|
141
|
+
* call sites, or the 2026-09-01 unstyled-proposal incident returns: a service
|
|
142
|
+
* whose client build never ran flips ready, serves pages, and answers /health
|
|
143
|
+
* 200 while every asset URL 404s. Degrading the PROCESS is not degrading READINESS.
|
|
144
|
+
*/
|
|
145
|
+
test("the degraded return precedes both readiness flips, so pages stay unready", () => {
|
|
146
|
+
const body = buildFullAppBody();
|
|
147
|
+
const gate = body.indexOf("verifyClientBuildAssets()");
|
|
148
|
+
const degraded = body.indexOf("clientBuildFailed: reason");
|
|
149
|
+
const setPageHandlerCall = body.indexOf("setPageHandler(deps.createPageHandler)");
|
|
150
|
+
const markFullAppReadyCall = body.indexOf("markFullAppReady()");
|
|
151
|
+
expect(degraded).toBeGreaterThan(gate);
|
|
152
|
+
expect(setPageHandlerCall).toBeGreaterThan(degraded);
|
|
153
|
+
expect(markFullAppReadyCall).toBeGreaterThan(degraded);
|
|
108
154
|
});
|
|
109
155
|
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
|
|
9
|
+
import { recordEventDurable } from "@teamscala/event-log/record-event";
|
|
9
10
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
10
11
|
import type { Hono } from "hono";
|
|
11
12
|
import { buildClientAssets } from "./build-client-assets.ts";
|
|
@@ -21,6 +22,7 @@ export interface FullAppResult {
|
|
|
21
22
|
blocks: { slug: string; packagePath: string }[];
|
|
22
23
|
routes: string[];
|
|
23
24
|
pgBossJobs: Array<{ handlerSlug: string }>;
|
|
25
|
+
clientBuildFailed?: string;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export async function buildFullApp(
|
|
@@ -63,12 +65,29 @@ export async function buildFullApp(
|
|
|
63
65
|
|
|
64
66
|
const clientBuild = await verifyClientBuildAssets();
|
|
65
67
|
if (!clientBuild.ok) {
|
|
66
|
-
|
|
67
|
-
`[two-phase-boot:${serviceSlug}]
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
);
|
|
68
|
+
const reason =
|
|
69
|
+
`[two-phase-boot:${serviceSlug}] client build is not servable — ${clientBuild.reason}. ` +
|
|
70
|
+
`Pages stay unavailable: serving here would emit asset URLs that 404 while every health surface reports ok. ` +
|
|
71
|
+
`Background work is mounted and unaffected, and the server keeps serving its API, MCP and job surfaces. ` +
|
|
72
|
+
`Fix the client build (see build-client-assets logs above); pages recover on the next restart.`;
|
|
73
|
+
log.error(reason);
|
|
74
|
+
await recordEventDurable({
|
|
75
|
+
id: crypto.randomUUID(),
|
|
76
|
+
kind: "boot.client-build-failed",
|
|
77
|
+
payload: {
|
|
78
|
+
at: new Date().toISOString(),
|
|
79
|
+
service: serviceSlug,
|
|
80
|
+
reason: reason.slice(0, 2000),
|
|
81
|
+
},
|
|
82
|
+
occurredAt: new Date(),
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
app: minimalApp,
|
|
86
|
+
blocks: [],
|
|
87
|
+
routes: [],
|
|
88
|
+
pgBossJobs,
|
|
89
|
+
clientBuildFailed: reason,
|
|
90
|
+
};
|
|
72
91
|
}
|
|
73
92
|
|
|
74
93
|
log.info(
|
package/src/ui-service.ts
CHANGED
|
@@ -101,6 +101,13 @@ export async function bootUiService(
|
|
|
101
101
|
|
|
102
102
|
swapToFullApp(fullAppResult.app, serviceSlug);
|
|
103
103
|
|
|
104
|
+
if (fullAppResult.clientBuildFailed !== undefined) {
|
|
105
|
+
log.error(
|
|
106
|
+
`[ui-service-boot:${serviceSlug}] Full app swapped in after ${fullDuration}ms with NO servable client build — the process stays up serving API, MCP and jobs; pages and /health stay unready. buildFullApp recorded boot.client-build-failed with the reason.`,
|
|
107
|
+
);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
104
111
|
log.info(
|
|
105
112
|
`[ui-service-boot:${serviceSlug}] Full app swapped in after ${fullDuration}ms (${fullDuration - minimalDuration}ms after minimal app)`,
|
|
106
113
|
);
|