@1agh/maude 0.45.1 → 0.46.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 +628 -74
- package/apps/studio/acp/index.ts +172 -23
- package/apps/studio/acp/probe.ts +31 -7
- package/apps/studio/acp/transcript.ts +91 -5
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +344 -14
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_import-asset.mjs +35 -5
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-edit.ts +302 -3
- package/apps/studio/canvas-lib.tsx +203 -11
- package/apps/studio/canvas-shell.tsx +167 -28
- package/apps/studio/client/app.jsx +678 -176
- package/apps/studio/client/panels/CapabilityBar.jsx +101 -0
- package/apps/studio/client/panels/ChatPanel.jsx +846 -133
- package/apps/studio/client/panels/ElicitationPrompt.jsx +429 -0
- package/apps/studio/client/panels/PermissionPrompt.jsx +119 -0
- package/apps/studio/client/panels/ReadinessList.jsx +17 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/ToolGroup.jsx +79 -0
- package/apps/studio/client/panels/acp-capabilities.js +82 -0
- package/apps/studio/client/panels/acp-elicitation.js +194 -0
- package/apps/studio/client/panels/acp-runtime.js +248 -9
- package/apps/studio/client/panels/acp-usage.js +65 -0
- package/apps/studio/client/panels/transcript-view.js +36 -0
- package/apps/studio/client/styles/6-acp-chat.css +689 -11
- package/apps/studio/dist/client.bundle.js +1738 -1736
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/generation/whisper-models.test.ts +28 -1
- package/apps/studio/generation/whisper-models.ts +22 -7
- package/apps/studio/http.ts +246 -9
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-bridge.test.ts +11 -6
- package/apps/studio/test/acp-capabilities.test.ts +123 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +274 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +475 -0
- package/apps/studio/test/acp-elicitation.test.ts +251 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +77 -0
- package/apps/studio/test/acp-permission.test.ts +262 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +91 -0
- package/apps/studio/test/acp-toolgroup.test.ts +76 -0
- package/apps/studio/test/acp-transcript-view.test.ts +71 -0
- package/apps/studio/test/acp-transcript.test.ts +75 -2
- package/apps/studio/test/acp-usage-bridge.test.ts +136 -0
- package/apps/studio/test/acp-usage.test.ts +143 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/canvas-meta-api.test.ts +167 -0
- package/apps/studio/test/element-structural-edit.test.ts +250 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/fixtures/mock-acp-agent-caps.mjs +158 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-flood.mjs +46 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit-url.mjs +42 -0
- package/apps/studio/test/fixtures/mock-acp-agent-elicit.mjs +63 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission-flood.mjs +51 -0
- package/apps/studio/test/fixtures/mock-acp-agent-permission.mjs +50 -0
- package/apps/studio/test/fixtures/mock-acp-agent-usage.mjs +56 -0
- package/apps/studio/test/import-asset.test.ts +31 -3
- package/apps/studio/test/pdf-print-boxes.test.ts +272 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +21 -2
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/whats-new.json +62 -0
- package/cli/commands/design.mjs +116 -3
- package/cli/lib/pkg-root.mjs +42 -10
- package/cli/lib/pkg-root.test.mjs +33 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +35 -0
|
@@ -12,9 +12,13 @@ import {
|
|
|
12
12
|
applyInsertElement,
|
|
13
13
|
applyInsertElementIntoArtboard,
|
|
14
14
|
applyResizeArtboard,
|
|
15
|
+
applySetArtboardGuides,
|
|
15
16
|
applySetArtboardHug,
|
|
17
|
+
applySetArtboardKind,
|
|
18
|
+
applySetArtboardPrint,
|
|
16
19
|
applySetArtboardStyle,
|
|
17
20
|
CanvasEditError,
|
|
21
|
+
readArtboardPrintProp,
|
|
18
22
|
} from '../canvas-edit.ts';
|
|
19
23
|
import { transpileCanvasSource } from '../canvas-pipeline.ts';
|
|
20
24
|
|
|
@@ -337,6 +341,252 @@ describe('canvas-edit / applySetArtboardStyle (background/padding/layout/gap)',
|
|
|
337
341
|
CanvasEditError
|
|
338
342
|
);
|
|
339
343
|
});
|
|
344
|
+
|
|
345
|
+
// Dogfood (artboard panel ↔ shared inspector controls) — padding/gap accept
|
|
346
|
+
// a var(--token) STRING binding alongside raw px numbers.
|
|
347
|
+
test('padding/gap accept a var(--token) string (space-token binding)', () => {
|
|
348
|
+
const out = applySetArtboardStyle(CANVAS, canvas, 'home', {
|
|
349
|
+
padding: 'var(--space-4)',
|
|
350
|
+
gap: 'var(--space-2)',
|
|
351
|
+
});
|
|
352
|
+
expect(out.source).toContain('padding="var(--space-4)"');
|
|
353
|
+
expect(out.source).toContain('gap="var(--space-2)"');
|
|
354
|
+
expect(parses(out.source)).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('a token binding round-trips back to a raw number (detach) and vice versa', () => {
|
|
358
|
+
const bound = applySetArtboardStyle(CANVAS, canvas, 'home', { padding: 'var(--space-4)' });
|
|
359
|
+
const detached = applySetArtboardStyle(CANVAS, bound.source, 'home', { padding: 24 });
|
|
360
|
+
expect(detached.source).toContain('padding={24}');
|
|
361
|
+
expect(detached.source).not.toContain('var(--space-4)');
|
|
362
|
+
const rebound = applySetArtboardStyle(CANVAS, detached.source, 'home', {
|
|
363
|
+
padding: 'var(--space-6)',
|
|
364
|
+
});
|
|
365
|
+
expect(rebound.source).toContain('padding="var(--space-6)"');
|
|
366
|
+
expect(rebound.source).not.toContain('padding={24}');
|
|
367
|
+
expect(parses(rebound.source)).toBe(true);
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// feature-1-artboard-kinds-foundation, T5/T8 — kind-switch write lane.
|
|
372
|
+
describe('canvas-edit / applySetArtboardKind', () => {
|
|
373
|
+
const canvas = [
|
|
374
|
+
'export default function Demo() {',
|
|
375
|
+
' return (',
|
|
376
|
+
' <DesignCanvas>',
|
|
377
|
+
' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
|
|
378
|
+
' <div>content</div>',
|
|
379
|
+
' </DCArtboard>',
|
|
380
|
+
' </DesignCanvas>',
|
|
381
|
+
' );',
|
|
382
|
+
'}',
|
|
383
|
+
].join('\n');
|
|
384
|
+
|
|
385
|
+
test('writes kind as a plain string-literal prop', () => {
|
|
386
|
+
const out = applySetArtboardKind(CANVAS, canvas, 'home', 'print');
|
|
387
|
+
expect(out.source).toContain('kind="print"');
|
|
388
|
+
expect(parses(out.source)).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('switching kind replaces the previous value, no duplicate attr', () => {
|
|
392
|
+
const printed = applySetArtboardKind(CANVAS, canvas, 'home', 'print');
|
|
393
|
+
const out = applySetArtboardKind(CANVAS, printed.source, 'home', 'web');
|
|
394
|
+
expect(out.source).toContain('kind="web"');
|
|
395
|
+
expect(out.source).not.toContain('kind="print"');
|
|
396
|
+
expect(out.source.match(/\bkind=/g)?.length).toBe(1);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('kind=null clears back to the implicit default', () => {
|
|
400
|
+
const printed = applySetArtboardKind(CANVAS, canvas, 'home', 'print');
|
|
401
|
+
const out = applySetArtboardKind(CANVAS, printed.source, 'home', null);
|
|
402
|
+
expect(out.source).not.toContain('kind=');
|
|
403
|
+
expect(parses(out.source)).toBe(true);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('an invalid kind value throws rather than writing garbage', () => {
|
|
407
|
+
expect(() => applySetArtboardKind(CANVAS, canvas, 'home', 'poster')).toThrow(CanvasEditError);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('unknown artboard id throws', () => {
|
|
411
|
+
expect(() => applySetArtboardKind(CANVAS, canvas, 'nope', 'print')).toThrow(CanvasEditError);
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// feature-1-artboard-kinds-foundation, T5 — generic layout guides write lane.
|
|
416
|
+
describe('canvas-edit / applySetArtboardGuides (replace-whole-prop)', () => {
|
|
417
|
+
const canvas = [
|
|
418
|
+
'export default function Demo() {',
|
|
419
|
+
' return (',
|
|
420
|
+
' <DesignCanvas>',
|
|
421
|
+
' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
|
|
422
|
+
' <div>content</div>',
|
|
423
|
+
' </DCArtboard>',
|
|
424
|
+
' </DesignCanvas>',
|
|
425
|
+
' );',
|
|
426
|
+
'}',
|
|
427
|
+
].join('\n');
|
|
428
|
+
|
|
429
|
+
test('inserts guides as a {{...}} object-expression prop', () => {
|
|
430
|
+
const out = applySetArtboardGuides(CANVAS, canvas, 'home', {
|
|
431
|
+
columns: { count: 12, gutter: 24, margin: 80 },
|
|
432
|
+
});
|
|
433
|
+
expect(out.source).toContain('guides={{"columns":{"count":12,"gutter":24,"margin":80}}}');
|
|
434
|
+
expect(parses(out.source)).toBe(true);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('a second write REPLACES the whole prop rather than merging', () => {
|
|
438
|
+
const first = applySetArtboardGuides(CANVAS, canvas, 'home', {
|
|
439
|
+
columns: { count: 12, gutter: 24, margin: 80 },
|
|
440
|
+
});
|
|
441
|
+
const out = applySetArtboardGuides(CANVAS, first.source, 'home', { grid: { size: 8 } });
|
|
442
|
+
expect(out.source).toContain('guides={{"grid":{"size":8}}}');
|
|
443
|
+
expect(out.source).not.toContain('columns');
|
|
444
|
+
expect(out.source.match(/\bguides=/g)?.length).toBe(1);
|
|
445
|
+
expect(parses(out.source)).toBe(true);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test('guides=null removes the prop', () => {
|
|
449
|
+
const written = applySetArtboardGuides(CANVAS, canvas, 'home', { grid: { size: 8 } });
|
|
450
|
+
const out = applySetArtboardGuides(CANVAS, written.source, 'home', null);
|
|
451
|
+
expect(out.source).not.toContain('guides=');
|
|
452
|
+
expect(parses(out.source)).toBe(true);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('unknown artboard id throws', () => {
|
|
456
|
+
expect(() => applySetArtboardGuides(CANVAS, canvas, 'nope', { grid: { size: 8 } })).toThrow(
|
|
457
|
+
CanvasEditError
|
|
458
|
+
);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// feature-2-print-artboards T2 — print prop write lane (same replace-whole-
|
|
463
|
+
// prop shape as applySetArtboardGuides).
|
|
464
|
+
describe('canvas-edit / applySetArtboardPrint (replace-whole-prop)', () => {
|
|
465
|
+
const canvas = [
|
|
466
|
+
'export default function Demo() {',
|
|
467
|
+
' return (',
|
|
468
|
+
' <DesignCanvas>',
|
|
469
|
+
' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
|
|
470
|
+
' <div>content</div>',
|
|
471
|
+
' </DCArtboard>',
|
|
472
|
+
' </DesignCanvas>',
|
|
473
|
+
' );',
|
|
474
|
+
'}',
|
|
475
|
+
].join('\n');
|
|
476
|
+
|
|
477
|
+
test('inserts print as a {{...}} object-expression prop', () => {
|
|
478
|
+
const out = applySetArtboardPrint(CANVAS, canvas, 'home', { paper: 'a4', bleedMm: 3 });
|
|
479
|
+
expect(out.source).toContain('print={{"paper":"a4","bleedMm":3}}');
|
|
480
|
+
expect(parses(out.source)).toBe(true);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test('a second write REPLACES the whole prop rather than merging', () => {
|
|
484
|
+
const first = applySetArtboardPrint(CANVAS, canvas, 'home', { paper: 'a4', bleedMm: 3 });
|
|
485
|
+
const out = applySetArtboardPrint(CANVAS, first.source, 'home', {
|
|
486
|
+
paper: 'letter',
|
|
487
|
+
orientation: 'landscape',
|
|
488
|
+
});
|
|
489
|
+
expect(out.source).toContain('print={{"paper":"letter","orientation":"landscape"}}');
|
|
490
|
+
expect(out.source).not.toContain('a4');
|
|
491
|
+
expect(out.source.match(/\bprint=/g)?.length).toBe(1);
|
|
492
|
+
expect(parses(out.source)).toBe(true);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('print=null removes the prop', () => {
|
|
496
|
+
const written = applySetArtboardPrint(CANVAS, canvas, 'home', { paper: 'a4' });
|
|
497
|
+
const out = applySetArtboardPrint(CANVAS, written.source, 'home', null);
|
|
498
|
+
expect(out.source).not.toContain('print=');
|
|
499
|
+
expect(parses(out.source)).toBe(true);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test('unknown artboard id throws', () => {
|
|
503
|
+
expect(() => applySetArtboardPrint(CANVAS, canvas, 'nope', { paper: 'a4' })).toThrow(
|
|
504
|
+
CanvasEditError
|
|
505
|
+
);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// feature-2-print-artboards T5 — the AST-only (no-eval) reader the PDF
|
|
510
|
+
// exporter uses to resolve an artboard's print geometry.
|
|
511
|
+
describe('canvas-edit / readArtboardPrintProp (no-eval AST read)', () => {
|
|
512
|
+
test('reads a JSON-shaped print prop on a kind="print" artboard (the picker\'s own write shape)', () => {
|
|
513
|
+
const canvas = [
|
|
514
|
+
'export default function Demo() {',
|
|
515
|
+
' return (',
|
|
516
|
+
' <DesignCanvas>',
|
|
517
|
+
' <DCArtboard id="home" label="Home" kind="print" width={816} height={1146} print={{"paper":"a4","bleedMm":3}}>',
|
|
518
|
+
' <div>content</div>',
|
|
519
|
+
' </DCArtboard>',
|
|
520
|
+
' </DesignCanvas>',
|
|
521
|
+
' );',
|
|
522
|
+
'}',
|
|
523
|
+
].join('\n');
|
|
524
|
+
expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toEqual({ paper: 'a4', bleedMm: 3 });
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test('reads a hand-authored JS-literal print prop (unquoted keys, nested object)', () => {
|
|
528
|
+
const canvas = [
|
|
529
|
+
'export default function Demo() {',
|
|
530
|
+
' return (',
|
|
531
|
+
' <DesignCanvas>',
|
|
532
|
+
' <DCArtboard id="home" label="Home" kind="print" width={816} height={1146} print={{ paper: \'a4\', orientation: \'landscape\', marginsMm: { top: 10, left: 5 } }}>',
|
|
533
|
+
' <div>content</div>',
|
|
534
|
+
' </DCArtboard>',
|
|
535
|
+
' </DesignCanvas>',
|
|
536
|
+
' );',
|
|
537
|
+
'}',
|
|
538
|
+
].join('\n');
|
|
539
|
+
expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toEqual({
|
|
540
|
+
paper: 'a4',
|
|
541
|
+
orientation: 'landscape',
|
|
542
|
+
marginsMm: { top: 10, left: 5 },
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('no print prop → null', () => {
|
|
547
|
+
const canvas = [
|
|
548
|
+
'export default function Demo() {',
|
|
549
|
+
' return (',
|
|
550
|
+
' <DesignCanvas>',
|
|
551
|
+
' <DCArtboard id="home" label="Home" kind="print" width={1440} height={1024}>',
|
|
552
|
+
' <div>content</div>',
|
|
553
|
+
' </DCArtboard>',
|
|
554
|
+
' </DesignCanvas>',
|
|
555
|
+
' );',
|
|
556
|
+
'}',
|
|
557
|
+
].join('\n');
|
|
558
|
+
expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toBeNull();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('unknown artboard id → null (not a throw — read-only, best-effort)', () => {
|
|
562
|
+
const canvas = [
|
|
563
|
+
'export default function Demo() {',
|
|
564
|
+
' return (',
|
|
565
|
+
' <DesignCanvas>',
|
|
566
|
+
' <DCArtboard id="home" label="Home" kind="print" width={1440} height={1024}>',
|
|
567
|
+
' <div>content</div>',
|
|
568
|
+
' </DCArtboard>',
|
|
569
|
+
' </DesignCanvas>',
|
|
570
|
+
' );',
|
|
571
|
+
'}',
|
|
572
|
+
].join('\n');
|
|
573
|
+
expect(readArtboardPrintProp(CANVAS, canvas, 'nope')).toBeNull();
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test('dogfood regression — a print prop present but kind NOT "print" → null (a stale prop from a prior kind switch must never leak into export)', () => {
|
|
577
|
+
const canvas = [
|
|
578
|
+
'export default function Demo() {',
|
|
579
|
+
' return (',
|
|
580
|
+
' <DesignCanvas>',
|
|
581
|
+
' <DCArtboard id="home" label="Home" width={1440} height={1024} print={{"paper":"a4","bleedMm":3}}>',
|
|
582
|
+
' <div>content</div>',
|
|
583
|
+
' </DCArtboard>',
|
|
584
|
+
' </DesignCanvas>',
|
|
585
|
+
' );',
|
|
586
|
+
'}',
|
|
587
|
+
].join('\n');
|
|
588
|
+
expect(readArtboardPrintProp(CANVAS, canvas, 'home')).toBeNull();
|
|
589
|
+
});
|
|
340
590
|
});
|
|
341
591
|
|
|
342
592
|
describe('canvas-edit / applyInsertArtboard (I4)', () => {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from 'bun:test';
|
|
10
10
|
|
|
11
|
-
import { clampScale, run } from '../../exporters/png.ts';
|
|
11
|
+
import { clampDpi, clampScale, resolveDeviceScale, run } from '../../exporters/png.ts';
|
|
12
12
|
|
|
13
13
|
const CTX = {
|
|
14
14
|
designRoot: '/tmp/.design',
|
|
@@ -53,3 +53,51 @@ describe('png clampScale — size presets (item 1)', () => {
|
|
|
53
53
|
expect(clampScale(1.2)).toBe(1);
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
describe('png clampDpi — feature-2-print-artboards T4', () => {
|
|
58
|
+
test('undefined/null → undefined (falls back to scale)', () => {
|
|
59
|
+
expect(clampDpi(undefined)).toBeUndefined();
|
|
60
|
+
expect(clampDpi(null)).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('non-numeric → undefined', () => {
|
|
64
|
+
expect(clampDpi('nonsense')).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('exact presets pass through', () => {
|
|
68
|
+
expect(clampDpi(96)).toBe(96);
|
|
69
|
+
expect(clampDpi(150)).toBe(150);
|
|
70
|
+
expect(clampDpi(300)).toBe(300);
|
|
71
|
+
expect(clampDpi(600)).toBe(600);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('snaps to the nearest preset', () => {
|
|
75
|
+
expect(clampDpi(200)).toBe(150);
|
|
76
|
+
expect(clampDpi(250)).toBe(300);
|
|
77
|
+
expect(clampDpi(1000)).toBe(600);
|
|
78
|
+
expect(clampDpi(0)).toBe(96);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('png resolveDeviceScale — dpi wins over scale', () => {
|
|
83
|
+
test('dpi=300 → 3.125× (300/96)', () => {
|
|
84
|
+
expect(resolveDeviceScale({ dpi: 300 })).toBeCloseTo(300 / 96, 10);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('dpi=600 → 6.25× (300dpi ceiling raise, item T4)', () => {
|
|
88
|
+
expect(resolveDeviceScale({ dpi: 600 })).toBeCloseTo(600 / 96, 10);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('dpi=96 → 1× (no-op)', () => {
|
|
92
|
+
expect(resolveDeviceScale({ dpi: 96 })).toBeCloseTo(1, 10);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('no dpi → falls back to the legacy scale preset', () => {
|
|
96
|
+
expect(resolveDeviceScale({ scale: 3 })).toBe(3);
|
|
97
|
+
expect(resolveDeviceScale({})).toBe(2); // clampScale default
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('dpi present but not a number → falls back to scale', () => {
|
|
101
|
+
expect(resolveDeviceScale({ dpi: 'nope', scale: 1 })).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Mock ACP agent exercising the session-capabilities channel (feature-acp-
|
|
3
|
+
// panel-dynamic-claude-code-capabilities) — modes + configOptions returned
|
|
4
|
+
// from session/new, live session/set_mode + session/set_config_option, and
|
|
5
|
+
// the config_option_update / session_info_update notifications a real
|
|
6
|
+
// claude-agent-acp session emits. No real `claude` needed.
|
|
7
|
+
//
|
|
8
|
+
// Mirrors the REAL adapter's observed behavior (read from the installed
|
|
9
|
+
// @agentclientprotocol/claude-agent-acp source, not guessed):
|
|
10
|
+
// - session/set_mode updates the mode AND mirrors it into configOptions'
|
|
11
|
+
// "mode" entry, emitting ONLY a config_option_update notification (no
|
|
12
|
+
// current_mode_update) — bridge.ts cross-derives lastModes from that.
|
|
13
|
+
// - session/set_config_option('model', …) can change which OTHER options
|
|
14
|
+
// are offered (here: picking "opus" adds a "fast" option) and returns
|
|
15
|
+
// the FULL refreshed configOptions in its RPC response (no notification).
|
|
16
|
+
|
|
17
|
+
import { Readable, Writable } from 'node:stream';
|
|
18
|
+
|
|
19
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
20
|
+
|
|
21
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
22
|
+
|
|
23
|
+
const MODEL_OPTIONS = [
|
|
24
|
+
{ value: 'sonnet', name: 'Sonnet' },
|
|
25
|
+
{ value: 'opus', name: 'Opus' },
|
|
26
|
+
];
|
|
27
|
+
const EFFORT_OPTIONS = [
|
|
28
|
+
{ value: 'default', name: 'Default' },
|
|
29
|
+
{ value: 'high', name: 'High' },
|
|
30
|
+
];
|
|
31
|
+
const MODE_OPTIONS = [
|
|
32
|
+
{ value: 'default', name: 'Manual' },
|
|
33
|
+
{ value: 'plan', name: 'Plan Mode' },
|
|
34
|
+
];
|
|
35
|
+
const AVAILABLE_MODES = [
|
|
36
|
+
{
|
|
37
|
+
id: 'default',
|
|
38
|
+
name: 'Manual',
|
|
39
|
+
description: 'Standard behavior, prompts for dangerous operations',
|
|
40
|
+
},
|
|
41
|
+
{ id: 'plan', name: 'Plan Mode', description: 'Planning mode, no actual tool execution' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// `session` carries every tracked field (model/effort/mode/fast) so a call
|
|
45
|
+
// that rebuilds the FULL option set (e.g. session/set_mode, which is scoped
|
|
46
|
+
// to just the mode) never regresses a field a PRIOR call already changed —
|
|
47
|
+
// mirrors the real adapter's `session.configOptions` being one persistent
|
|
48
|
+
// object, not independently recomputed per field.
|
|
49
|
+
function baseConfigOptions(session) {
|
|
50
|
+
const opts = [
|
|
51
|
+
{
|
|
52
|
+
id: 'model',
|
|
53
|
+
name: 'Model',
|
|
54
|
+
category: 'model',
|
|
55
|
+
type: 'select',
|
|
56
|
+
currentValue: session.model,
|
|
57
|
+
options: MODEL_OPTIONS,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'effort',
|
|
61
|
+
name: 'Effort',
|
|
62
|
+
category: 'thought_level',
|
|
63
|
+
type: 'select',
|
|
64
|
+
currentValue: session.effort,
|
|
65
|
+
options: EFFORT_OPTIONS,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'mode',
|
|
69
|
+
name: 'Mode',
|
|
70
|
+
category: 'mode',
|
|
71
|
+
type: 'select',
|
|
72
|
+
currentValue: session.mode,
|
|
73
|
+
options: MODE_OPTIONS,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
if (session.model === 'opus') {
|
|
77
|
+
opts.push({
|
|
78
|
+
id: 'fast',
|
|
79
|
+
name: 'Fast mode',
|
|
80
|
+
category: 'model_config',
|
|
81
|
+
type: 'select',
|
|
82
|
+
currentValue: session.fast,
|
|
83
|
+
options: [
|
|
84
|
+
{ value: 'on', name: 'On' },
|
|
85
|
+
{ value: 'off', name: 'Off' },
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return opts;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let n = 0;
|
|
93
|
+
const sessions = new Map(); // sessionId -> { model, effort, mode, fast }
|
|
94
|
+
|
|
95
|
+
acp
|
|
96
|
+
.agent({ name: 'mock-acp-agent-caps' })
|
|
97
|
+
.onRequest('initialize', () => ({
|
|
98
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
99
|
+
agentCapabilities: { loadSession: false },
|
|
100
|
+
}))
|
|
101
|
+
.onRequest('session/new', async (ctx) => {
|
|
102
|
+
const sessionId = `mock-caps-session-${++n}`;
|
|
103
|
+
const session = { model: 'sonnet', effort: 'default', mode: 'default', fast: 'off' };
|
|
104
|
+
sessions.set(sessionId, session);
|
|
105
|
+
await ctx.client.notify('session/update', {
|
|
106
|
+
sessionId,
|
|
107
|
+
update: { sessionUpdate: 'session_info_update', title: 'New chat' },
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
sessionId,
|
|
111
|
+
modes: { currentModeId: 'default', availableModes: AVAILABLE_MODES },
|
|
112
|
+
configOptions: baseConfigOptions(session),
|
|
113
|
+
};
|
|
114
|
+
})
|
|
115
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
116
|
+
await ctx.client.notify('session/update', {
|
|
117
|
+
sessionId: ctx.params.sessionId,
|
|
118
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ok' } },
|
|
119
|
+
});
|
|
120
|
+
return { stopReason: 'end_turn' };
|
|
121
|
+
})
|
|
122
|
+
.onRequest('session/set_mode', async (ctx) => {
|
|
123
|
+
const session = sessions.get(ctx.params.sessionId);
|
|
124
|
+
if (!session) throw new Error('session not found');
|
|
125
|
+
if (!AVAILABLE_MODES.some((m) => m.id === ctx.params.modeId)) {
|
|
126
|
+
throw new Error(`unknown mode: ${ctx.params.modeId}`);
|
|
127
|
+
}
|
|
128
|
+
session.mode = ctx.params.modeId;
|
|
129
|
+
// Real adapter: setSessionMode emits ONLY config_option_update, never
|
|
130
|
+
// current_mode_update — bridge.ts must cross-derive lastModes from it.
|
|
131
|
+
await ctx.client.notify('session/update', {
|
|
132
|
+
sessionId: ctx.params.sessionId,
|
|
133
|
+
update: { sessionUpdate: 'config_option_update', configOptions: baseConfigOptions(session) },
|
|
134
|
+
});
|
|
135
|
+
return {};
|
|
136
|
+
})
|
|
137
|
+
.onRequest('session/set_config_option', async (ctx) => {
|
|
138
|
+
const session = sessions.get(ctx.params.sessionId);
|
|
139
|
+
if (!session) throw new Error('session not found');
|
|
140
|
+
const { configId, value } = ctx.params;
|
|
141
|
+
if (configId === 'model') {
|
|
142
|
+
if (!MODEL_OPTIONS.some((o) => o.value === value)) throw new Error(`unknown model: ${value}`);
|
|
143
|
+
session.model = value;
|
|
144
|
+
} else if (configId === 'effort') {
|
|
145
|
+
if (!EFFORT_OPTIONS.some((o) => o.value === value))
|
|
146
|
+
throw new Error(`unknown effort: ${value}`);
|
|
147
|
+
session.effort = value;
|
|
148
|
+
} else if (configId === 'mode') {
|
|
149
|
+
if (!AVAILABLE_MODES.some((m) => m.id === value)) throw new Error(`unknown mode: ${value}`);
|
|
150
|
+
session.mode = value;
|
|
151
|
+
} else if (configId === 'fast') {
|
|
152
|
+
session.fast = value;
|
|
153
|
+
} else {
|
|
154
|
+
throw new Error(`unknown config option: ${configId}`);
|
|
155
|
+
}
|
|
156
|
+
return { configOptions: baseConfigOptions(session) };
|
|
157
|
+
})
|
|
158
|
+
.connect(stream);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Mock ACP agent proving the MAX_PENDING_ELICITATIONS cap (feature-acp-ask-
|
|
3
|
+
// user-question, SECURITY / ethical-hacker finding — an unbounded queue lets
|
|
4
|
+
// any connected MCP server flood the client with elicitation requests). Fires
|
|
5
|
+
// MORE than the cap concurrently, without waiting for each one — mirroring a
|
|
6
|
+
// hostile/compromised MCP server that never waits for an answer before
|
|
7
|
+
// issuing the next request — and reports which ones actually round-tripped to
|
|
8
|
+
// the client vs. were declined immediately by the bridge's own cap.
|
|
9
|
+
|
|
10
|
+
import { Readable, Writable } from 'node:stream';
|
|
11
|
+
|
|
12
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
13
|
+
|
|
14
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
15
|
+
|
|
16
|
+
const FLOOD_COUNT = 8; // > MAX_PENDING_ELICITATIONS (5) in acp/bridge.ts
|
|
17
|
+
|
|
18
|
+
let n = 0;
|
|
19
|
+
|
|
20
|
+
acp
|
|
21
|
+
.agent({ name: 'mock-acp-agent-elicit-flood' })
|
|
22
|
+
.onRequest('initialize', () => ({
|
|
23
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
24
|
+
agentCapabilities: { loadSession: false },
|
|
25
|
+
}))
|
|
26
|
+
.onRequest('session/new', () => ({ sessionId: `mock-elicit-flood-session-${++n}` }))
|
|
27
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
28
|
+
const requests = Array.from({ length: FLOOD_COUNT }, (_, i) =>
|
|
29
|
+
ctx.client.request(acp.methods.client.elicitation.create, {
|
|
30
|
+
mode: 'form',
|
|
31
|
+
sessionId: ctx.params.sessionId,
|
|
32
|
+
message: `Flood question ${i}`,
|
|
33
|
+
requestedSchema: { type: 'object', properties: { [`q${i}`]: { type: 'string' } } },
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
const responses = await Promise.all(requests);
|
|
37
|
+
await ctx.client.notify('session/update', {
|
|
38
|
+
sessionId: ctx.params.sessionId,
|
|
39
|
+
update: {
|
|
40
|
+
sessionUpdate: 'agent_message_chunk',
|
|
41
|
+
content: { type: 'text', text: `responses=${JSON.stringify(responses)}` },
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
return { stopReason: 'end_turn' };
|
|
45
|
+
})
|
|
46
|
+
.connect(stream);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Mock ACP agent that sends a `mode:'url'` elicitation request — proves
|
|
3
|
+
// AcpBridge.unstable_createElicitation rejects it structurally (declines
|
|
4
|
+
// without ever calling onElicitationRequest) even though the adapter/agent
|
|
5
|
+
// side ignored the client's declared `elicitation:{form:{}}`-only capability
|
|
6
|
+
// (ethical-hacker finding — capability negotiation is advisory, not
|
|
7
|
+
// enforced; the client must reject what it never advertised, not just hope
|
|
8
|
+
// the other side honors it).
|
|
9
|
+
|
|
10
|
+
import { Readable, Writable } from 'node:stream';
|
|
11
|
+
|
|
12
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
13
|
+
|
|
14
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
15
|
+
|
|
16
|
+
let n = 0;
|
|
17
|
+
|
|
18
|
+
acp
|
|
19
|
+
.agent({ name: 'mock-acp-agent-elicit-url' })
|
|
20
|
+
.onRequest('initialize', () => ({
|
|
21
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
22
|
+
agentCapabilities: { loadSession: false },
|
|
23
|
+
}))
|
|
24
|
+
.onRequest('session/new', () => ({ sessionId: `mock-elicit-url-session-${++n}` }))
|
|
25
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
26
|
+
const response = await ctx.client.request(acp.methods.client.elicitation.create, {
|
|
27
|
+
mode: 'url',
|
|
28
|
+
sessionId: ctx.params.sessionId,
|
|
29
|
+
message: 'Confirm you have completed verification',
|
|
30
|
+
url: 'https://example.com/verify',
|
|
31
|
+
elicitationId: 'elicit-url-1',
|
|
32
|
+
});
|
|
33
|
+
await ctx.client.notify('session/update', {
|
|
34
|
+
sessionId: ctx.params.sessionId,
|
|
35
|
+
update: {
|
|
36
|
+
sessionUpdate: 'agent_message_chunk',
|
|
37
|
+
content: { type: 'text', text: `response=${JSON.stringify(response)}` },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
return { stopReason: 'end_turn' };
|
|
41
|
+
})
|
|
42
|
+
.connect(stream);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Mock ACP agent exercising the elicitation-form gate (feature-acp-ask-user-
|
|
3
|
+
// question) — session/prompt calls back INTO the client via a REAL
|
|
4
|
+
// `elicitation/create` request, shaped exactly like
|
|
5
|
+
// `askUserQuestionsToCreateRequest`'s single-question output (confirmed on
|
|
6
|
+
// disk against the installed `@agentclientprotocol/claude-agent-acp` — a
|
|
7
|
+
// titled `oneOf` enum plus its sibling `_custom` free-text field), before
|
|
8
|
+
// streaming its final reply. Mirrors mock-acp-agent-permission.mjs's shape.
|
|
9
|
+
// No real `claude` needed.
|
|
10
|
+
|
|
11
|
+
import { Readable, Writable } from 'node:stream';
|
|
12
|
+
|
|
13
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
14
|
+
|
|
15
|
+
const stream = acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
|
16
|
+
|
|
17
|
+
let n = 0;
|
|
18
|
+
|
|
19
|
+
acp
|
|
20
|
+
.agent({ name: 'mock-acp-agent-elicit' })
|
|
21
|
+
.onRequest('initialize', () => ({
|
|
22
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
23
|
+
agentCapabilities: { loadSession: false },
|
|
24
|
+
}))
|
|
25
|
+
.onRequest('session/new', () => ({ sessionId: `mock-elicit-session-${++n}` }))
|
|
26
|
+
.onRequest('session/prompt', async (ctx) => {
|
|
27
|
+
// `ctx.client` (AgentContext) only exposes the generic request()/notify() —
|
|
28
|
+
// no typed convenience method — so call the raw ACP method via
|
|
29
|
+
// `acp.methods.client.elicitation.create` (confirmed on disk: acp.js's
|
|
30
|
+
// `methods.client.elicitation.create === schema.CLIENT_METHODS.elicitation_create`).
|
|
31
|
+
const response = await ctx.client.request(acp.methods.client.elicitation.create, {
|
|
32
|
+
mode: 'form',
|
|
33
|
+
sessionId: ctx.params.sessionId,
|
|
34
|
+
toolCallId: 'tc-elicit-1',
|
|
35
|
+
message: 'Which color do you like?',
|
|
36
|
+
requestedSchema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
question_0: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
oneOf: [
|
|
42
|
+
{ const: 'Red', title: 'Red' },
|
|
43
|
+
{ const: 'Blue', title: 'Blue' },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
question_0_custom: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
title: 'Other',
|
|
49
|
+
description: 'Type your own answer instead of choosing an option above (optional).',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
await ctx.client.notify('session/update', {
|
|
55
|
+
sessionId: ctx.params.sessionId,
|
|
56
|
+
update: {
|
|
57
|
+
sessionUpdate: 'agent_message_chunk',
|
|
58
|
+
content: { type: 'text', text: `response=${JSON.stringify(response)}` },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return { stopReason: 'end_turn' };
|
|
62
|
+
})
|
|
63
|
+
.connect(stream);
|