@1agh/maude 0.43.0 → 0.44.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/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- package/plugins/design/dependencies.json +18 -0
package/apps/studio/server.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { spawn } from 'node:child_process';
|
|
18
18
|
|
|
19
19
|
import { createAcp } from './acp/index.ts';
|
|
20
|
+
import { cancelInstall, cancelSignin } from './acp/login-state.ts';
|
|
20
21
|
import { createActivity } from './activity.ts';
|
|
21
22
|
import { ASSET_MAX_VIDEO_BYTES, createApi } from './api.ts';
|
|
22
23
|
import { bootSelfHeal } from './boot-self-heal.ts';
|
|
@@ -27,6 +28,7 @@ import { createCollab } from './collab/index.ts';
|
|
|
27
28
|
import { createContext, reloadConfig } from './context.ts';
|
|
28
29
|
import { createExportJobQueue } from './exporters/jobs.ts';
|
|
29
30
|
import { createFsWatch } from './fs-watch.ts';
|
|
31
|
+
import { createGenerationJobQueue } from './generation/jobs.ts';
|
|
30
32
|
import { createGitWatch } from './git/watch.ts';
|
|
31
33
|
import { createHttp } from './http.ts';
|
|
32
34
|
import { createInspect } from './inspect.ts';
|
|
@@ -107,7 +109,9 @@ const activity = createActivity(ctx);
|
|
|
107
109
|
const acp = createAcp(ctx, aiActivity);
|
|
108
110
|
const ws = createWs(ctx, api, inspect, collab, activity, acp);
|
|
109
111
|
const exportJobs = createExportJobQueue(ctx.bus, ctx.paths.designRoot);
|
|
110
|
-
|
|
112
|
+
// feature-ai-media-generation (DDR-16x) — background AI-media generation queue.
|
|
113
|
+
const generateJobs = createGenerationJobQueue(ctx.bus, ctx.paths.designRoot);
|
|
114
|
+
const http = createHttp(ctx, api, inspect, aiActivity, exportJobs, generateJobs);
|
|
111
115
|
const fsWatch = createFsWatch(ctx);
|
|
112
116
|
|
|
113
117
|
// Port: --port arg > $PORT > $MDCC_DEV_PORT > 4399.
|
|
@@ -463,6 +467,14 @@ if (!process.env.NO_OPEN) {
|
|
|
463
467
|
|
|
464
468
|
async function shutdown() {
|
|
465
469
|
console.log('\n Stopping…');
|
|
470
|
+
// DDR-166 — reap in-flight claude-provisioning grandchildren before this
|
|
471
|
+
// process exits. Security-review finding: neither the SIGTERM/SIGINT path
|
|
472
|
+
// here nor sidecar.rs's child.kill() on the Tauri side propagate to a
|
|
473
|
+
// spawned `claude auth login`/installer child by default, so quitting
|
|
474
|
+
// mid-signin or mid-install orphaned it — exactly the DDR's own named-but-
|
|
475
|
+
// previously-unanswered "how does app-quit reach the grandchild" question.
|
|
476
|
+
cancelSignin();
|
|
477
|
+
cancelInstall();
|
|
466
478
|
fsWatch.stop();
|
|
467
479
|
try {
|
|
468
480
|
gitWatch.stop();
|
|
@@ -37,7 +37,12 @@ export function makeSandbox(): Sandbox {
|
|
|
37
37
|
return { root, designRoot };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// Base is spread by pid, not a fixed literal — bun test runs different test
|
|
41
|
+
// files as separate processes, each importing a fresh copy of this module, so
|
|
42
|
+
// a fixed base (e.g. 4500 for everyone) reliably collides across files. The
|
|
43
|
+
// pid spread doesn't guarantee uniqueness on its own — bootServer's pid check
|
|
44
|
+
// below is the actual safety net — but it makes collisions rare in practice.
|
|
45
|
+
let portCounter = 4500 + (process.pid % 4000);
|
|
41
46
|
export function nextPort(): number {
|
|
42
47
|
// Bump on every call so parallel tests don't collide. Bun.serve will throw
|
|
43
48
|
// EADDRINUSE if the host happened to bind one — caller retries with nextPort().
|
|
@@ -65,7 +70,15 @@ export async function bootServer(
|
|
|
65
70
|
const r = await fetch(`http://localhost:${port}/_health`, {
|
|
66
71
|
signal: AbortSignal.timeout(200),
|
|
67
72
|
});
|
|
68
|
-
if (r.ok)
|
|
73
|
+
if (r.ok) {
|
|
74
|
+
const json = (await r.json()) as { pid?: number };
|
|
75
|
+
// A same-port collision with a concurrently-running test file's server
|
|
76
|
+
// (see the portCounter comment above) would otherwise look "ready" —
|
|
77
|
+
// /_health answers fine, just from someone else's sandbox — and every
|
|
78
|
+
// later request 404s against a designRoot that doesn't have our
|
|
79
|
+
// fixtures. Only trust the health check if it's actually our process.
|
|
80
|
+
if (json.pid === proc.pid) return proc;
|
|
81
|
+
}
|
|
69
82
|
} catch {
|
|
70
83
|
/* not up yet */
|
|
71
84
|
}
|
|
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
|
|
11
11
|
import { AcpBridge } from '../acp/bridge.ts';
|
|
12
|
-
import { probeAcpAvailability } from '../acp/probe.ts';
|
|
12
|
+
import { probeAcpAvailability, probeAcpAvailabilityAuthed } from '../acp/probe.ts';
|
|
13
13
|
|
|
14
14
|
const FIXTURE = join(import.meta.dir, 'fixtures', 'mock-acp-agent.mjs');
|
|
15
15
|
const TEST_ENV_KEYS = [
|
|
@@ -261,3 +261,36 @@ describe('probeAcpAvailability — not-connected detection', () => {
|
|
|
261
261
|
expect(probe.adapterEntry).toBe(FIXTURE);
|
|
262
262
|
});
|
|
263
263
|
});
|
|
264
|
+
|
|
265
|
+
describe('probeAcpAvailabilityAuthed — DDR-166 T0d, logged-out must not read as available', () => {
|
|
266
|
+
const AUTH_FIXTURE = join(import.meta.dir, 'fixtures', 'fake-claude-auth.mjs');
|
|
267
|
+
|
|
268
|
+
afterEach(() => {
|
|
269
|
+
delete process.env.FAKE_CLAUDE_LOGGED_IN;
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('installed but NOT signed in reports available:false (the bug this closes)', async () => {
|
|
273
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
274
|
+
process.env.MAUDE_CLAUDE_BIN = AUTH_FIXTURE;
|
|
275
|
+
process.env.FAKE_CLAUDE_LOGGED_IN = '0';
|
|
276
|
+
const probe = await probeAcpAvailabilityAuthed();
|
|
277
|
+
expect(probe.available).toBe(false);
|
|
278
|
+
expect(probe.reason ?? '').toMatch(/signed in/i);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('installed AND signed in reports available:true', async () => {
|
|
282
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
283
|
+
process.env.MAUDE_CLAUDE_BIN = AUTH_FIXTURE;
|
|
284
|
+
process.env.FAKE_CLAUDE_LOGGED_IN = '1';
|
|
285
|
+
const probe = await probeAcpAvailabilityAuthed();
|
|
286
|
+
expect(probe.available).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('not installed still reports the plain not-installed reason (no auth spawn attempted)', async () => {
|
|
290
|
+
process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
|
|
291
|
+
process.env.MAUDE_CLAUDE_BIN = join(import.meta.dir, 'no-such-claude-bin');
|
|
292
|
+
const probe = await probeAcpAvailabilityAuthed();
|
|
293
|
+
expect(probe.available).toBe(false);
|
|
294
|
+
expect(probe.reason ?? '').toMatch(/Claude Code/i);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -62,4 +62,34 @@ describe('scrubAgentEnv — subscription guardrail', () => {
|
|
|
62
62
|
const out = scrubAgentEnv();
|
|
63
63
|
expect('ANTHROPIC_API_KEY' in out).toBe(false);
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
// feature-ai-media-generation Phase 1 (Task 1.4) + DDR-164 F3 — the ACP chat
|
|
67
|
+
// panel can TRIGGER generation because the BYOK provider key is resolved
|
|
68
|
+
// SERVER-SIDE (the dev-server, a separate process, reads the 0600 file /
|
|
69
|
+
// keychain via keys.ts). The ACP `claude` subprocess never needs a generation
|
|
70
|
+
// key-custody env var, so we SCRUB them: the Phase-5.1 keychain-bridge endpoint
|
|
71
|
+
// + key (`MAUDE_GEN_KEY_*` — F3, brought forward so the tripwire is armed before
|
|
72
|
+
// the bridge is wired) AND a custom key-file path (`MAUDE_GEN_KEYS_PATH`). This
|
|
73
|
+
// does NOT make the key unreachable to a compromised same-UID agent (it can
|
|
74
|
+
// still read the default keys.json off disk — the pre-existing full-tool-agent
|
|
75
|
+
// trifecta, documented, not closable by an env-scrub); it only ensures the child
|
|
76
|
+
// is never HANDED a pointer/credential it doesn't need. Meanwhile the `maude`
|
|
77
|
+
// CLI on PATH still resolves, so generation keeps working through the scrub.
|
|
78
|
+
test('scrubs the generation key-custody env vars, keeps CLI PATH (Task 1.4 / DDR-164 F3)', () => {
|
|
79
|
+
const out = scrubAgentEnv({
|
|
80
|
+
PATH: '/usr/local/bin:/usr/bin',
|
|
81
|
+
MAUDE_GEN_KEYS_PATH: '/Users/x/.config/maude/keys.json',
|
|
82
|
+
MAUDE_GEN_KEY_ENDPOINT: 'http://127.0.0.1:9/keychain', // Phase-5.1 bridge — MUST be scrubbed (F3)
|
|
83
|
+
MAUDE_GEN_KEY_KEY: 'per-launch-bridge-secret',
|
|
84
|
+
ANTHROPIC_API_KEY: 'sk-should-be-scrubbed',
|
|
85
|
+
});
|
|
86
|
+
// The generate CLI stays reachable (the sidecar, not this child, holds the key).
|
|
87
|
+
expect(out.PATH).toBe('/usr/local/bin:/usr/bin');
|
|
88
|
+
// Every generation key-custody var is stripped from the agent env.
|
|
89
|
+
expect('MAUDE_GEN_KEYS_PATH' in out).toBe(false);
|
|
90
|
+
expect('MAUDE_GEN_KEY_ENDPOINT' in out).toBe(false);
|
|
91
|
+
expect('MAUDE_GEN_KEY_KEY' in out).toBe(false);
|
|
92
|
+
// The subscription guardrail still fires.
|
|
93
|
+
expect('ANTHROPIC_API_KEY' in out).toBe(false);
|
|
94
|
+
});
|
|
65
95
|
});
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
// ACP session-scoped plugin auto-bootstrap resolver (DDR-143
|
|
1
|
+
// ACP session-scoped plugin auto-bootstrap resolver (DDR-143; unconditional
|
|
2
|
+
// injection DDR-168).
|
|
2
3
|
//
|
|
3
|
-
// The pure gate logic — native/bundle context
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// The pure gate logic — native/bundle context and per-plugin bundled-dir
|
|
5
|
+
// presence — all exercised without touching disk or ~/.claude. Since DDR-168,
|
|
6
|
+
// there is no longer a `scan`-gated no-op: a bundled plugin injects regardless
|
|
7
|
+
// of whether it's ALSO installed on disk (the double-registration risk that
|
|
8
|
+
// used to gate this is now closed structurally in bridge.ts instead — see
|
|
9
|
+
// acp-session-plugins.test.ts). The wire-level presence test (that the
|
|
10
|
+
// resolved configs actually land on `_meta.claudeCode.options.plugins`) lives
|
|
11
|
+
// in acp-session-plugins.test.ts.
|
|
8
12
|
|
|
9
13
|
import { describe, expect, test } from 'bun:test';
|
|
10
14
|
|
|
@@ -12,78 +16,40 @@ import { computeSessionPlugins } from '../acp/plugin-bootstrap.ts';
|
|
|
12
16
|
|
|
13
17
|
const DESIGN = '/bundle/plugins/design';
|
|
14
18
|
const FLOW = '/bundle/plugins/flow';
|
|
15
|
-
const NEITHER_INSTALLED = { design: false, flow: false };
|
|
16
19
|
|
|
17
20
|
describe('computeSessionPlugins — gates', () => {
|
|
18
21
|
// `/flow` auto-load is intentionally OFF for now (2026-07-03) — only `design`
|
|
19
|
-
// is ever injected, even when the flow dir resolves
|
|
20
|
-
test('
|
|
21
|
-
const out = computeSessionPlugins({
|
|
22
|
-
native: true,
|
|
23
|
-
designDir: DESIGN,
|
|
24
|
-
flowDir: FLOW,
|
|
25
|
-
scan: NEITHER_INSTALLED,
|
|
26
|
-
});
|
|
22
|
+
// is ever injected, even when the flow dir resolves.
|
|
23
|
+
test('native, both bundled ⇒ inject design only (flow disabled)', () => {
|
|
24
|
+
const out = computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW });
|
|
27
25
|
expect(out).toEqual([{ type: 'local', path: DESIGN, skipMcpDiscovery: true }]);
|
|
28
26
|
});
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
});
|
|
37
|
-
expect(out).toEqual([]);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('no-op gate: BOTH already installed (power user) ⇒ inject nothing', () => {
|
|
41
|
-
const out = computeSessionPlugins({
|
|
42
|
-
native: true,
|
|
43
|
-
designDir: DESIGN,
|
|
44
|
-
flowDir: FLOW,
|
|
45
|
-
scan: { design: true, flow: true },
|
|
46
|
-
});
|
|
47
|
-
expect(out).toEqual([]);
|
|
28
|
+
// DDR-168 — no more no-op gate: injection no longer depends on disk state.
|
|
29
|
+
// The double-registration risk this used to guard against is now closed
|
|
30
|
+
// structurally via bridge.ts's `options.settings.enabledPlugins` override.
|
|
31
|
+
test('design already installed on disk ⇒ STILL injects (no more scan-gated no-op)', () => {
|
|
32
|
+
const out = computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW });
|
|
33
|
+
expect(out).toEqual([{ type: 'local', path: DESIGN, skipMcpDiscovery: true }]);
|
|
48
34
|
});
|
|
49
35
|
|
|
50
36
|
test('native-only gate: not native (web serve) ⇒ inject nothing even if dirs resolve', () => {
|
|
51
|
-
const out = computeSessionPlugins({
|
|
52
|
-
native: false,
|
|
53
|
-
designDir: DESIGN,
|
|
54
|
-
flowDir: FLOW,
|
|
55
|
-
scan: NEITHER_INSTALLED,
|
|
56
|
-
});
|
|
37
|
+
const out = computeSessionPlugins({ native: false, designDir: DESIGN, flowDir: FLOW });
|
|
57
38
|
expect(out).toEqual([]);
|
|
58
39
|
});
|
|
59
40
|
|
|
60
41
|
test('path-missing gate: design dir null ⇒ inject nothing (flow disabled, so no fallback)', () => {
|
|
61
|
-
const out = computeSessionPlugins({
|
|
62
|
-
native: true,
|
|
63
|
-
designDir: null,
|
|
64
|
-
flowDir: FLOW,
|
|
65
|
-
scan: NEITHER_INSTALLED,
|
|
66
|
-
});
|
|
42
|
+
const out = computeSessionPlugins({ native: true, designDir: null, flowDir: FLOW });
|
|
67
43
|
expect(out).toEqual([]);
|
|
68
44
|
});
|
|
69
45
|
|
|
70
46
|
test('web/npm layout: native false AND both dirs null ⇒ empty', () => {
|
|
71
|
-
const out = computeSessionPlugins({
|
|
72
|
-
native: false,
|
|
73
|
-
designDir: null,
|
|
74
|
-
flowDir: null,
|
|
75
|
-
scan: NEITHER_INSTALLED,
|
|
76
|
-
});
|
|
47
|
+
const out = computeSessionPlugins({ native: false, designDir: null, flowDir: null });
|
|
77
48
|
expect(out).toEqual([]);
|
|
78
49
|
});
|
|
79
50
|
|
|
80
51
|
test('every injected config sets skipMcpDiscovery (SDK host owns MCP)', () => {
|
|
81
|
-
const out = computeSessionPlugins({
|
|
82
|
-
native: true,
|
|
83
|
-
designDir: DESIGN,
|
|
84
|
-
flowDir: FLOW,
|
|
85
|
-
scan: NEITHER_INSTALLED,
|
|
86
|
-
});
|
|
52
|
+
const out = computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW });
|
|
87
53
|
expect(out.every((c) => c.skipMcpDiscovery === true && c.type === 'local')).toBe(true);
|
|
88
54
|
});
|
|
89
55
|
});
|
|
@@ -22,7 +22,13 @@ const PLUGINS: SdkPluginConfig[] = [
|
|
|
22
22
|
|
|
23
23
|
type Meta = {
|
|
24
24
|
systemPrompt?: { append?: string };
|
|
25
|
-
claudeCode?: {
|
|
25
|
+
claudeCode?: {
|
|
26
|
+
options?: {
|
|
27
|
+
plugins?: SdkPluginConfig[];
|
|
28
|
+
settingSources?: string[];
|
|
29
|
+
settings?: { enabledPlugins?: Record<string, boolean> };
|
|
30
|
+
};
|
|
31
|
+
};
|
|
26
32
|
};
|
|
27
33
|
|
|
28
34
|
describe('newSessionParams — plugin carrier shape', () => {
|
|
@@ -79,6 +85,27 @@ describe('newSessionParams — plugin carrier shape', () => {
|
|
|
79
85
|
expect((p._meta as Meta).claudeCode?.options?.settingSources).toEqual(['user']);
|
|
80
86
|
}
|
|
81
87
|
});
|
|
88
|
+
|
|
89
|
+
// DDR-168 — the structural double-registration guard: a non-empty `plugins`
|
|
90
|
+
// must also force off any natively-loaded user-level copy of the same id via
|
|
91
|
+
// the SDK's "flag" settings layer, so the bundled copy is the ONLY one that
|
|
92
|
+
// ever loads.
|
|
93
|
+
test('with plugins: forces design@maude off via options.settings.enabledPlugins (double-registration guard)', () => {
|
|
94
|
+
const p = newSessionParams('/repo', undefined, PLUGINS);
|
|
95
|
+
expect((p._meta as Meta).claudeCode?.options?.settings?.enabledPlugins).toEqual({
|
|
96
|
+
'design@maude': false,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('empty plugins: options.settings is entirely absent (web/npm path unaffected)', () => {
|
|
101
|
+
const p = newSessionParams('/repo', 'BRIEF', []);
|
|
102
|
+
expect((p._meta as Meta).claudeCode?.options?.settings).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('undefined plugins: options.settings is entirely absent', () => {
|
|
106
|
+
const p = newSessionParams('/repo', 'BRIEF');
|
|
107
|
+
expect((p._meta as Meta).claudeCode?.options?.settings).toBeUndefined();
|
|
108
|
+
});
|
|
82
109
|
});
|
|
83
110
|
|
|
84
111
|
describe('upgrade guard — installed adapter + SDK still honor the plugins contract', () => {
|
|
@@ -129,4 +156,48 @@ describe('upgrade guard — installed adapter + SDK still honor the plugins cont
|
|
|
129
156
|
expect(src).toMatch(/SdkPluginConfig\s*=\s*\{/);
|
|
130
157
|
expect(src).toMatch(/type:\s*'local'/);
|
|
131
158
|
});
|
|
159
|
+
|
|
160
|
+
// DDR-168 — the same style of guard, for the `options.settings` mechanism the
|
|
161
|
+
// double-registration fix depends on: `settings` must NOT be among the fields
|
|
162
|
+
// the adapter overrides after the `...userProvidedOptions` spread (else our
|
|
163
|
+
// `enabledPlugins: false` override would be silently discarded), and the SDK
|
|
164
|
+
// must still declare `Settings.enabledPlugins` as an accepted field.
|
|
165
|
+
test('adapter forwards `settings` from userProvidedOptions and only ever overrides it conditionally when the caller left it unset', () => {
|
|
166
|
+
expect(existsSync(adapterEntry)).toBe(true);
|
|
167
|
+
const src = readFileSync(adapterEntry, 'utf8');
|
|
168
|
+
// Locate the `...userProvidedOptions` spread and inspect what follows it up
|
|
169
|
+
// to the `query({ options })` call. Verified live at claude-agent-acp@0.57.0:
|
|
170
|
+
// the object DOES contain a later `settings:` assignment (a
|
|
171
|
+
// CLAUDE_MODEL_CONFIG env-var fallback) — but it's gated behind
|
|
172
|
+
// `!userProvidedOptions?.settings && …`, i.e. it only fires when the caller
|
|
173
|
+
// did NOT already provide a settings object. A caller-provided `settings`
|
|
174
|
+
// (ours, carrying `enabledPlugins`) always wins. A bump that drops that
|
|
175
|
+
// guard — making the fallback unconditional — must fail here, since it
|
|
176
|
+
// would silently clobber DDR-168's double-registration override.
|
|
177
|
+
const spreadIdx = src.indexOf('...userProvidedOptions');
|
|
178
|
+
expect(spreadIdx).toBeGreaterThan(-1);
|
|
179
|
+
const queryIdx = src.indexOf('query(', spreadIdx);
|
|
180
|
+
expect(queryIdx).toBeGreaterThan(spreadIdx);
|
|
181
|
+
const window = src.slice(spreadIdx, queryIdx);
|
|
182
|
+
expect(window).toMatch(/!userProvidedOptions\?\.settings\s*&&/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("SDK Options declares settings (Settings.enabledPlugins is the field DDR-168's guard relies on)", () => {
|
|
186
|
+
let sdkPkg: string | null = null;
|
|
187
|
+
try {
|
|
188
|
+
const realAdapterDir = dirname(realpathSync(adapterEntry));
|
|
189
|
+
sdkPkg = Bun.resolveSync('@anthropic-ai/claude-agent-sdk/package.json', realAdapterDir);
|
|
190
|
+
} catch {
|
|
191
|
+
sdkPkg = null;
|
|
192
|
+
}
|
|
193
|
+
expect(sdkPkg).not.toBeNull();
|
|
194
|
+
const dts = join(dirname(sdkPkg as string), 'sdk.d.ts');
|
|
195
|
+
expect(existsSync(dts)).toBe(true);
|
|
196
|
+
const src = readFileSync(dts, 'utf8');
|
|
197
|
+
// sdk.d.ts:1831 `settings?: Settings;` (or similarly named field on Options) +
|
|
198
|
+
// :5193 `Settings.enabledPlugins` — a bump that renames/drops either must
|
|
199
|
+
// fail here, then adapt, rather than silently reopening double-registration.
|
|
200
|
+
expect(src).toMatch(/settings\?\s*:/);
|
|
201
|
+
expect(src).toMatch(/enabledPlugins\??\s*:/);
|
|
202
|
+
});
|
|
132
203
|
});
|
|
@@ -90,6 +90,23 @@ describe('createAnnotationStrokesCommand', () => {
|
|
|
90
90
|
expect(putFn.mock.calls[1]?.[0]).toEqual([pen1]);
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
+
test("do() passes the pre-op BEFORE as putFn's second arg; undo() passes the post-op AFTER", async () => {
|
|
94
|
+
// The live-bug regression this locks in: putStrokes needs its own
|
|
95
|
+
// command's baseline to tell a delete (an id `before` had that `next`
|
|
96
|
+
// dropped) apart from a genuinely concurrent sibling addition (an id
|
|
97
|
+
// `prev` has that this command's OWN before/next never knew about).
|
|
98
|
+
const putFn = mock(() => Promise.resolve());
|
|
99
|
+
const cmd = createAnnotationStrokesCommand({
|
|
100
|
+
before: [pen1, pen2],
|
|
101
|
+
after: [pen1],
|
|
102
|
+
putFn,
|
|
103
|
+
});
|
|
104
|
+
await cmd.do();
|
|
105
|
+
expect(putFn.mock.calls[0]).toEqual([[pen1], [pen1, pen2]]);
|
|
106
|
+
await cmd.undo();
|
|
107
|
+
expect(putFn.mock.calls[1]).toEqual([[pen1, pen2], [pen1]]);
|
|
108
|
+
});
|
|
109
|
+
|
|
93
110
|
test('default label — add 2 strokes', () => {
|
|
94
111
|
const cmd = createAnnotationStrokesCommand({
|
|
95
112
|
before: [pen1],
|
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
polygonPoints,
|
|
22
22
|
polygonVertices,
|
|
23
23
|
type RectStroke,
|
|
24
|
+
reconcileCommit,
|
|
25
|
+
reconcileForeignEcho,
|
|
26
|
+
resolveImageUploadSwap,
|
|
24
27
|
rid,
|
|
25
28
|
STICKY_PALETTE,
|
|
26
29
|
type StickyStroke,
|
|
@@ -427,6 +430,177 @@ describe('annotations-layer / strokes round-trip is stable for arrays', () => {
|
|
|
427
430
|
});
|
|
428
431
|
});
|
|
429
432
|
|
|
433
|
+
describe('annotations-layer / reconcileCommit (live-bug regression — delete must stick)', () => {
|
|
434
|
+
const a: RectStroke = {
|
|
435
|
+
id: 'a',
|
|
436
|
+
tool: 'rect',
|
|
437
|
+
color: '#000',
|
|
438
|
+
width: 2,
|
|
439
|
+
x: 0,
|
|
440
|
+
y: 0,
|
|
441
|
+
w: 10,
|
|
442
|
+
h: 10,
|
|
443
|
+
};
|
|
444
|
+
const b: RectStroke = {
|
|
445
|
+
id: 'b',
|
|
446
|
+
tool: 'rect',
|
|
447
|
+
color: '#000',
|
|
448
|
+
width: 2,
|
|
449
|
+
x: 20,
|
|
450
|
+
y: 0,
|
|
451
|
+
w: 10,
|
|
452
|
+
h: 10,
|
|
453
|
+
};
|
|
454
|
+
const c: RectStroke = {
|
|
455
|
+
id: 'c',
|
|
456
|
+
tool: 'rect',
|
|
457
|
+
color: '#000',
|
|
458
|
+
width: 2,
|
|
459
|
+
x: 40,
|
|
460
|
+
y: 0,
|
|
461
|
+
w: 10,
|
|
462
|
+
h: 10,
|
|
463
|
+
};
|
|
464
|
+
const d: RectStroke = {
|
|
465
|
+
id: 'd',
|
|
466
|
+
tool: 'rect',
|
|
467
|
+
color: '#000',
|
|
468
|
+
width: 2,
|
|
469
|
+
x: 60,
|
|
470
|
+
y: 0,
|
|
471
|
+
w: 10,
|
|
472
|
+
h: 10,
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
test('a delete stays deleted even when the rendered `prev` has not caught up yet', () => {
|
|
476
|
+
// This is exactly the reported bug: Backspace computes next = before
|
|
477
|
+
// minus the deleted id, but React's `prev` (read by the functional
|
|
478
|
+
// updater) still shows the pre-delete set. The old reconcileIncoming
|
|
479
|
+
// treated "id missing from next" as "not caught up, fold it back" —
|
|
480
|
+
// reverting every delete locally while the smaller set still went out
|
|
481
|
+
// over PUT.
|
|
482
|
+
const prev = [a, b, c]; // rendered state, hasn't caught up to the delete
|
|
483
|
+
const opBefore = [a, b, c]; // this command's own baseline
|
|
484
|
+
const next = [a, b]; // c deleted
|
|
485
|
+
expect(reconcileCommit(prev, opBefore, next)).toEqual([a, b]);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('a genuinely concurrent sibling addition (unknown to this commit) is still folded in', () => {
|
|
489
|
+
// `prev` has `d`, which neither this commit's `opBefore` nor `next` knows
|
|
490
|
+
// about — a different in-flight commit added it after `opBefore` was
|
|
491
|
+
// captured. It must survive.
|
|
492
|
+
const prev = [a, b, d];
|
|
493
|
+
const opBefore = [a, b];
|
|
494
|
+
const next = [a, b, c]; // this commit adds c
|
|
495
|
+
expect(reconcileCommit(prev, opBefore, next)).toEqual([a, b, c, d]);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('delete + concurrent addition combine correctly (delete wins for its own id, addition still folds in)', () => {
|
|
499
|
+
const prev = [a, b, c, d]; // rendered: hasn't caught up to the delete OR seen `d` reconciled yet
|
|
500
|
+
const opBefore = [a, b, c];
|
|
501
|
+
const next = [a, b]; // deletes c
|
|
502
|
+
expect(reconcileCommit(prev, opBefore, next)).toEqual([a, b, d]);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
describe('annotations-layer / reconcileForeignEcho (deletes must sync across tabs/peers)', () => {
|
|
507
|
+
const a: RectStroke = {
|
|
508
|
+
id: 'a',
|
|
509
|
+
tool: 'rect',
|
|
510
|
+
color: '#000',
|
|
511
|
+
width: 2,
|
|
512
|
+
x: 0,
|
|
513
|
+
y: 0,
|
|
514
|
+
w: 10,
|
|
515
|
+
h: 10,
|
|
516
|
+
};
|
|
517
|
+
const b: RectStroke = {
|
|
518
|
+
id: 'b',
|
|
519
|
+
tool: 'rect',
|
|
520
|
+
color: '#000',
|
|
521
|
+
width: 2,
|
|
522
|
+
x: 20,
|
|
523
|
+
y: 0,
|
|
524
|
+
w: 10,
|
|
525
|
+
h: 10,
|
|
526
|
+
};
|
|
527
|
+
const optimisticImage: ImageStroke = {
|
|
528
|
+
id: 'img',
|
|
529
|
+
tool: 'image',
|
|
530
|
+
x: 0,
|
|
531
|
+
y: 0,
|
|
532
|
+
w: 10,
|
|
533
|
+
h: 10,
|
|
534
|
+
href: 'blob:local-preview',
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
test('a foreign echo missing a non-ephemeral id (a real delete) is NOT reverted', () => {
|
|
538
|
+
const prev = [a, b];
|
|
539
|
+
const incoming = [a]; // peer deleted b
|
|
540
|
+
expect(reconcileForeignEcho(prev, incoming)).toEqual([a]);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test('a still-uploading local optimistic image (ephemeral href) survives a foreign echo that predates it', () => {
|
|
544
|
+
const prev = [a, optimisticImage];
|
|
545
|
+
const incoming = [a]; // the peer's broadcast was authored before our upload started
|
|
546
|
+
expect(reconcileForeignEcho(prev, incoming)).toEqual([a, optimisticImage]);
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
describe('annotations-layer / resolveImageUploadSwap (delete-during-upload must not resurrect)', () => {
|
|
551
|
+
// Adversarial-review finding: dropping an image, then Backspacing it
|
|
552
|
+
// before its upload resolves, used to resurrect it once the upload
|
|
553
|
+
// landed — the swap read "id absent from `before`" as render lag
|
|
554
|
+
// unconditionally, with no way to tell that apart from a genuine delete.
|
|
555
|
+
const optimistic: ImageStroke = {
|
|
556
|
+
id: 'img-1',
|
|
557
|
+
tool: 'image',
|
|
558
|
+
x: 0,
|
|
559
|
+
y: 0,
|
|
560
|
+
w: 10,
|
|
561
|
+
h: 10,
|
|
562
|
+
href: 'blob:optimistic-preview',
|
|
563
|
+
};
|
|
564
|
+
const sibling: RectStroke = {
|
|
565
|
+
id: 'sib',
|
|
566
|
+
tool: 'rect',
|
|
567
|
+
color: '#000',
|
|
568
|
+
width: 2,
|
|
569
|
+
x: 20,
|
|
570
|
+
y: 0,
|
|
571
|
+
w: 10,
|
|
572
|
+
h: 10,
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
test('deleted before the chain caught up to the optimistic insert (common case) → stays gone, no undo record', () => {
|
|
576
|
+
const before = [sibling]; // render lag — optimistic insert not reflected yet
|
|
577
|
+
const result = resolveImageUploadSwap(before, 'img-1', optimistic, 'assets/real.png', true);
|
|
578
|
+
expect(result).toBeNull();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test('deleted after the chain already had the optimistic entry → explicitly stripped from `after`', () => {
|
|
582
|
+
const before = [sibling, optimistic];
|
|
583
|
+
const result = resolveImageUploadSwap(before, 'img-1', optimistic, 'assets/real.png', true);
|
|
584
|
+
expect(result?.after).toEqual([sibling]);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test('NOT deleted + render lag (the original, still-valid case) → swaps to the real href', () => {
|
|
588
|
+
const before = [sibling]; // optimistic insert hasn't rendered into `before` yet
|
|
589
|
+
const result = resolveImageUploadSwap(before, 'img-1', optimistic, 'assets/real.png', false);
|
|
590
|
+
const img = result?.after.find((s) => s.id === 'img-1') as unknown as { href: string };
|
|
591
|
+
expect(img?.href).toBe('assets/real.png');
|
|
592
|
+
expect(result?.commitBefore).toEqual([sibling]); // undo excludes the never-committed blob: entry
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test('NOT deleted, already present in `before` → swaps in place, sibling order preserved', () => {
|
|
596
|
+
const before = [sibling, optimistic];
|
|
597
|
+
const result = resolveImageUploadSwap(before, 'img-1', optimistic, 'assets/real.png', false);
|
|
598
|
+
expect(result?.after.map((s) => s.id)).toEqual(['sib', 'img-1']);
|
|
599
|
+
const img = result?.after.find((s) => s.id === 'img-1') as unknown as { href: string };
|
|
600
|
+
expect(img?.href).toBe('assets/real.png');
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
430
604
|
describe('annotations-layer / strokesShallowEqual (drag no-op gate)', () => {
|
|
431
605
|
const pen: PenStroke = {
|
|
432
606
|
id: 'p',
|
|
@@ -169,12 +169,49 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
169
169
|
// the gate on both — the catalogue route and the static PNG serve.
|
|
170
170
|
'/_api/stickers',
|
|
171
171
|
'/_stickers/some-pack/some-sticker.png',
|
|
172
|
+
// DDR-166 Phase 1 / T2 — the bundled intro-video media serve is
|
|
173
|
+
// MAIN-ORIGIN ONLY, same posture as /_stickers above: a Maude-product
|
|
174
|
+
// asset, absent from CANVAS_SAFE_API + startCanvasServer's routes.
|
|
175
|
+
'/_media/intro.mp4',
|
|
172
176
|
// feature-footage-analysis-director — the footage-analysis + EDL sidecar
|
|
173
177
|
// route is MAIN-ORIGIN ONLY (written by the analyst/director agents over
|
|
174
178
|
// loopback; absent from CANVAS_SAFE_API + startCanvasServer's routes). The
|
|
175
179
|
// untrusted canvas origin must never read/write the director's analysis —
|
|
176
180
|
// a GET here 403s at the gate, not 405 from a reached handler.
|
|
177
181
|
'/_api/footage',
|
|
182
|
+
// feature-ai-media-generation (DDR-16x) — the generation job queue +
|
|
183
|
+
// provider catalogue + key routes are MAIN-ORIGIN ONLY (they resolve a
|
|
184
|
+
// provider KEY and make outbound provider calls). The untrusted canvas
|
|
185
|
+
// origin must never reach them — it sees only the produced /assets/<sha8>.
|
|
186
|
+
// A GET here 403s at the gate, not 405/200 from a reached handler.
|
|
187
|
+
'/_api/generate-jobs',
|
|
188
|
+
'/_api/generate/providers',
|
|
189
|
+
'/_api/generate/keys',
|
|
190
|
+
// Task 2.6 — the non-secret transcription-engine preference writes into
|
|
191
|
+
// .design/config.json + hot-reloads; still MAIN-ORIGIN ONLY (the canvas
|
|
192
|
+
// must not steer the user's engine choice).
|
|
193
|
+
'/_api/generate/prefs',
|
|
194
|
+
// Task 2.5 — reuse-before-you-pay audio search + history re-download
|
|
195
|
+
// resolve the provider key server-side; MAIN-ORIGIN ONLY.
|
|
196
|
+
'/_api/generate/audio-search',
|
|
197
|
+
'/_api/generate/audio-reuse',
|
|
198
|
+
// Task 2.7 — managed whisper-model download (egress + local disk write);
|
|
199
|
+
// MAIN-ORIGIN ONLY.
|
|
200
|
+
'/_api/generate/whisper-model',
|
|
201
|
+
// DDR-166 plan, Phase 2 (T6) — the design-setup readiness probe (project/
|
|
202
|
+
// design-system/first-canvas/brand-assets progress) is a shell/onboarding
|
|
203
|
+
// concern, same posture as /_api/preflight above; MAIN-ORIGIN ONLY.
|
|
204
|
+
'/_api/setup-readiness',
|
|
205
|
+
// DDR-167 (Phase 3 / T10) — local-file SVG/PDF ingestion is a
|
|
206
|
+
// file-write + local-file-adjacent surface (Decision 4); MAIN-ORIGIN
|
|
207
|
+
// ONLY, same posture as /_api/asset's containment but privileged
|
|
208
|
+
// rather than canvas-reachable — the untrusted canvas iframe must
|
|
209
|
+
// never drive an import.
|
|
210
|
+
'/_api/import-asset',
|
|
211
|
+
// DDR-173 (Phase 3 / T12) — brand-file typed-cue extraction is a
|
|
212
|
+
// file-write surface (writes assets/logos/*), same privileged
|
|
213
|
+
// posture as /_api/import-asset above; MAIN-ORIGIN ONLY.
|
|
214
|
+
'/_api/import-brand',
|
|
178
215
|
'/package.json',
|
|
179
216
|
]) {
|
|
180
217
|
expect(await code(p)).toBe(403);
|
|
@@ -48,4 +48,15 @@ describe('cspForCanvasShell — A6 hardening', () => {
|
|
|
48
48
|
const csp = cspForCanvasShell(SHELL);
|
|
49
49
|
expect(csp).not.toContain('frame-ancestors');
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
// fix-photo-editor-followup-debt Task 22 — the narrow, documented
|
|
53
|
+
// `connect-src` exception for @imgly/background-removal's model-weight CDN.
|
|
54
|
+
// Regression guard against both directions of drift: the exception being
|
|
55
|
+
// silently reverted, AND further hosts creeping in beside it.
|
|
56
|
+
test('connect-src carries the exact staticimgly.com exception, no more', () => {
|
|
57
|
+
const csp = cspForCanvasShell(SHELL);
|
|
58
|
+
expect(csp).toContain("connect-src 'self' https://staticimgly.com");
|
|
59
|
+
const connectSrc = csp.split('; ').find((d) => d.startsWith('connect-src'));
|
|
60
|
+
expect(connectSrc).toBe("connect-src 'self' https://staticimgly.com");
|
|
61
|
+
});
|
|
51
62
|
});
|