@kumwe/studio-testkit 0.1.0-alpha.7 → 0.1.0-rc.1

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.
Files changed (159) hide show
  1. package/README.md +91 -18
  2. package/THIRD_PARTY_NOTICES.md +9 -0
  3. package/conformance/authoring-web/non-drag-move-equivalence.json +199 -0
  4. package/conformance/renderer-web/blob-default-deny.json +47 -0
  5. package/conformance/renderer-web/chart-fallback.json +39 -0
  6. package/conformance/renderer-web/complete-catalog.json +634 -0
  7. package/conformance/renderer-web/escaped-heading.json +28 -0
  8. package/conformance/renderer-web/interactive-behaviors.json +226 -0
  9. package/conformance/renderer-web/presentation-capabilities.json +76 -0
  10. package/conformance/renderer-web/responsive-grid.json +33 -0
  11. package/conformance/renderer-web/security-fallbacks.json +169 -0
  12. package/conformance/rich-text/advanced.first-party.json +67 -0
  13. package/corpus-manifest.json +487 -35
  14. package/dist/host-testbed.d.ts +24 -5
  15. package/dist/host-testbed.d.ts.map +1 -1
  16. package/dist/host-testbed.js +276 -41
  17. package/dist/host-testbed.js.map +1 -1
  18. package/dist/http-host-adapter.d.ts.map +1 -1
  19. package/dist/http-host-adapter.js +8 -0
  20. package/dist/http-host-adapter.js.map +1 -1
  21. package/dist/index.d.ts +84 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +73 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/web-conformance.d.ts +105 -0
  26. package/dist/web-conformance.d.ts.map +1 -0
  27. package/dist/web-conformance.js +72 -0
  28. package/dist/web-conformance.js.map +1 -0
  29. package/fixtures/authoring-message-catalog.en.json +649 -0
  30. package/fixtures/host-capabilities.example.json +2 -2
  31. package/fixtures/preview.render.example.json +1 -0
  32. package/fixtures/studio-config.example.json +3 -3
  33. package/invalid/binding-projection-vector.unknown-status.json +85 -0
  34. package/invalid/host-capabilities.invalid-protocol-version.json +1 -1
  35. package/invalid/host-sequence.pending-expectation.json +52 -0
  36. package/invalid/host-sequence.preview-render-missing-request-id.json +50 -0
  37. package/invalid/preview-message.activated-unknown-interaction.json +1 -1
  38. package/invalid/preview-message.duplicate-marker.json +24 -0
  39. package/invalid/preview-message.invalid-digest.json +1 -0
  40. package/invalid/preview-message.invalid-marker-grammar.json +19 -0
  41. package/invalid/preview-message.missing-marker-map.json +20 -0
  42. package/invalid/preview-message.missing-render-request-id.json +18 -0
  43. package/invalid/preview-message.unknown-type.json +13 -0
  44. package/invalid/preview-message.viewport-height-too-small.json +13 -0
  45. package/invalid/preview-message.viewport-unsafe-role.json +13 -0
  46. package/invalid/studio-chart.script-config.json +10 -0
  47. package/invalid/studio-drawing.svg-payload.json +10 -0
  48. package/invalid/studio-money.float.json +8 -0
  49. package/invalid/studio-presentation.css-injection.json +8 -0
  50. package/invalid/studio-table.html-cell.json +8 -0
  51. package/package.json +7 -3
  52. package/studio-release.json +28 -0
  53. package/vectors/binding-projection/app-content-fields.resolved.json +286 -0
  54. package/vectors/binding-projection/field-removed.diagnostic.json +135 -0
  55. package/vectors/binding-projection/model-revision-mismatch.diagnostic.json +124 -0
  56. package/vectors/host/artifact.dependencies.result.json +2 -2
  57. package/vectors/host/artifact.load.stored.json +2 -2
  58. package/vectors/host/artifact.load.unknown.error.json +2 -2
  59. package/vectors/host/artifact.publish.accepted.json +2 -2
  60. package/vectors/host/artifact.publish.forbidden.error.json +1 -1
  61. package/vectors/host/artifact.publish.stale.error.json +2 -2
  62. package/vectors/host/artifact.save.accepted.json +2 -2
  63. package/vectors/host/artifact.save.forbidden.error.json +1 -1
  64. package/vectors/host/artifact.save.stale.error.json +2 -2
  65. package/vectors/host/artifact.unpublish.stale.error.json +2 -2
  66. package/vectors/host/envelope.malformed-context.error.json +2 -2
  67. package/vectors/host/envelope.protocol-version.error.json +1 -1
  68. package/vectors/host/envelope.stale-generation.error.json +2 -2
  69. package/vectors/host/localization.messages.unknown-locale.error.json +2 -2
  70. package/vectors/host/media.abort-upload.unknown.error.json +1 -1
  71. package/vectors/host/media.authorize-upload.granted.json +1 -1
  72. package/vectors/host/media.authorize-upload.path-filename.error.json +1 -1
  73. package/vectors/host/media.authorize-upload.too-large.error.json +1 -1
  74. package/vectors/host/media.complete-upload.unknown.error.json +1 -1
  75. package/vectors/host/media.get.unknown.json +2 -2
  76. package/vectors/host/media.import-external.private-host.error.json +1 -1
  77. package/vectors/host/media.list.cursor.error.json +2 -2
  78. package/vectors/host/media.list.limit.error.json +2 -2
  79. package/vectors/host/media.upload-status.unknown.error.json +1 -1
  80. package/vectors/host/model.get.stored.json +37 -0
  81. package/vectors/host/model.list.authorized.json +31 -0
  82. package/vectors/host/permission.explain.withheld.json +2 -2
  83. package/vectors/host/permission.refresh.snapshot.json +2 -2
  84. package/vectors/host/recovery.load.absent.json +2 -2
  85. package/vectors/host/telemetry.emit.accepted.json +2 -2
  86. package/vectors/host/telemetry.emit.non-primitive.error.json +2 -2
  87. package/vectors/host-sequence/artifact.publish.changed-intent.sequence.json +75 -0
  88. package/vectors/host-sequence/artifact.publish.idempotent-replay.sequence.json +111 -0
  89. package/vectors/host-sequence/preview.cancel.cross-context.sequence.json +78 -0
  90. package/vectors/host-sequence/preview.render.cancelled.sequence.json +75 -0
  91. package/vectors/host-sequence/recovery.store.canonical-number.sequence.json +72 -0
  92. package/vectors/host-sequence/recovery.store.changed-context.sequence.json +61 -0
  93. package/vectors/host-sequence/recovery.store.rate-limited.sequence.json +93 -0
  94. package/vectors/host-sequence/recovery.store.resource-scope.sequence.json +61 -0
  95. package/vectors/host-sequence/recovery.store.wrong-operation-id.sequence.json +56 -0
  96. package/vectors/preview/canonical-preorder.json +102 -0
  97. package/vectors/preview/empty-draft.json +36 -0
  98. package/vectors/schema-profile/alternative-limit.accepted.json +95 -0
  99. package/vectors/schema-profile/alternative-limit.rejected.json +92 -0
  100. package/vectors/schema-profile/array-composition.accepted.json +35 -0
  101. package/vectors/schema-profile/byte-limit.accepted.json +30 -0
  102. package/vectors/schema-profile/byte-limit.rejected.json +26 -0
  103. package/vectors/schema-profile/closed-object.accepted.json +38 -0
  104. package/vectors/schema-profile/decimal-multiple.accepted.json +54 -0
  105. package/vectors/schema-profile/depth-limit.accepted.json +90 -0
  106. package/vectors/schema-profile/depth-limit.rejected.json +88 -0
  107. package/vectors/schema-profile/description-limit.accepted.json +29 -0
  108. package/vectors/schema-profile/description-limit.rejected.json +25 -0
  109. package/vectors/schema-profile/diagnostic-precedence.rejected.json +26 -0
  110. package/vectors/schema-profile/duplicate-dependent-required.rejected.json +21 -0
  111. package/vectors/schema-profile/duplicate-required.rejected.json +18 -0
  112. package/vectors/schema-profile/empty-combinator.rejected.json +17 -0
  113. package/vectors/schema-profile/empty-enum.rejected.json +17 -0
  114. package/vectors/schema-profile/enum-limit.accepted.json +89 -0
  115. package/vectors/schema-profile/enum-limit.rejected.json +84 -0
  116. package/vectors/schema-profile/examples-limit.accepted.json +35 -0
  117. package/vectors/schema-profile/examples-limit.rejected.json +31 -0
  118. package/vectors/schema-profile/format-keyword.rejected.json +17 -0
  119. package/vectors/schema-profile/forward-reference-path-precedence.rejected.json +20 -0
  120. package/vectors/schema-profile/invalid-pointer.rejected.json +18 -0
  121. package/vectors/schema-profile/json-depth-limit.accepted.json +278 -0
  122. package/vectors/schema-profile/json-depth-limit.rejected.json +276 -0
  123. package/vectors/schema-profile/json-item-limit.accepted.json +686 -0
  124. package/vectors/schema-profile/json-item-limit.rejected.json +682 -0
  125. package/vectors/schema-profile/json-property-limit.accepted.json +1031 -0
  126. package/vectors/schema-profile/json-property-limit.rejected.json +1028 -0
  127. package/vectors/schema-profile/local-reference.accepted.json +28 -0
  128. package/vectors/schema-profile/memoized-diagnostic.accepted.json +31 -0
  129. package/vectors/schema-profile/node-limit.accepted.json +1097 -0
  130. package/vectors/schema-profile/node-limit.rejected.json +1094 -0
  131. package/vectors/schema-profile/non-object-root.rejected.json +9 -0
  132. package/vectors/schema-profile/non-schema-reference.rejected.json +21 -0
  133. package/vectors/schema-profile/object-key-limit.accepted.json +32 -0
  134. package/vectors/schema-profile/object-key-limit.rejected.json +28 -0
  135. package/vectors/schema-profile/open-root.rejected.json +13 -0
  136. package/vectors/schema-profile/percent-encoded-pointer.rejected.json +26 -0
  137. package/vectors/schema-profile/property-name-limit.accepted.json +543 -0
  138. package/vectors/schema-profile/property-name-limit.rejected.json +536 -0
  139. package/vectors/schema-profile/recursive-diagnostic-precedence.rejected.json +30 -0
  140. package/vectors/schema-profile/recursive-reference.rejected.json +24 -0
  141. package/vectors/schema-profile/reference-fanout.accepted.json +662 -0
  142. package/vectors/schema-profile/reference-length-limit.accepted.json +45 -0
  143. package/vectors/schema-profile/reference-length-limit.rejected.json +39 -0
  144. package/vectors/schema-profile/reference-limit.accepted.json +416 -0
  145. package/vectors/schema-profile/reference-limit.rejected.json +415 -0
  146. package/vectors/schema-profile/reference-vs-keyword-precedence.rejected.json +22 -0
  147. package/vectors/schema-profile/reserved-pointer.rejected.json +26 -0
  148. package/vectors/schema-profile/root-diagnostic-precedence.rejected.json +16 -0
  149. package/vectors/schema-profile/root-vs-reference-precedence.rejected.json +19 -0
  150. package/vectors/schema-profile/schema-map-limit.accepted.json +539 -0
  151. package/vectors/schema-profile/schema-map-limit.rejected.json +536 -0
  152. package/vectors/schema-profile/semantic-recursion-precedence.rejected.json +21 -0
  153. package/vectors/schema-profile/semantic-reference-precedence.rejected.json +21 -0
  154. package/vectors/schema-profile/title-limit.accepted.json +29 -0
  155. package/vectors/schema-profile/title-limit.rejected.json +25 -0
  156. package/vectors/schema-profile/unresolved-reference.rejected.json +17 -0
  157. package/vectors/schema-profile/unsafe-dependent-required.rejected.json +24 -0
  158. package/vectors/schema-profile/unsafe-member.rejected.json +17 -0
  159. package/vectors/schema-profile/unsafe-required.rejected.json +17 -0
@@ -1,14 +1,31 @@
1
- import { type HostAdapter, type HostErrorCategory, type HostPortError, type HostPortResult, type HostRequestContext, type MediaAsset, type MediaUploadAcceptedAsset, type PreviewRenderPayload, type PreviewRenderedPayload, type QualifiedName, type ResourceSearchHit, type Revision, type StableId, type StudioArtifact, type StudioWireProtocolVersion, type TelemetryEvent } from '@kumwe/studio-protocol';
2
- export type TestbedPortName = 'artifact' | 'localization' | 'media' | 'permission' | 'preview' | 'recovery' | 'resource' | 'telemetry';
1
+ import { HostPortFailure, type HostAdapter, type HostErrorCategory, type HostPortError, type HostPortResult, type HostRequestContext, type JsonObject, type MediaAsset, type MediaUploadAcceptedAsset, type PreviewRenderPayload, type PreviewRenderedPayload, type QualifiedName, type ResourceSearchHit, type Revision, type StableId, type StudioArtifact, type StudioWireProtocolVersion, type TelemetryEvent } from '@kumwe/studio-protocol';
2
+ export type TestbedPortName = 'artifact' | 'localization' | 'media' | 'model' | 'permission' | 'preview' | 'recovery' | 'resource' | 'telemetry';
3
3
  export interface TestbedHostOptions {
4
+ /**
5
+ * Permits the fixture-only `studio.test/operation` wildcard used by broad
6
+ * unit tests. Defaults to false and MUST remain false for conformance replay.
7
+ */
8
+ allowTestOperationId?: boolean;
4
9
  documents?: StudioArtifact[];
10
+ initialClockMilliseconds?: number;
5
11
  mediaAssets?: MediaAsset[];
6
12
  messages?: Record<string, Record<QualifiedName, string>>;
7
13
  permissions?: QualifiedName[];
8
- render?: (payload: PreviewRenderPayload) => PreviewRenderedPayload;
14
+ rateLimits?: TestbedRateLimitPolicy[];
15
+ render?: (payload: PreviewRenderPayload) => PreviewRenderedPayload | Promise<PreviewRenderedPayload>;
9
16
  resources?: ResourceSearchHit[];
17
+ /** Exact live generation to seed before the first portable request. */
18
+ sessionGeneration?: Revision;
19
+ }
20
+ /** A deterministic fixed-window policy used by portable sequence replays. */
21
+ export interface TestbedRateLimitPolicy {
22
+ maximumRequests: number;
23
+ operationId: QualifiedName;
24
+ windowMilliseconds: number;
10
25
  }
11
26
  export interface TestbedControls {
27
+ advanceClock(milliseconds: number): void;
28
+ artifactStatus(id: StableId): StudioArtifact['status'] | undefined;
12
29
  disconnect(): void;
13
30
  failNext(portName: TestbedPortName, operation: string, category: HostErrorCategory): void;
14
31
  /**
@@ -24,7 +41,10 @@ export interface TestbedControls {
24
41
  */
25
42
  importExternalMedia(candidate: string, context: HostRequestContext): Promise<HostPortResult<MediaUploadAcceptedAsset>>;
26
43
  reconnect(): void;
44
+ recoveryEnvelope(resourceContextKey: StableId): JsonObject | undefined;
27
45
  revisionOf(id: StableId): Revision | undefined;
46
+ readonly pendingPreviewRenders: number;
47
+ readonly previewDeliveries: readonly string[];
28
48
  readonly sessionGeneration: Revision;
29
49
  setPermissions(permissions: QualifiedName[]): void;
30
50
  readonly telemetryEvents: readonly TelemetryEvent[];
@@ -43,8 +63,7 @@ export interface HostRequestContextFixtureOptions {
43
63
  resourceContextKey?: StableId;
44
64
  sessionGeneration?: Revision;
45
65
  }
46
- export declare class TestbedHostError extends Error {
47
- readonly error: HostPortError;
66
+ export declare class TestbedHostError extends HostPortFailure {
48
67
  constructor(error: HostPortError);
49
68
  }
50
69
  export declare function createHostRequestContextFixture(options?: HostRequestContextFixtureOptions): HostRequestContext;
@@ -1 +1 @@
1
- {"version":3,"file":"host-testbed.d.ts","sourceRoot":"","sources":["../src/host-testbed.ts"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,kBAAkB,EAGvB,KAAK,UAAU,EACf,KAAK,wBAAwB,EAI7B,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,aAAa,EAClB,KAAK,iBAAiB,EACtB,KAAK,QAAQ,EACb,KAAK,QAAQ,EACb,KAAK,cAAc,EACnB,KAAK,yBAAyB,EAC9B,KAAK,cAAc,EACpB,MAAM,wBAAwB,CAAC;AAEhC,MAAM,MAAM,eAAe,GACvB,UAAU,GACV,cAAc,GACd,OAAO,GACP,YAAY,GACZ,SAAS,GACT,UAAU,GACV,UAAU,GACV,WAAW,CAAC;AAEhB,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,EAAE,cAAc,EAAE,CAAC;IAC7B,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC,CAAC;IACzD,WAAW,CAAC,EAAE,aAAa,EAAE,CAAC;IAC9B,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,oBAAoB,KAAK,sBAAsB,CAAC;IACnE,SAAS,CAAC,EAAE,iBAAiB,EAAE,CAAC;CACjC;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,IAAI,IAAI,CAAC;IACnB,QAAQ,CAAC,QAAQ,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAC1F;;;;;;;;;;OAUG;IACH,mBAAmB,CACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,cAAc,CAAC,wBAAwB,CAAC,CAAC,CAAC;IACrD,SAAS,IAAI,IAAI,CAAC;IAClB,UAAU,CAAC,EAAE,EAAE,QAAQ,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC/C,QAAQ,CAAC,iBAAiB,EAAE,QAAQ,CAAC;IACrC,cAAc,CAAC,WAAW,EAAE,aAAa,EAAE,GAAG,IAAI,CAAC;IACnD,QAAQ,CAAC,eAAe,EAAE,SAAS,cAAc,EAAE,CAAC;CACrD;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,eAAe,CAAC;IAC1B,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,MAAM,WAAW,gCAAgC;IAC/C,gBAAgB,CAAC,EAAE,QAAQ,CAAC;IAC5B,cAAc,CAAC,EAAE,QAAQ,CAAC;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,aAAa,CAAC;IAC5B,eAAe,CAAC,EAAE,yBAAyB,CAAC;IAC5C,SAAS,CAAC,EAAE,QAAQ,CAAC;IACrB,kBAAkB,CAAC,EAAE,QAAQ,CAAC;IAC9B,iBAAiB,CAAC,EAAE,QAAQ,CAAC;CAC9B;AAED,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,SAAgB,KAAK,EAAE,aAAa,CAAC;gBAElB,KAAK,EAAE,aAAa;CAKxC;AAED,wBAAgB,+BAA+B,CAC7C,OAAO,GAAE,gCAAqC,GAC7C,kBAAkB,CAapB;AAeD,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,kBAAuB,GAAG,WAAW,CAue/E"}
1
+ {"version":3,"file":"host-testbed.d.ts","sourceRoot":"","sources":["../src/host-testbed.ts"],"names":[],"mappings":"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"}
@@ -1,11 +1,9 @@
1
- import { validateExternalUrl } from '@kumwe/studio-core';
2
- import { STUDIO_CONTRACT_VERSION, STUDIO_WIRE_PROTOCOL_VERSION, } from '@kumwe/studio-protocol';
3
- export class TestbedHostError extends Error {
4
- error;
1
+ import { canonicalStringify, validateExternalUrl } from '@kumwe/studio-core';
2
+ import { HostPortFailure, STUDIO_CONTRACT_VERSION, STUDIO_STALE_SESSION_GENERATION_DIAGNOSTIC_CODE, STUDIO_WIRE_PROTOCOL_VERSION, } from '@kumwe/studio-protocol';
3
+ export class TestbedHostError extends HostPortFailure {
5
4
  constructor(error) {
6
- super(error.message.defaultMessage ?? error.message.key);
5
+ super(error);
7
6
  this.name = 'TestbedHostError';
8
- this.error = error;
9
7
  }
10
8
  }
11
9
  export function createHostRequestContextFixture(options = {}) {
@@ -33,13 +31,21 @@ const PUBLISH_PERMISSION = 'studio.permission/publish';
33
31
  const UPLOAD_CHUNK_BYTES = 5_242_880;
34
32
  const UPLOAD_MAXIMUM_BYTES = 52_428_800;
35
33
  const GRANT_EXPIRY = '2030-01-01T00:00:00Z';
34
+ const MAXIMUM_RATE_LIMIT_REQUESTS = 1000;
35
+ const MAXIMUM_RATE_LIMIT_WINDOW_MILLISECONDS = 86_400_000;
36
36
  export function createTestbedHost(options = {}) {
37
37
  const artifactStore = new Map();
38
38
  for (const seeded of options.documents ?? []) {
39
39
  const document = cloneValue(seeded);
40
- const revision = `${document.id}-r1`;
41
- document.revision = revision;
42
- artifactStore.set(document.id, { document, revision, serial: 1 });
40
+ if (artifactStore.has(document.id)) {
41
+ throw new TypeError(`Testbed artifact ${document.id} is duplicated.`);
42
+ }
43
+ const revision = document.revision;
44
+ artifactStore.set(document.id, {
45
+ document,
46
+ revision,
47
+ serial: seededRevisionSerial(document.id, revision),
48
+ });
43
49
  }
44
50
  const mediaAssets = cloneValue(options.mediaAssets ?? []);
45
51
  const resources = cloneValue(options.resources ?? []);
@@ -48,16 +54,43 @@ export function createTestbedHost(options = {}) {
48
54
  const telemetryEvents = [];
49
55
  const injectedFailures = [];
50
56
  const renderCallback = options.render ?? defaultRender;
57
+ const idempotencyRecords = new Map();
58
+ const pendingPreviewRenders = new Map();
59
+ const previewDeliveries = [];
60
+ const rateLimitPolicies = new Map();
61
+ const rateLimitWindows = new Map();
62
+ for (const policy of options.rateLimits ?? []) {
63
+ if (!Number.isSafeInteger(policy.maximumRequests) ||
64
+ policy.maximumRequests < 1 ||
65
+ policy.maximumRequests > MAXIMUM_RATE_LIMIT_REQUESTS ||
66
+ !Number.isSafeInteger(policy.windowMilliseconds) ||
67
+ policy.windowMilliseconds < 1 ||
68
+ policy.windowMilliseconds > MAXIMUM_RATE_LIMIT_WINDOW_MILLISECONDS) {
69
+ throw new RangeError('Testbed rate limits require positive safe-integer bounds.');
70
+ }
71
+ if (rateLimitPolicies.has(policy.operationId)) {
72
+ throw new TypeError(`Testbed rate limit ${policy.operationId} is duplicated.`);
73
+ }
74
+ rateLimitPolicies.set(policy.operationId, { ...policy });
75
+ }
51
76
  const uploadGrants = new Map();
52
77
  const acceptedUploads = new Map();
53
78
  let uploadSerial = 0;
54
79
  let permissions = (options.permissions ?? []).slice();
55
- let generationSerial = 1;
80
+ let liveGeneration = options.sessionGeneration ?? 'session-r1';
81
+ if (!isNonEmptyString(liveGeneration) || liveGeneration.length > 200) {
82
+ throw new TypeError('The testbed session generation must be a non-empty bounded revision.');
83
+ }
84
+ let generationSerial = seededRevisionSerial('session', liveGeneration);
56
85
  let connected = true;
57
86
  let correlationSerial = 0;
58
87
  let importSerial = 0;
88
+ let clockMilliseconds = options.initialClockMilliseconds ?? 0;
89
+ if (!Number.isSafeInteger(clockMilliseconds) || clockMilliseconds < 0) {
90
+ throw new RangeError('The testbed logical clock must be a non-negative safe integer.');
91
+ }
59
92
  function currentGeneration() {
60
- return `session-r${generationSerial}`;
93
+ return liveGeneration;
61
94
  }
62
95
  function createError(category, defaultMessage, extras = {}) {
63
96
  correlationSerial += 1;
@@ -68,24 +101,20 @@ export function createTestbedHost(options = {}) {
68
101
  kind: 'host-error',
69
102
  message: { defaultMessage, key: `studio.testbed/${category}` },
70
103
  retryable: extras.retryable ?? (category === 'rate-limited' || category === 'unavailable'),
104
+ ...(extras.diagnostics === undefined ? {} : { diagnostics: cloneValue(extras.diagnostics) }),
105
+ ...(extras.retryAfterMilliseconds === undefined
106
+ ? {}
107
+ : { retryAfterMilliseconds: extras.retryAfterMilliseconds }),
71
108
  ...(extras.revision === undefined ? {} : { revision: extras.revision }),
72
109
  };
73
110
  }
74
111
  function fail(category, defaultMessage, extras = {}) {
75
112
  throw new TestbedHostError(createError(category, defaultMessage, extras));
76
113
  }
77
- function guard(portName, operation, context) {
114
+ function guardEnvelope(portName, operation, context) {
78
115
  if (!connected) {
79
116
  fail('unavailable', 'The testbed host is disconnected.');
80
117
  }
81
- const index = injectedFailures.findIndex((entry) => entry.portName === portName && entry.operation === operation);
82
- if (index >= 0) {
83
- const injected = injectedFailures[index];
84
- injectedFailures.splice(index, 1);
85
- if (injected !== undefined) {
86
- fail(injected.category, 'The testbed injected this failure.');
87
- }
88
- }
89
118
  if (!isNonEmptyString(context.requestId) ||
90
119
  !isNonEmptyString(context.operationId) ||
91
120
  !isNonEmptyString(context.resourceContextKey) ||
@@ -96,19 +125,122 @@ export function createTestbedHost(options = {}) {
96
125
  fail('incompatible', 'The request protocol version is not supported by this testbed.');
97
126
  }
98
127
  if (context.sessionGeneration !== currentGeneration()) {
99
- fail('invalid-request', 'The session generation is no longer valid.');
128
+ fail('invalid-request', 'The session generation is no longer valid.', {
129
+ diagnostics: [
130
+ {
131
+ code: STUDIO_STALE_SESSION_GENERATION_DIAGNOSTIC_CODE,
132
+ message: {
133
+ defaultMessage: 'The request belongs to an obsolete Studio session generation.',
134
+ key: STUDIO_STALE_SESSION_GENERATION_DIAGNOSTIC_CODE,
135
+ },
136
+ severity: 'blocking',
137
+ },
138
+ ],
139
+ });
140
+ }
141
+ const expectedOperationId = `studio.operation/${portName}.${operation}`;
142
+ if (context.operationId !== expectedOperationId &&
143
+ !(options.allowTestOperationId === true && context.operationId === 'studio.test/operation')) {
144
+ fail('invalid-request', 'The request operation identifier does not match the invoked port.');
100
145
  }
101
146
  }
102
- function run(portName, operation, context, body) {
147
+ function applyOperationalGuards(portName, operation, context) {
148
+ const index = injectedFailures.findIndex((entry) => entry.portName === portName && entry.operation === operation);
149
+ if (index >= 0) {
150
+ const injected = injectedFailures[index];
151
+ injectedFailures.splice(index, 1);
152
+ if (injected !== undefined) {
153
+ fail(injected.category, 'The testbed injected this failure.');
154
+ }
155
+ }
156
+ const policy = rateLimitPolicies.get(context.operationId);
157
+ if (policy === undefined) {
158
+ return;
159
+ }
160
+ let rateWindow = rateLimitWindows.get(context.operationId);
161
+ if (rateWindow === undefined ||
162
+ clockMilliseconds >= rateWindow.startedAtMilliseconds + policy.windowMilliseconds) {
163
+ rateWindow = { requestCount: 0, startedAtMilliseconds: clockMilliseconds };
164
+ rateLimitWindows.set(context.operationId, rateWindow);
165
+ }
166
+ if (rateWindow.requestCount >= policy.maximumRequests) {
167
+ fail('rate-limited', 'The operation has reached its deterministic request bound.', {
168
+ retryAfterMilliseconds: rateWindow.startedAtMilliseconds + policy.windowMilliseconds - clockMilliseconds,
169
+ });
170
+ }
171
+ rateWindow.requestCount += 1;
172
+ }
173
+ async function executeBody(body) {
103
174
  try {
104
- guard(portName, operation, context);
105
- return Promise.resolve(body());
175
+ return await body();
106
176
  }
107
177
  catch (error) {
108
178
  if (error instanceof TestbedHostError) {
109
- return Promise.reject(error);
179
+ throw error;
180
+ }
181
+ throw new TestbedHostError(createError('internal', 'The testbed operation failed unexpectedly.'));
182
+ }
183
+ }
184
+ function run(portName, operation, context, body) {
185
+ try {
186
+ guardEnvelope(portName, operation, context);
187
+ applyOperationalGuards(portName, operation, context);
188
+ return executeBody(body);
189
+ }
190
+ catch (error) {
191
+ return executeBody(() => {
192
+ throw error;
193
+ });
194
+ }
195
+ }
196
+ function runMutation(portName, operation, argument, context, body) {
197
+ try {
198
+ guardEnvelope(portName, operation, context);
199
+ const idempotencyKey = context.idempotencyKey;
200
+ if (idempotencyKey === undefined) {
201
+ applyOperationalGuards(portName, operation, context);
202
+ return executeBody(body);
110
203
  }
111
- return Promise.reject(new TestbedHostError(createError('internal', 'The testbed operation failed unexpectedly.')));
204
+ const scope = canonicalStringify({
205
+ idempotencyKey,
206
+ operationId: context.operationId,
207
+ resourceContextKey: context.resourceContextKey,
208
+ sessionGeneration: context.sessionGeneration,
209
+ });
210
+ const fingerprint = canonicalStringify({
211
+ argument,
212
+ context: {
213
+ ...(context.expectedRevision === undefined
214
+ ? {}
215
+ : { expectedRevision: context.expectedRevision }),
216
+ ...(context.locale === undefined ? {} : { locale: context.locale }),
217
+ protocolVersion: context.protocolVersion,
218
+ },
219
+ });
220
+ const prior = idempotencyRecords.get(scope);
221
+ if (prior !== undefined) {
222
+ if (prior.fingerprint !== fingerprint) {
223
+ fail('invalid-request', 'The idempotency key was already accepted for another intent.');
224
+ }
225
+ return prior.promise.then((result) => cloneValue(result));
226
+ }
227
+ applyOperationalGuards(portName, operation, context);
228
+ const accepted = executeBody(body).then((result) => cloneValue(result));
229
+ idempotencyRecords.set(scope, {
230
+ fingerprint,
231
+ promise: accepted,
232
+ });
233
+ void accepted.catch(() => {
234
+ if (idempotencyRecords.get(scope)?.promise === accepted) {
235
+ idempotencyRecords.delete(scope);
236
+ }
237
+ });
238
+ return accepted.then((result) => cloneValue(result));
239
+ }
240
+ catch (error) {
241
+ return executeBody(() => {
242
+ throw error;
243
+ });
112
244
  }
113
245
  }
114
246
  /**
@@ -176,10 +308,10 @@ export function createTestbedHost(options = {}) {
176
308
  });
177
309
  },
178
310
  publish(reference, context) {
179
- return run('artifact', 'publish', context, () => setStatus(reference.id, context, 'published'));
311
+ return runMutation('artifact', 'publish', reference, context, () => setStatus(reference.id, context, 'published'));
180
312
  },
181
313
  save(document, context) {
182
- return run('artifact', 'save', context, () => {
314
+ return runMutation('artifact', 'save', document, context, () => {
183
315
  requireArtifactPermission(SAVE_PERMISSION);
184
316
  const stored = requireStored(document.id);
185
317
  ensureExpectedRevision(stored, context);
@@ -188,7 +320,7 @@ export function createTestbedHost(options = {}) {
188
320
  });
189
321
  },
190
322
  unpublish(reference, context) {
191
- return run('artifact', 'unpublish', context, () => setStatus(reference.id, context, 'draft'));
323
+ return runMutation('artifact', 'unpublish', reference, context, () => setStatus(reference.id, context, 'draft'));
192
324
  },
193
325
  },
194
326
  localization: {
@@ -211,7 +343,7 @@ export function createTestbedHost(options = {}) {
211
343
  },
212
344
  media: {
213
345
  abortUpload(uploadId, context) {
214
- return run('media', 'abort-upload', context, () => {
346
+ return runMutation('media', 'abort-upload', uploadId, context, () => {
215
347
  if (!uploadGrants.delete(uploadId)) {
216
348
  fail('not-found', 'The requested upload is not available.');
217
349
  }
@@ -219,7 +351,7 @@ export function createTestbedHost(options = {}) {
219
351
  });
220
352
  },
221
353
  authorizeUpload(request, context) {
222
- return run('media', 'authorize-upload', context, () => {
354
+ return runMutation('media', 'authorize-upload', request, context, () => {
223
355
  assertUploadRequest(request);
224
356
  uploadSerial += 1;
225
357
  const uploadId = `uploads/testbed-${String(uploadSerial)}`;
@@ -240,7 +372,7 @@ export function createTestbedHost(options = {}) {
240
372
  });
241
373
  },
242
374
  completeUpload(uploadId, context) {
243
- return run('media', 'complete-upload', context, () => {
375
+ return runMutation('media', 'complete-upload', uploadId, context, () => {
244
376
  const pending = uploadGrants.get(uploadId);
245
377
  if (pending === undefined) {
246
378
  fail('not-found', 'The requested upload is not available.');
@@ -287,7 +419,7 @@ export function createTestbedHost(options = {}) {
287
419
  });
288
420
  },
289
421
  importExternal(url, context) {
290
- return run('media', 'import-external', context, () => {
422
+ return runMutation('media', 'import-external', url, context, () => {
291
423
  const verdict = validateExternalUrl(url);
292
424
  if (!verdict.ok) {
293
425
  // The stable reason is disclosed; the candidate never is.
@@ -313,6 +445,33 @@ export function createTestbedHost(options = {}) {
313
445
  });
314
446
  },
315
447
  },
448
+ model: {
449
+ get(reference, context) {
450
+ return run('model', 'get', context, () => {
451
+ const stored = requireStored(reference.id);
452
+ if (stored.document.kind !== 'content-model' ||
453
+ stored.document.version !== reference.version ||
454
+ ('revision' in reference && reference.revision !== stored.revision)) {
455
+ fail('not-found', 'The requested model projection is not available.');
456
+ }
457
+ return {
458
+ revision: stored.revision,
459
+ value: cloneValue(stored.document),
460
+ };
461
+ });
462
+ },
463
+ list(context) {
464
+ return run('model', 'list', context, () => {
465
+ const value = [...artifactStore.values()]
466
+ .filter((stored) => stored.document.kind === 'content-model')
467
+ .map((stored) => cloneValue(stored.document))
468
+ .sort((left, right) => compareCodeUnits(left.id, right.id) ||
469
+ compareCodeUnits(left.version, right.version) ||
470
+ compareCodeUnits(left.revision, right.revision));
471
+ return { value };
472
+ });
473
+ },
474
+ },
316
475
  permission: {
317
476
  explain(operation, context) {
318
477
  return run('permission', 'explain', context, () => {
@@ -336,17 +495,48 @@ export function createTestbedHost(options = {}) {
336
495
  },
337
496
  preview: {
338
497
  cancel(draftDigest, context) {
339
- return run('preview', 'cancel', context, () => ({ value: null }));
498
+ return run('preview', 'cancel', context, () => {
499
+ const pending = pendingPreviewRenders.get(previewRenderKey(draftDigest, context));
500
+ pending?.cancel();
501
+ return { value: null };
502
+ });
340
503
  },
341
504
  render(payload, context) {
342
- return run('preview', 'render', context, () => ({
343
- value: cloneValue(renderCallback(cloneValue(payload))),
344
- }));
505
+ return run('preview', 'render', context, async () => {
506
+ const key = previewRenderKey(payload.draftDigest, context);
507
+ if (pendingPreviewRenders.has(key)) {
508
+ fail('invalid-request', 'A render for this draft is already in flight.');
509
+ }
510
+ let cancel = () => undefined;
511
+ const cancellation = new Promise((_resolve, reject) => {
512
+ cancel = () => {
513
+ reject(new TestbedHostError(createError('cancelled', 'The in-flight preview render was cancelled.', {
514
+ retryable: false,
515
+ })));
516
+ };
517
+ });
518
+ pendingPreviewRenders.set(key, { cancel });
519
+ try {
520
+ const rendered = await Promise.race([
521
+ Promise.resolve(renderCallback(cloneValue(payload))),
522
+ cancellation,
523
+ ]);
524
+ if (rendered.draftDigest !== payload.draftDigest ||
525
+ rendered.requestId !== payload.requestId) {
526
+ fail('internal', 'The preview renderer returned a result for a different render attempt.');
527
+ }
528
+ previewDeliveries.push(payload.draftDigest);
529
+ return { value: cloneValue(rendered) };
530
+ }
531
+ finally {
532
+ pendingPreviewRenders.delete(key);
533
+ }
534
+ });
345
535
  },
346
536
  },
347
537
  recovery: {
348
538
  discard(context) {
349
- return run('recovery', 'discard', context, () => {
539
+ return runMutation('recovery', 'discard', null, context, () => {
350
540
  recoveryStore.delete(context.resourceContextKey);
351
541
  return { value: null };
352
542
  });
@@ -359,7 +549,7 @@ export function createTestbedHost(options = {}) {
359
549
  });
360
550
  },
361
551
  store(envelope, context) {
362
- return run('recovery', 'store', context, () => {
552
+ return runMutation('recovery', 'store', envelope, context, () => {
363
553
  recoveryStore.set(context.resourceContextKey, cloneValue(envelope));
364
554
  return { value: null };
365
555
  });
@@ -390,7 +580,7 @@ export function createTestbedHost(options = {}) {
390
580
  },
391
581
  telemetry: {
392
582
  emit(event, context) {
393
- return run('telemetry', 'emit', context, () => {
583
+ return runMutation('telemetry', 'emit', event, context, () => {
394
584
  if (event.attributes !== undefined) {
395
585
  for (const attribute of Object.values(event.attributes)) {
396
586
  if (!isJsonPrimitiveValue(attribute)) {
@@ -405,6 +595,19 @@ export function createTestbedHost(options = {}) {
405
595
  },
406
596
  };
407
597
  const controls = {
598
+ advanceClock(milliseconds) {
599
+ if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) {
600
+ throw new RangeError('Testbed clock advances must be non-negative safe integers.');
601
+ }
602
+ const next = clockMilliseconds + milliseconds;
603
+ if (!Number.isSafeInteger(next)) {
604
+ throw new RangeError('The testbed logical clock cannot exceed the safe-integer bound.');
605
+ }
606
+ clockMilliseconds = next;
607
+ },
608
+ artifactStatus(id) {
609
+ return artifactStore.get(id)?.document.status;
610
+ },
408
611
  disconnect() {
409
612
  connected = false;
410
613
  },
@@ -438,15 +641,26 @@ export function createTestbedHost(options = {}) {
438
641
  reconnect() {
439
642
  connected = true;
440
643
  },
644
+ recoveryEnvelope(resourceContextKey) {
645
+ const envelope = recoveryStore.get(resourceContextKey);
646
+ return envelope === undefined ? undefined : cloneValue(envelope);
647
+ },
441
648
  revisionOf(id) {
442
649
  return artifactStore.get(id)?.revision;
443
650
  },
651
+ get pendingPreviewRenders() {
652
+ return pendingPreviewRenders.size;
653
+ },
654
+ get previewDeliveries() {
655
+ return previewDeliveries.slice();
656
+ },
444
657
  get sessionGeneration() {
445
658
  return currentGeneration();
446
659
  },
447
660
  setPermissions(next) {
448
661
  permissions = next.slice();
449
662
  generationSerial += 1;
663
+ liveGeneration = `session-r${generationSerial}`;
450
664
  },
451
665
  get telemetryEvents() {
452
666
  return cloneValue(telemetryEvents);
@@ -455,7 +669,25 @@ export function createTestbedHost(options = {}) {
455
669
  return { controls, host };
456
670
  }
457
671
  function defaultRender(payload) {
458
- return { diagnostics: [], draftDigest: payload.draftDigest, markers: [] };
672
+ return {
673
+ diagnostics: [],
674
+ draftDigest: payload.draftDigest,
675
+ markerMap: {},
676
+ markers: [],
677
+ requestId: payload.requestId,
678
+ };
679
+ }
680
+ function previewRenderKey(draftDigest, context) {
681
+ return `${context.sessionGeneration}\u0000${context.resourceContextKey}\u0000${draftDigest}`;
682
+ }
683
+ function seededRevisionSerial(id, revision) {
684
+ const prefix = `${id}-r`;
685
+ if (!revision.startsWith(prefix)) {
686
+ return 0;
687
+ }
688
+ const suffix = revision.slice(prefix.length);
689
+ const parsed = Number(suffix);
690
+ return Number.isSafeInteger(parsed) && parsed >= 0 && String(parsed) === suffix ? parsed : 0;
459
691
  }
460
692
  function isNonEmptyString(value) {
461
693
  return typeof value === 'string' && value.length > 0;
@@ -513,6 +745,9 @@ function decodeCursor(cursor) {
513
745
  function cloneValue(value) {
514
746
  return JSON.parse(JSON.stringify(value));
515
747
  }
748
+ function compareCodeUnits(left, right) {
749
+ return left < right ? -1 : left > right ? 1 : 0;
750
+ }
516
751
  /**
517
752
  * The policy gate that runs before any byte moves: a declared upload larger
518
753
  * than the host's bound, or one carrying a filename the canonical shape