@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.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/AGENTS.md +339 -190
- package/README.md +31 -0
- package/docs/architecture.md +238 -23
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/bridge.ts +110 -1
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +5 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +21 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +48 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +8 -7
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +46 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +18 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +57 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `_buildProviderCatalogue` — the bridge-side pure helper that
|
|
3
|
+
* derives ProviderInfo[] from a captured ModelRegistry.
|
|
4
|
+
* See change: replace-hardcoded-provider-lists.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import { _buildProviderCatalogue } from "../provider-register.js";
|
|
8
|
+
|
|
9
|
+
function makeRegistry(opts: {
|
|
10
|
+
oauthIds?: string[];
|
|
11
|
+
models?: Array<{ provider: string; id: string }>;
|
|
12
|
+
authStatus?: Record<string, { configured: boolean; source?: string }>;
|
|
13
|
+
credentials?: Record<string, { type: "oauth" | "api_key"; expires?: number; key?: string }>;
|
|
14
|
+
displayNames?: Record<string, string>;
|
|
15
|
+
}): any {
|
|
16
|
+
return {
|
|
17
|
+
authStorage: {
|
|
18
|
+
getOAuthProviders: () => (opts.oauthIds ?? []).map((id) => ({ id, name: id })),
|
|
19
|
+
getAuthStatus: (id: string) => opts.authStatus?.[id] ?? { configured: false },
|
|
20
|
+
get: (id: string) => opts.credentials?.[id],
|
|
21
|
+
},
|
|
22
|
+
getAll: () => opts.models ?? [],
|
|
23
|
+
getProviderDisplayName: (id: string) => opts.displayNames?.[id] ?? id,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("_buildProviderCatalogue", () => {
|
|
28
|
+
it("returns [] when registry is null", () => {
|
|
29
|
+
expect(_buildProviderCatalogue(null, {})).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("includes every OAuth id and every distinct model.provider, deduplicated", () => {
|
|
33
|
+
const reg = makeRegistry({
|
|
34
|
+
oauthIds: ["anthropic", "openai-codex"],
|
|
35
|
+
models: [
|
|
36
|
+
{ provider: "anthropic", id: "claude-4" },
|
|
37
|
+
{ provider: "deepseek", id: "deepseek-chat" },
|
|
38
|
+
{ provider: "deepseek", id: "deepseek-coder" },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
42
|
+
const ids = cat.map((c) => c.id).sort();
|
|
43
|
+
expect(ids).toEqual(["anthropic", "deepseek", "openai-codex"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("sets hasOAuth true only for ids in the OAuth provider set", () => {
|
|
47
|
+
const reg = makeRegistry({
|
|
48
|
+
oauthIds: ["anthropic"],
|
|
49
|
+
models: [{ provider: "anthropic", id: "x" }, { provider: "deepseek", id: "y" }],
|
|
50
|
+
});
|
|
51
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
52
|
+
expect(cat.find((c) => c.id === "anthropic")?.hasOAuth).toBe(true);
|
|
53
|
+
expect(cat.find((c) => c.id === "deepseek")?.hasOAuth).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("populates displayName from getProviderDisplayName, falling back to id", () => {
|
|
57
|
+
const reg = makeRegistry({
|
|
58
|
+
models: [{ provider: "deepseek", id: "x" }, { provider: "custom-llm", id: "y" }],
|
|
59
|
+
displayNames: { deepseek: "DeepSeek" },
|
|
60
|
+
});
|
|
61
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
62
|
+
expect(cat.find((c) => c.id === "deepseek")?.displayName).toBe("DeepSeek");
|
|
63
|
+
expect(cat.find((c) => c.id === "custom-llm")?.displayName).toBe("custom-llm");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("populates configured + source from authStorage.getAuthStatus", () => {
|
|
67
|
+
const reg = makeRegistry({
|
|
68
|
+
models: [{ provider: "openai", id: "gpt-4" }],
|
|
69
|
+
authStatus: { openai: { configured: false, source: "environment" } },
|
|
70
|
+
});
|
|
71
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
72
|
+
const row = cat.find((c) => c.id === "openai")!;
|
|
73
|
+
expect(row.configured).toBe(false);
|
|
74
|
+
expect(row.source).toBe("environment");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("populates expires for OAuth credentials in auth.json", () => {
|
|
78
|
+
const reg = makeRegistry({
|
|
79
|
+
oauthIds: ["anthropic"],
|
|
80
|
+
models: [{ provider: "anthropic", id: "x" }],
|
|
81
|
+
credentials: { anthropic: { type: "oauth", expires: 1234567890 } },
|
|
82
|
+
});
|
|
83
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
84
|
+
expect(cat.find((c) => c.id === "anthropic")?.expires).toBe(1234567890);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("api_key credentials do not surface expires", () => {
|
|
88
|
+
const reg = makeRegistry({
|
|
89
|
+
models: [{ provider: "openai", id: "x" }],
|
|
90
|
+
credentials: { openai: { type: "api_key", key: "sk-..." } },
|
|
91
|
+
});
|
|
92
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
93
|
+
expect(cat.find((c) => c.id === "openai")?.expires).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("populates envVar from piAi.findEnvKeys (first entry)", () => {
|
|
97
|
+
const reg = makeRegistry({ models: [{ provider: "openai", id: "x" }] });
|
|
98
|
+
const cat = _buildProviderCatalogue(reg, {
|
|
99
|
+
findEnvKeys: (id) => (id === "openai" ? ["OPENAI_API_KEY"] : undefined),
|
|
100
|
+
});
|
|
101
|
+
expect(cat.find((c) => c.id === "openai")?.envVar).toBe("OPENAI_API_KEY");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("sets ambient when piAi.getEnvApiKey returns '<authenticated>'", () => {
|
|
105
|
+
const reg = makeRegistry({ models: [{ provider: "google-vertex", id: "x" }] });
|
|
106
|
+
const cat = _buildProviderCatalogue(reg, {
|
|
107
|
+
getEnvApiKey: (id) => (id === "google-vertex" ? "<authenticated>" : undefined),
|
|
108
|
+
});
|
|
109
|
+
expect(cat.find((c) => c.id === "google-vertex")?.ambient).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("ambient stays undefined for ordinary api keys", () => {
|
|
113
|
+
const reg = makeRegistry({ models: [{ provider: "openai", id: "x" }] });
|
|
114
|
+
const cat = _buildProviderCatalogue(reg, {
|
|
115
|
+
getEnvApiKey: () => "sk-real-key",
|
|
116
|
+
});
|
|
117
|
+
expect(cat.find((c) => c.id === "openai")?.ambient).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does not throw when pi-ai helpers are missing", () => {
|
|
121
|
+
const reg = makeRegistry({ models: [{ provider: "openai", id: "x" }] });
|
|
122
|
+
expect(() => _buildProviderCatalogue(reg, {})).not.toThrow();
|
|
123
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
124
|
+
expect(cat[0].envVar).toBeUndefined();
|
|
125
|
+
expect(cat[0].ambient).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("marks ids in customIds set with custom:true; catalogue stays complete", () => {
|
|
129
|
+
const reg = makeRegistry({
|
|
130
|
+
models: [
|
|
131
|
+
{ provider: "deepseek", id: "deepseek-chat" },
|
|
132
|
+
{ provider: "proxy", id: "opus-4" }, // custom (registered via providers.json)
|
|
133
|
+
{ provider: "your-llmproxy", id: "foo" }, // custom
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
const customIds = new Set(["proxy", "your-llmproxy"]);
|
|
137
|
+
const cat = _buildProviderCatalogue(reg, {}, customIds);
|
|
138
|
+
// ALL providers present — the catalogue does not filter; consumers do.
|
|
139
|
+
const ids = cat.map((c) => c.id).sort();
|
|
140
|
+
expect(ids).toEqual(["deepseek", "proxy", "your-llmproxy"]);
|
|
141
|
+
// Custom flag is set only for custom providers.
|
|
142
|
+
expect(cat.find((c) => c.id === "deepseek")?.custom).toBeUndefined();
|
|
143
|
+
expect(cat.find((c) => c.id === "proxy")?.custom).toBe(true);
|
|
144
|
+
expect(cat.find((c) => c.id === "your-llmproxy")?.custom).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("OAuth-handler ids carry custom:true when in customIds (custom OAuth provider)", () => {
|
|
148
|
+
const reg = makeRegistry({
|
|
149
|
+
oauthIds: ["corporate-sso"],
|
|
150
|
+
models: [{ provider: "corporate-sso", id: "x" }, { provider: "deepseek", id: "y" }],
|
|
151
|
+
});
|
|
152
|
+
const cat = _buildProviderCatalogue(reg, {}, new Set(["corporate-sso"]));
|
|
153
|
+
expect(cat.find((c) => c.id === "corporate-sso")?.hasOAuth).toBe(true);
|
|
154
|
+
expect(cat.find((c) => c.id === "corporate-sso")?.custom).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("empty customIds = no provider marked custom (default behaviour)", () => {
|
|
158
|
+
const reg = makeRegistry({
|
|
159
|
+
models: [{ provider: "deepseek", id: "x" }, { provider: "proxy", id: "y" }],
|
|
160
|
+
});
|
|
161
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
162
|
+
const ids = cat.map((c) => c.id).sort();
|
|
163
|
+
expect(ids).toEqual(["deepseek", "proxy"]);
|
|
164
|
+
expect(cat.every((c) => !c.custom)).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("does not throw when getProviderDisplayName throws", () => {
|
|
168
|
+
const reg = {
|
|
169
|
+
authStorage: { getOAuthProviders: () => [], getAuthStatus: () => ({ configured: false }), get: () => undefined },
|
|
170
|
+
getAll: () => [{ provider: "weird", id: "x" }],
|
|
171
|
+
getProviderDisplayName: () => { throw new Error("boom"); },
|
|
172
|
+
};
|
|
173
|
+
const cat = _buildProviderCatalogue(reg, {});
|
|
174
|
+
expect(cat[0].displayName).toBe("weird");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the bridge's markdown image inliner.
|
|
3
|
+
* See change: chat-markdown-local-images-and-math.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
inlineMessageText,
|
|
8
|
+
parseImageTokens,
|
|
9
|
+
isLocalSrc,
|
|
10
|
+
mimeFromExtension,
|
|
11
|
+
hashBytes,
|
|
12
|
+
resolveLocalPath,
|
|
13
|
+
type ReadFileOutcome,
|
|
14
|
+
} from "../markdown-image-inliner.js";
|
|
15
|
+
|
|
16
|
+
/** Build a fake `readFile` from an in-memory map. Missing keys → ENOENT. */
|
|
17
|
+
function fakeReader(files: Record<string, Buffer | "EACCES" | "EISDIR" | "EOTHER">) {
|
|
18
|
+
return (absolutePath: string): ReadFileOutcome => {
|
|
19
|
+
const v = files[absolutePath];
|
|
20
|
+
if (v === undefined) return { ok: false, kind: "ENOENT" };
|
|
21
|
+
if (v === "EACCES") return { ok: false, kind: "EACCES" };
|
|
22
|
+
if (v === "EISDIR") return { ok: false, kind: "EISDIR" };
|
|
23
|
+
if (v === "EOTHER") return { ok: false, kind: "EOTHER" };
|
|
24
|
+
return { ok: true, bytes: v };
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PNG = Buffer.from("png-bytes");
|
|
29
|
+
const PNG_2 = Buffer.from("different-png-bytes");
|
|
30
|
+
const SVG = Buffer.from("<svg/>");
|
|
31
|
+
|
|
32
|
+
describe("parseImageTokens", () => {
|
|
33
|
+
it("matches a single image token", () => {
|
|
34
|
+
const tokens = parseImageTokens("Hello  world");
|
|
35
|
+
expect(tokens).toHaveLength(1);
|
|
36
|
+
expect(tokens[0].alt).toBe("pic");
|
|
37
|
+
expect(tokens[0].src).toBe("/abs/path.png");
|
|
38
|
+
expect(tokens[0].token).toBe("");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("matches multiple image tokens", () => {
|
|
42
|
+
const tokens = parseImageTokens(" and ");
|
|
43
|
+
expect(tokens.map((t) => t.src)).toEqual(["/x.png", "/y.png"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("does not match partial token without closing paren", () => {
|
|
47
|
+
const tokens = parseImageTokens("Hello ;
|
|
48
|
+
expect(tokens).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("does not match non-image link with single bracket", () => {
|
|
52
|
+
const tokens = parseImageTokens("[click](/x.png)");
|
|
53
|
+
expect(tokens).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("isLocalSrc", () => {
|
|
58
|
+
it("treats data: / blob: / http(s): / pi-asset: / # as non-local", () => {
|
|
59
|
+
expect(isLocalSrc("data:image/png;base64,XXX")).toBe(false);
|
|
60
|
+
expect(isLocalSrc("blob:abc-123")).toBe(false);
|
|
61
|
+
expect(isLocalSrc("http://x/y.png")).toBe(false);
|
|
62
|
+
expect(isLocalSrc("https://x/y.png")).toBe(false);
|
|
63
|
+
expect(isLocalSrc("pi-asset:abc1234567890123")).toBe(false);
|
|
64
|
+
expect(isLocalSrc("#anchor")).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("treats absolute and relative paths as local", () => {
|
|
68
|
+
expect(isLocalSrc("/abs/x.png")).toBe(true);
|
|
69
|
+
expect(isLocalSrc("./rel.png")).toBe(true);
|
|
70
|
+
expect(isLocalSrc("../up.png")).toBe(true);
|
|
71
|
+
expect(isLocalSrc("file:///a/b.png")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("mimeFromExtension", () => {
|
|
76
|
+
it("matches case-insensitively", () => {
|
|
77
|
+
expect(mimeFromExtension("/x/y.PNG")).toBe("image/png");
|
|
78
|
+
expect(mimeFromExtension("/x/y.JpEg")).toBe("image/jpeg");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns null for non-image extensions", () => {
|
|
82
|
+
expect(mimeFromExtension("/x/y.txt")).toBe(null);
|
|
83
|
+
expect(mimeFromExtension("/x/y")).toBe(null);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("recognizes svg", () => {
|
|
87
|
+
expect(mimeFromExtension("/x/y.svg")).toBe("image/svg+xml");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("hashBytes", () => {
|
|
92
|
+
it("produces 16 hex chars", () => {
|
|
93
|
+
const h = hashBytes(Buffer.from("hello"));
|
|
94
|
+
expect(h).toHaveLength(16);
|
|
95
|
+
expect(h).toMatch(/^[0-9a-f]{16}$/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("is deterministic", () => {
|
|
99
|
+
expect(hashBytes(Buffer.from("hello"))).toBe(hashBytes(Buffer.from("hello")));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("differs for different bytes", () => {
|
|
103
|
+
expect(hashBytes(Buffer.from("a"))).not.toBe(hashBytes(Buffer.from("b")));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("resolveLocalPath", () => {
|
|
108
|
+
it("strips file:// prefix", () => {
|
|
109
|
+
expect(resolveLocalPath("file:///abs/x.png", "/cwd")).toBe("/abs/x.png");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns absolute paths as-is", () => {
|
|
113
|
+
expect(resolveLocalPath("/abs/x.png", "/cwd")).toBe("/abs/x.png");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("resolves relative paths against cwd", () => {
|
|
117
|
+
expect(resolveLocalPath("./x.png", "/cwd")).toBe("/cwd/x.png");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("inlineMessageText — pass-through cases", () => {
|
|
122
|
+
it("external https URL passes through unchanged", () => {
|
|
123
|
+
const r = inlineMessageText("", {
|
|
124
|
+
readFile: fakeReader({}),
|
|
125
|
+
cwd: "/c",
|
|
126
|
+
alreadyEmitted: new Set(),
|
|
127
|
+
});
|
|
128
|
+
expect(r.rewritten).toBe("");
|
|
129
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("data: URL passes through unchanged", () => {
|
|
133
|
+
const r = inlineMessageText("", {
|
|
134
|
+
readFile: fakeReader({}),
|
|
135
|
+
cwd: "/c",
|
|
136
|
+
alreadyEmitted: new Set(),
|
|
137
|
+
});
|
|
138
|
+
expect(r.rewritten).toBe("");
|
|
139
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("blob: URL passes through unchanged", () => {
|
|
143
|
+
const r = inlineMessageText("", {
|
|
144
|
+
readFile: fakeReader({}),
|
|
145
|
+
cwd: "/c",
|
|
146
|
+
alreadyEmitted: new Set(),
|
|
147
|
+
});
|
|
148
|
+
expect(r.rewritten).toBe("");
|
|
149
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("partially-formed token (no closing paren) passes through unchanged", () => {
|
|
153
|
+
const text = "streaming chunk ,
|
|
156
|
+
cwd: "/c",
|
|
157
|
+
alreadyEmitted: new Set(),
|
|
158
|
+
});
|
|
159
|
+
expect(r.rewritten).toBe(text);
|
|
160
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("idempotent on text containing pi-asset tokens", () => {
|
|
164
|
+
const text = "Here is ";
|
|
165
|
+
const alreadyEmitted = new Set<string>(["abc1234567890123"]);
|
|
166
|
+
const r1 = inlineMessageText(text, {
|
|
167
|
+
readFile: fakeReader({}),
|
|
168
|
+
cwd: "/c",
|
|
169
|
+
alreadyEmitted,
|
|
170
|
+
});
|
|
171
|
+
const r2 = inlineMessageText(r1.rewritten, {
|
|
172
|
+
readFile: fakeReader({}),
|
|
173
|
+
cwd: "/c",
|
|
174
|
+
alreadyEmitted,
|
|
175
|
+
});
|
|
176
|
+
expect(r1.rewritten).toBe(text);
|
|
177
|
+
expect(r2.rewritten).toBe(text);
|
|
178
|
+
expect(r1.assetsToEmit).toEqual([]);
|
|
179
|
+
expect(r2.assetsToEmit).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("inlineMessageText — happy path", () => {
|
|
184
|
+
it("inlines a single absolute-path local image", () => {
|
|
185
|
+
const r = inlineMessageText("Here is  end", {
|
|
186
|
+
readFile: fakeReader({ "/home/me/shot.png": PNG }),
|
|
187
|
+
cwd: "/c",
|
|
188
|
+
alreadyEmitted: new Set(),
|
|
189
|
+
});
|
|
190
|
+
const expectedHash = hashBytes(PNG);
|
|
191
|
+
expect(r.rewritten).toBe(`Here is  end`);
|
|
192
|
+
expect(r.assetsToEmit).toEqual([
|
|
193
|
+
{ hash: expectedHash, mimeType: "image/png", data: PNG.toString("base64") },
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("inlines a relative-path image resolved against cwd", () => {
|
|
198
|
+
const r = inlineMessageText("", {
|
|
199
|
+
readFile: fakeReader({ "/work/shot.png": PNG }),
|
|
200
|
+
cwd: "/work",
|
|
201
|
+
alreadyEmitted: new Set(),
|
|
202
|
+
});
|
|
203
|
+
const expectedHash = hashBytes(PNG);
|
|
204
|
+
expect(r.rewritten).toBe(``);
|
|
205
|
+
expect(r.assetsToEmit).toHaveLength(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("inlines an SVG as image/svg+xml", () => {
|
|
209
|
+
const r = inlineMessageText("", {
|
|
210
|
+
readFile: fakeReader({ "/d.svg": SVG }),
|
|
211
|
+
cwd: "/c",
|
|
212
|
+
alreadyEmitted: new Set(),
|
|
213
|
+
});
|
|
214
|
+
expect(r.assetsToEmit[0].mimeType).toBe("image/svg+xml");
|
|
215
|
+
expect(r.rewritten).toContain("pi-asset:");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("matches extension case-insensitively", () => {
|
|
219
|
+
const r = inlineMessageText("", {
|
|
220
|
+
readFile: fakeReader({ "/x/SHOT.PNG": PNG }),
|
|
221
|
+
cwd: "/c",
|
|
222
|
+
alreadyEmitted: new Set(),
|
|
223
|
+
});
|
|
224
|
+
expect(r.assetsToEmit).toHaveLength(1);
|
|
225
|
+
expect(r.assetsToEmit[0].mimeType).toBe("image/png");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("inlineMessageText — dedup", () => {
|
|
230
|
+
it("emits one asset for two refs to the same file in one message", () => {
|
|
231
|
+
const r = inlineMessageText(" and ", {
|
|
232
|
+
readFile: fakeReader({ "/same.png": PNG }),
|
|
233
|
+
cwd: "/c",
|
|
234
|
+
alreadyEmitted: new Set(),
|
|
235
|
+
});
|
|
236
|
+
expect(r.assetsToEmit).toHaveLength(1);
|
|
237
|
+
const hash = hashBytes(PNG);
|
|
238
|
+
expect(r.rewritten).toBe(` and `);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("emits zero assets for refs to a hash already in alreadyEmitted", () => {
|
|
242
|
+
const hash = hashBytes(PNG);
|
|
243
|
+
const set = new Set<string>([hash]);
|
|
244
|
+
const r = inlineMessageText("", {
|
|
245
|
+
readFile: fakeReader({ "/same.png": PNG }),
|
|
246
|
+
cwd: "/c",
|
|
247
|
+
alreadyEmitted: set,
|
|
248
|
+
});
|
|
249
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
250
|
+
expect(r.rewritten).toBe(``);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("dedup carries across multiple inliner invocations sharing the same Set", () => {
|
|
254
|
+
const set = new Set<string>();
|
|
255
|
+
const r1 = inlineMessageText("", {
|
|
256
|
+
readFile: fakeReader({ "/x.png": PNG }),
|
|
257
|
+
cwd: "/c",
|
|
258
|
+
alreadyEmitted: set,
|
|
259
|
+
});
|
|
260
|
+
expect(r1.assetsToEmit).toHaveLength(1);
|
|
261
|
+
const r2 = inlineMessageText("", {
|
|
262
|
+
readFile: fakeReader({ "/x.png": PNG }),
|
|
263
|
+
cwd: "/c",
|
|
264
|
+
alreadyEmitted: set,
|
|
265
|
+
});
|
|
266
|
+
expect(r2.assetsToEmit).toEqual([]);
|
|
267
|
+
expect(r2.rewritten).toBe(r1.rewritten.replace("![a]", "![b]"));
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("inlineMessageText — placeholder branches", () => {
|
|
272
|
+
it("non-image extension yields [unsupported image type: ...]", () => {
|
|
273
|
+
const r = inlineMessageText("", {
|
|
274
|
+
readFile: fakeReader({ "/notes.txt": Buffer.from("hi") }),
|
|
275
|
+
cwd: "/c",
|
|
276
|
+
alreadyEmitted: new Set(),
|
|
277
|
+
});
|
|
278
|
+
expect(r.rewritten).toBe("[unsupported image type: /notes.txt]");
|
|
279
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("ENOENT yields [image not found: ...]", () => {
|
|
283
|
+
const r = inlineMessageText("", {
|
|
284
|
+
readFile: fakeReader({}),
|
|
285
|
+
cwd: "/c",
|
|
286
|
+
alreadyEmitted: new Set(),
|
|
287
|
+
});
|
|
288
|
+
expect(r.rewritten).toBe("[image not found: /no/such.png]");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("EACCES is folded into [image not found: ...] (no permission leak)", () => {
|
|
292
|
+
const r = inlineMessageText("", {
|
|
293
|
+
readFile: fakeReader({ "/root/private.png": "EACCES" }),
|
|
294
|
+
cwd: "/c",
|
|
295
|
+
alreadyEmitted: new Set(),
|
|
296
|
+
});
|
|
297
|
+
expect(r.rewritten).toBe("[image not found: /root/private.png]");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("EISDIR yields [image read failed: ...]", () => {
|
|
301
|
+
const r = inlineMessageText("", {
|
|
302
|
+
readFile: fakeReader({ "/home/me": "EISDIR" }),
|
|
303
|
+
cwd: "/c",
|
|
304
|
+
alreadyEmitted: new Set(),
|
|
305
|
+
});
|
|
306
|
+
expect(r.rewritten).toBe("[image read failed: /home/me]");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("oversized image yields [image too large: ...]", () => {
|
|
310
|
+
const big = Buffer.alloc(6 * 1024 * 1024, 0xab);
|
|
311
|
+
const r = inlineMessageText("", {
|
|
312
|
+
readFile: fakeReader({ "/big.png": big }),
|
|
313
|
+
cwd: "/c",
|
|
314
|
+
alreadyEmitted: new Set(),
|
|
315
|
+
});
|
|
316
|
+
expect(r.rewritten).toMatch(/^\[image too large: \/big\.png \(6\.0 MB\)\]$/);
|
|
317
|
+
expect(r.assetsToEmit).toEqual([]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("per-message budget exhausted after caps reached", () => {
|
|
321
|
+
// Five distinct 4.5 MB images. First four fit (18 MB ≤ 20 MB cap), fifth
|
|
322
|
+
// pushes total to 22.5 MB and is rejected.
|
|
323
|
+
const make = (i: number) => Buffer.alloc(4.5 * 1024 * 1024, i);
|
|
324
|
+
const files: Record<string, Buffer> = {};
|
|
325
|
+
let text = "";
|
|
326
|
+
for (let i = 0; i < 5; i++) {
|
|
327
|
+
const p = `/img${i}.png`;
|
|
328
|
+
files[p] = make(i);
|
|
329
|
+
text += ` `;
|
|
330
|
+
}
|
|
331
|
+
const r = inlineMessageText(text, {
|
|
332
|
+
readFile: fakeReader(files),
|
|
333
|
+
cwd: "/c",
|
|
334
|
+
alreadyEmitted: new Set(),
|
|
335
|
+
});
|
|
336
|
+
expect(r.assetsToEmit).toHaveLength(4);
|
|
337
|
+
expect(r.rewritten).toContain("[message asset budget exhausted: /img4.png]");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("already-registered asset does not count against budget", () => {
|
|
341
|
+
// 4 MB new + 18 MB already-registered → cumulative new bytes = 4 MB
|
|
342
|
+
const newImg = Buffer.alloc(4 * 1024 * 1024, 1);
|
|
343
|
+
const oldImg = Buffer.alloc(18 * 1024 * 1024, 2);
|
|
344
|
+
const oldHash = hashBytes(oldImg);
|
|
345
|
+
const r = inlineMessageText(" ", {
|
|
346
|
+
readFile: fakeReader({ "/new.png": newImg, "/old.png": oldImg }),
|
|
347
|
+
cwd: "/c",
|
|
348
|
+
alreadyEmitted: new Set([oldHash]),
|
|
349
|
+
});
|
|
350
|
+
expect(r.assetsToEmit).toHaveLength(1);
|
|
351
|
+
expect(r.assetsToEmit[0].hash).toBe(hashBytes(newImg));
|
|
352
|
+
expect(r.rewritten).toContain(`pi-asset:${hashBytes(newImg)}`);
|
|
353
|
+
expect(r.rewritten).toContain(`pi-asset:${oldHash}`);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -266,4 +266,72 @@ describe("detectOpenSpecActivity", () => {
|
|
|
266
266
|
expect(result).toEqual({ changeName: "add-auth", isActive: true });
|
|
267
267
|
});
|
|
268
268
|
});
|
|
269
|
+
|
|
270
|
+
describe("non-slug-shaped change names (fix-uuid-rename-bug)", () => {
|
|
271
|
+
const UUID = "019df0aa-1234-5678-9abc-def012345678";
|
|
272
|
+
|
|
273
|
+
it("returns null for UUID-shaped path on Read", () => {
|
|
274
|
+
const result = detectOpenSpecActivity("read", {
|
|
275
|
+
path: `openspec/changes/${UUID}/proposal.md`,
|
|
276
|
+
});
|
|
277
|
+
expect(result).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("returns null for UUID-shaped path on Write", () => {
|
|
281
|
+
const result = detectOpenSpecActivity("write", {
|
|
282
|
+
path: `openspec/changes/${UUID}/proposal.md`,
|
|
283
|
+
});
|
|
284
|
+
expect(result).toBeNull();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("returns null for UUID-shaped CLI argument", () => {
|
|
288
|
+
const result = detectOpenSpecActivity("bash", {
|
|
289
|
+
command: `openspec archive ${UUID}`,
|
|
290
|
+
});
|
|
291
|
+
expect(result).toBeNull();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("returns null for uppercase change name on Read", () => {
|
|
295
|
+
const result = detectOpenSpecActivity("read", {
|
|
296
|
+
path: "openspec/changes/AddAuth/proposal.md",
|
|
297
|
+
});
|
|
298
|
+
expect(result).toBeNull();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("returns null for underscore-containing CLI argument", () => {
|
|
302
|
+
const result = detectOpenSpecActivity("bash", {
|
|
303
|
+
command: "openspec archive add_auth",
|
|
304
|
+
});
|
|
305
|
+
expect(result).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("returns null for digit-prefixed CLI argument", () => {
|
|
309
|
+
const result = detectOpenSpecActivity("bash", {
|
|
310
|
+
command: "openspec archive 1bad",
|
|
311
|
+
});
|
|
312
|
+
expect(result).toBeNull();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("returns null for token exceeding 64-character cap", () => {
|
|
316
|
+
const longName = "a".repeat(65);
|
|
317
|
+
const result = detectOpenSpecActivity("write", {
|
|
318
|
+
path: `openspec/changes/${longName}/spec.md`,
|
|
319
|
+
});
|
|
320
|
+
expect(result).toBeNull();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("still extracts valid digit-containing kebab slug", () => {
|
|
324
|
+
const result = detectOpenSpecActivity("bash", {
|
|
325
|
+
command: "openspec archive valid-name-123",
|
|
326
|
+
});
|
|
327
|
+
expect(result).toEqual({ changeName: "valid-name-123", isActive: true });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("still extracts valid slug from Write path", () => {
|
|
331
|
+
const result = detectOpenSpecActivity("write", {
|
|
332
|
+
path: "openspec/changes/fix-mobile-attach/proposal.md",
|
|
333
|
+
});
|
|
334
|
+
expect(result).toEqual({ changeName: "fix-mobile-attach", isActive: true });
|
|
335
|
+
});
|
|
336
|
+
});
|
|
269
337
|
});
|
|
@@ -2,15 +2,23 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
2
2
|
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { expandPromptTemplateFromDisk } from "../prompt-expander.js";
|
|
5
|
+
import { parseSkillBlock } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
|
|
5
6
|
|
|
6
7
|
const tmpDir = join(import.meta.dirname ?? __dirname, "__tmp_prompt_test__");
|
|
7
8
|
const promptsDir = join(tmpDir, ".pi", "prompts");
|
|
9
|
+
const skillsDir = join(tmpDir, ".pi", "skills");
|
|
8
10
|
|
|
9
11
|
beforeEach(() => {
|
|
10
12
|
mkdirSync(promptsDir, { recursive: true });
|
|
11
13
|
writeFileSync(join(promptsDir, "opsx-continue.md"), "---\ndescription: continue\n---\nContinue the change");
|
|
12
14
|
writeFileSync(join(promptsDir, "opsx-apply.md"), "Apply the change");
|
|
13
15
|
writeFileSync(join(promptsDir, "hello.md"), "Hello world");
|
|
16
|
+
// Skill fixture
|
|
17
|
+
mkdirSync(join(skillsDir, "my-skill"), { recursive: true });
|
|
18
|
+
writeFileSync(
|
|
19
|
+
join(skillsDir, "my-skill", "SKILL.md"),
|
|
20
|
+
"---\nname: my-skill\ndescription: A demo skill\n---\nFirst body line\nSecond body line",
|
|
21
|
+
);
|
|
14
22
|
});
|
|
15
23
|
|
|
16
24
|
afterEach(() => {
|
|
@@ -51,4 +59,41 @@ describe("expandPromptTemplateFromDisk", () => {
|
|
|
51
59
|
expect(result).toBe("Continue the change");
|
|
52
60
|
expect(result).not.toContain("---");
|
|
53
61
|
});
|
|
62
|
+
|
|
63
|
+
// See change: render-skill-invocations-collapsibly.
|
|
64
|
+
|
|
65
|
+
it("wraps /skill:my-skill output in a <skill> envelope (with args)", () => {
|
|
66
|
+
const result = expandPromptTemplateFromDisk("/skill:my-skill do the thing", tmpDir);
|
|
67
|
+
expect(result.startsWith('<skill name="my-skill" location="')).toBe(true);
|
|
68
|
+
expect(result).toContain("References are relative to ");
|
|
69
|
+
expect(result).toContain("First body line\nSecond body line");
|
|
70
|
+
expect(result.endsWith("\n\ndo the thing")).toBe(true);
|
|
71
|
+
// round-trips through parseSkillBlock
|
|
72
|
+
const parsed = parseSkillBlock(result);
|
|
73
|
+
expect(parsed).not.toBeNull();
|
|
74
|
+
expect(parsed!.name).toBe("my-skill");
|
|
75
|
+
expect(parsed!.args).toBe("do the thing");
|
|
76
|
+
expect(parsed!.condensed).toBe("/skill:my-skill do the thing");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("wraps /skill:my-skill output in a <skill> envelope (without args)", () => {
|
|
80
|
+
const result = expandPromptTemplateFromDisk("/skill:my-skill", tmpDir);
|
|
81
|
+
expect(result.startsWith('<skill name="my-skill" location="')).toBe(true);
|
|
82
|
+
expect(result.endsWith("</skill>")).toBe(true);
|
|
83
|
+
expect(result).not.toContain("</skill>\n\n");
|
|
84
|
+
const parsed = parseSkillBlock(result);
|
|
85
|
+
expect(parsed!.args).toBeUndefined();
|
|
86
|
+
expect(parsed!.condensed).toBe("/skill:my-skill");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("prompt template /opsx-continue stays unwrapped (no <skill> tag)", () => {
|
|
90
|
+
const result = expandPromptTemplateFromDisk("/opsx-continue my-change", tmpDir);
|
|
91
|
+
expect(result).not.toContain("<skill name=");
|
|
92
|
+
expect(result).not.toContain("</skill>");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("colon-alias prompt template /opsx:continue stays unwrapped", () => {
|
|
96
|
+
const result = expandPromptTemplateFromDisk("/opsx:continue x", tmpDir);
|
|
97
|
+
expect(result).not.toContain("<skill name=");
|
|
98
|
+
});
|
|
54
99
|
});
|