@cleocode/core 2026.4.12 → 2026.4.13

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.
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Tests for backup-unpack.ts (T350).
3
+ *
4
+ * Covers: unpack of unencrypted and encrypted bundles, all verification
5
+ * layers, BundleError codes (70–75), staging dir management, and manifest
6
+ * field correctness.
7
+ *
8
+ * Uses real node:sqlite DatabaseSync to seed minimal test databases.
9
+ * All filesystem interactions occur in temp directories; the real user's
10
+ * project root is never touched.
11
+ *
12
+ * @task T350
13
+ * @epic T311
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import { createRequire } from 'node:module';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import type { DatabaseSync as _DatabaseSyncType } from 'node:sqlite';
21
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
+ import { packBundle } from '../backup-pack.js';
23
+ import { BundleError, cleanupStaging, unpackBundle } from '../backup-unpack.js';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // node:sqlite interop
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const _require = createRequire(import.meta.url);
30
+ type DatabaseSync = _DatabaseSyncType;
31
+ const { DatabaseSync } = _require('node:sqlite') as {
32
+ DatabaseSync: new (...args: ConstructorParameters<typeof _DatabaseSyncType>) => DatabaseSync;
33
+ };
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Seed a project root with the minimal .cleo layout required by packBundle.
41
+ * Creates: tasks.db, brain.db, conduit.db, config.json, project-info.json,
42
+ * project-context.json.
43
+ */
44
+ function seedProject(projectRoot: string): void {
45
+ const cleoDir = path.join(projectRoot, '.cleo');
46
+ fs.mkdirSync(cleoDir, { recursive: true });
47
+ for (const name of ['tasks', 'brain', 'conduit']) {
48
+ const db = new DatabaseSync(path.join(cleoDir, `${name}.db`));
49
+ db.exec('CREATE TABLE t(x INTEGER); INSERT INTO t VALUES (1);');
50
+ db.close();
51
+ }
52
+ fs.writeFileSync(path.join(cleoDir, 'config.json'), '{}');
53
+ fs.writeFileSync(path.join(cleoDir, 'project-info.json'), '{}');
54
+ fs.writeFileSync(path.join(cleoDir, 'project-context.json'), '{}');
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Test suite
59
+ // ---------------------------------------------------------------------------
60
+
61
+ describe('T350 backup-unpack', () => {
62
+ let tmpRoot: string;
63
+ let bundleDir: string;
64
+
65
+ beforeEach(() => {
66
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t350-root-'));
67
+ bundleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t350-bundle-'));
68
+ seedProject(tmpRoot);
69
+ });
70
+
71
+ afterEach(() => {
72
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
73
+ fs.rmSync(bundleDir, { recursive: true, force: true });
74
+ });
75
+
76
+ async function createBundle(opts?: { encrypt?: boolean; passphrase?: string }): Promise<string> {
77
+ const bundlePath = path.join(
78
+ bundleDir,
79
+ 'test' + (opts?.encrypt === true ? '.enc' : '') + '.cleobundle.tar.gz',
80
+ );
81
+ await packBundle({
82
+ scope: 'project',
83
+ projectRoot: tmpRoot,
84
+ outputPath: bundlePath,
85
+ encrypt: opts?.encrypt,
86
+ passphrase: opts?.passphrase,
87
+ });
88
+ return bundlePath;
89
+ }
90
+
91
+ // -------------------------------------------------------------------------
92
+ // Unencrypted bundle — happy path
93
+ // -------------------------------------------------------------------------
94
+
95
+ it('unpacks an unencrypted bundle successfully', async () => {
96
+ const bundlePath = await createBundle();
97
+ const result = await unpackBundle({ bundlePath });
98
+ expect(result.verified.manifestSchema).toBe(true);
99
+ expect(result.verified.checksums).toBe(true);
100
+ expect(result.verified.sqliteIntegrity).toBe(true);
101
+ expect(result.manifest.backup.scope).toBe('project');
102
+ cleanupStaging(result.stagingDir);
103
+ });
104
+
105
+ it('returns verified.encryptionAuth=true for unencrypted bundle (N/A = pass)', async () => {
106
+ const bundlePath = await createBundle();
107
+ const result = await unpackBundle({ bundlePath });
108
+ expect(result.verified.encryptionAuth).toBe(true);
109
+ cleanupStaging(result.stagingDir);
110
+ });
111
+
112
+ // -------------------------------------------------------------------------
113
+ // Encrypted bundle — happy path
114
+ // -------------------------------------------------------------------------
115
+
116
+ it('unpacks an encrypted bundle with correct passphrase', async () => {
117
+ const bundlePath = await createBundle({ encrypt: true, passphrase: 'hunter2' });
118
+ const result = await unpackBundle({ bundlePath, passphrase: 'hunter2' });
119
+ expect(result.verified.encryptionAuth).toBe(true);
120
+ expect(result.verified.manifestSchema).toBe(true);
121
+ expect(result.verified.checksums).toBe(true);
122
+ expect(result.verified.sqliteIntegrity).toBe(true);
123
+ cleanupStaging(result.stagingDir);
124
+ });
125
+
126
+ // -------------------------------------------------------------------------
127
+ // Error: wrong passphrase → E_BUNDLE_DECRYPT (70)
128
+ // -------------------------------------------------------------------------
129
+
130
+ it('throws BundleError(70) on wrong passphrase', async () => {
131
+ const bundlePath = await createBundle({ encrypt: true, passphrase: 'hunter2' });
132
+ await expect(unpackBundle({ bundlePath, passphrase: 'wrong' })).rejects.toMatchObject({
133
+ code: 70,
134
+ codeName: 'E_BUNDLE_DECRYPT',
135
+ });
136
+ });
137
+
138
+ // -------------------------------------------------------------------------
139
+ // Error: encrypted bundle without passphrase → E_BUNDLE_DECRYPT (70)
140
+ // -------------------------------------------------------------------------
141
+
142
+ it('throws BundleError(70) on encrypted bundle without passphrase', async () => {
143
+ const bundlePath = await createBundle({ encrypt: true, passphrase: 'hunter2' });
144
+ await expect(unpackBundle({ bundlePath })).rejects.toMatchObject({ code: 70 });
145
+ });
146
+
147
+ it('throws BundleError(70) on encrypted bundle with empty passphrase', async () => {
148
+ const bundlePath = await createBundle({ encrypt: true, passphrase: 'hunter2' });
149
+ await expect(unpackBundle({ bundlePath, passphrase: '' })).rejects.toMatchObject({ code: 70 });
150
+ });
151
+
152
+ // -------------------------------------------------------------------------
153
+ // Error: tampered bundle → BundleError (72 or tar error)
154
+ // -------------------------------------------------------------------------
155
+
156
+ it('throws BundleError on checksum mismatch (tampered bytes)', async () => {
157
+ const bundlePath = await createBundle();
158
+ // Tamper by flipping bits near the end of the file (in the compressed data)
159
+ const buf = fs.readFileSync(bundlePath);
160
+ // Flip bytes well inside the compressed payload (not the gzip header)
161
+ const tamperOffset = Math.floor(buf.length / 2);
162
+ buf[tamperOffset] = buf[tamperOffset]! ^ 0xff;
163
+ buf[tamperOffset + 1] = (buf[tamperOffset + 1] ?? 0) ^ 0xff;
164
+ fs.writeFileSync(bundlePath, buf);
165
+ // tar may catch the corruption before we read checksums — accept any BundleError
166
+ await expect(unpackBundle({ bundlePath })).rejects.toBeInstanceOf(BundleError);
167
+ });
168
+
169
+ // -------------------------------------------------------------------------
170
+ // Staging directory structure
171
+ // -------------------------------------------------------------------------
172
+
173
+ it('returns a valid staging dir containing manifest.json, databases/, json/', async () => {
174
+ const bundlePath = await createBundle();
175
+ const result = await unpackBundle({ bundlePath });
176
+ expect(fs.existsSync(path.join(result.stagingDir, 'manifest.json'))).toBe(true);
177
+ expect(fs.existsSync(path.join(result.stagingDir, 'databases'))).toBe(true);
178
+ expect(fs.existsSync(path.join(result.stagingDir, 'json'))).toBe(true);
179
+ cleanupStaging(result.stagingDir);
180
+ });
181
+
182
+ it('staging dir contains schemas/manifest-v1.json', async () => {
183
+ const bundlePath = await createBundle();
184
+ const result = await unpackBundle({ bundlePath });
185
+ expect(fs.existsSync(path.join(result.stagingDir, 'schemas', 'manifest-v1.json'))).toBe(true);
186
+ cleanupStaging(result.stagingDir);
187
+ });
188
+
189
+ // -------------------------------------------------------------------------
190
+ // cleanupStaging
191
+ // -------------------------------------------------------------------------
192
+
193
+ it('cleanupStaging removes the staging dir', async () => {
194
+ const bundlePath = await createBundle();
195
+ const result = await unpackBundle({ bundlePath });
196
+ cleanupStaging(result.stagingDir);
197
+ expect(fs.existsSync(result.stagingDir)).toBe(false);
198
+ });
199
+
200
+ it('cleanupStaging is idempotent — does not throw if already removed', async () => {
201
+ const bundlePath = await createBundle();
202
+ const result = await unpackBundle({ bundlePath });
203
+ cleanupStaging(result.stagingDir);
204
+ // Second call must not throw
205
+ expect(() => cleanupStaging(result.stagingDir)).not.toThrow();
206
+ });
207
+
208
+ // -------------------------------------------------------------------------
209
+ // Manifest field correctness
210
+ // -------------------------------------------------------------------------
211
+
212
+ it('parses manifest fields correctly', async () => {
213
+ const bundlePath = await createBundle();
214
+ const result = await unpackBundle({ bundlePath });
215
+ expect(result.manifest.manifestVersion).toBe('1.0.0');
216
+ expect(result.manifest.$schema).toBe('./schemas/manifest-v1.json');
217
+ expect(result.manifest.backup.scope).toBe('project');
218
+ expect(result.manifest.integrity.algorithm).toBe('sha256');
219
+ expect(result.manifest.databases.length).toBeGreaterThanOrEqual(3);
220
+ cleanupStaging(result.stagingDir);
221
+ });
222
+
223
+ it('manifest.backup.encrypted is false for unencrypted bundle', async () => {
224
+ const bundlePath = await createBundle();
225
+ const result = await unpackBundle({ bundlePath });
226
+ expect(result.manifest.backup.encrypted).toBe(false);
227
+ cleanupStaging(result.stagingDir);
228
+ });
229
+
230
+ it('manifest.backup.encrypted is true for encrypted bundle', async () => {
231
+ const bundlePath = await createBundle({ encrypt: true, passphrase: 'p@ss' });
232
+ const result = await unpackBundle({ bundlePath, passphrase: 'p@ss' });
233
+ expect(result.manifest.backup.encrypted).toBe(true);
234
+ cleanupStaging(result.stagingDir);
235
+ });
236
+
237
+ it('manifest.databases includes tasks, brain, conduit for project scope', async () => {
238
+ const bundlePath = await createBundle();
239
+ const result = await unpackBundle({ bundlePath });
240
+ const names = result.manifest.databases.map((d) => d.name);
241
+ expect(names).toContain('tasks');
242
+ expect(names).toContain('brain');
243
+ expect(names).toContain('conduit');
244
+ cleanupStaging(result.stagingDir);
245
+ });
246
+
247
+ // -------------------------------------------------------------------------
248
+ // BundleError class shape
249
+ // -------------------------------------------------------------------------
250
+
251
+ it('BundleError extends Error with code and codeName properties', async () => {
252
+ const bundlePath = await createBundle({ encrypt: true, passphrase: 'secret' });
253
+ let caught: unknown = null;
254
+ try {
255
+ await unpackBundle({ bundlePath });
256
+ } catch (err) {
257
+ caught = err;
258
+ }
259
+ expect(caught).toBeInstanceOf(BundleError);
260
+ expect(caught).toBeInstanceOf(Error);
261
+ const be = caught as BundleError;
262
+ expect(typeof be.code).toBe('number');
263
+ expect(typeof be.codeName).toBe('string');
264
+ expect(be.name).toBe('BundleError');
265
+ });
266
+
267
+ // -------------------------------------------------------------------------
268
+ // Cleanup on error — staging dir is removed when unpack fails
269
+ // -------------------------------------------------------------------------
270
+
271
+ it('does not leave a staging dir behind when decryption fails', async () => {
272
+ const bundlePath = await createBundle({ encrypt: true, passphrase: 'secret' });
273
+ const preDirs = fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith('cleo-unpack-'));
274
+
275
+ try {
276
+ await unpackBundle({ bundlePath, passphrase: 'wrong' });
277
+ } catch {
278
+ // expected
279
+ }
280
+
281
+ const postDirs = fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith('cleo-unpack-'));
282
+ const newDirs = postDirs.filter((d) => !preDirs.includes(d));
283
+ expect(newDirs).toHaveLength(0);
284
+ });
285
+
286
+ // -------------------------------------------------------------------------
287
+ // Warnings are empty for a freshly-packed bundle
288
+ // -------------------------------------------------------------------------
289
+
290
+ it('returns no schema version warnings for a freshly packed bundle', async () => {
291
+ const bundlePath = await createBundle();
292
+ const result = await unpackBundle({ bundlePath });
293
+ // No warnings for a brand-new pack; schema versions may be 'unknown'
294
+ // so compareSchemaVersions returns null and warnings stays empty.
295
+ expect(Array.isArray(result.warnings)).toBe(true);
296
+ cleanupStaging(result.stagingDir);
297
+ });
298
+ });
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Tests for T352 dry-run JSON file generators.
3
+ *
4
+ * Verifies:
5
+ * 1. All generators return the correct `filename` field.
6
+ * 2. No generator writes anything to disk.
7
+ * 3. Machine-local fields reflect the `projectRoot` argument.
8
+ * 4. Different `projectRoot` values produce distinguishable output.
9
+ * 5. `regenerateAllJson` returns all three files.
10
+ *
11
+ * @task T352
12
+ * @epic T311
13
+ */
14
+
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
19
+ import {
20
+ regenerateAllJson,
21
+ regenerateConfigJson,
22
+ regenerateProjectContextJson,
23
+ regenerateProjectInfoJson,
24
+ } from '../regenerators.js';
25
+
26
+ describe('T352 regenerators (dry-run init JSON generators)', () => {
27
+ let tmpRoot: string;
28
+
29
+ beforeEach(() => {
30
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t352-'));
31
+ });
32
+
33
+ afterEach(() => {
34
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
35
+ });
36
+
37
+ // ── regenerateConfigJson ────────────────────────────────────────────
38
+
39
+ describe('regenerateConfigJson', () => {
40
+ it('returns filename="config.json"', () => {
41
+ const result = regenerateConfigJson(tmpRoot);
42
+ expect(result.filename).toBe('config.json');
43
+ });
44
+
45
+ it('returns a non-null object as content', () => {
46
+ const result = regenerateConfigJson(tmpRoot);
47
+ expect(result.content).toBeTypeOf('object');
48
+ expect(result.content).not.toBeNull();
49
+ });
50
+
51
+ it('does NOT write config.json to disk', () => {
52
+ regenerateConfigJson(tmpRoot);
53
+ expect(fs.existsSync(path.join(tmpRoot, '.cleo', 'config.json'))).toBe(false);
54
+ expect(fs.existsSync(path.join(tmpRoot, '.cleo'))).toBe(false);
55
+ });
56
+
57
+ it('content has expected top-level keys from createDefaultConfig', () => {
58
+ const result = regenerateConfigJson(tmpRoot);
59
+ const content = result.content as Record<string, unknown>;
60
+ // These keys mirror createDefaultConfig() in scaffold.ts
61
+ expect(content).toHaveProperty('version');
62
+ expect(content).toHaveProperty('output');
63
+ expect(content).toHaveProperty('backup');
64
+ expect(content).toHaveProperty('hierarchy');
65
+ expect(content).toHaveProperty('session');
66
+ expect(content).toHaveProperty('lifecycle');
67
+ });
68
+
69
+ it('is stable across two calls to the same projectRoot (modulo timestamps)', () => {
70
+ const a = regenerateConfigJson(tmpRoot);
71
+ const b = regenerateConfigJson(tmpRoot);
72
+ // All non-timestamp fields must be identical
73
+ expect(typeof a.content).toBe(typeof b.content);
74
+ expect((a.content as Record<string, unknown>)['version']).toBe(
75
+ (b.content as Record<string, unknown>)['version'],
76
+ );
77
+ });
78
+ });
79
+
80
+ // ── regenerateProjectInfoJson ───────────────────────────────────────
81
+
82
+ describe('regenerateProjectInfoJson', () => {
83
+ it('returns filename="project-info.json"', () => {
84
+ const result = regenerateProjectInfoJson(tmpRoot);
85
+ expect(result.filename).toBe('project-info.json');
86
+ });
87
+
88
+ it('returns a non-null object as content', () => {
89
+ const result = regenerateProjectInfoJson(tmpRoot);
90
+ expect(result.content).toBeTypeOf('object');
91
+ expect(result.content).not.toBeNull();
92
+ });
93
+
94
+ it('does NOT write project-info.json to disk', () => {
95
+ regenerateProjectInfoJson(tmpRoot);
96
+ expect(fs.existsSync(path.join(tmpRoot, '.cleo', 'project-info.json'))).toBe(false);
97
+ expect(fs.existsSync(path.join(tmpRoot, '.cleo'))).toBe(false);
98
+ });
99
+
100
+ it('content includes required machine-local fields', () => {
101
+ const result = regenerateProjectInfoJson(tmpRoot);
102
+ const content = result.content as Record<string, unknown>;
103
+ expect(content).toHaveProperty('projectHash');
104
+ expect(content).toHaveProperty('projectId');
105
+ expect(content).toHaveProperty('cleoVersion');
106
+ expect(content).toHaveProperty('lastUpdated');
107
+ expect(content).toHaveProperty('schemas');
108
+ });
109
+
110
+ it('projectHash reflects the resolved projectRoot path', () => {
111
+ const result = regenerateProjectInfoJson(tmpRoot);
112
+ const content = result.content as Record<string, unknown>;
113
+ // projectHash must be a non-empty string (SHA-256 prefix)
114
+ expect(typeof content['projectHash']).toBe('string');
115
+ expect((content['projectHash'] as string).length).toBeGreaterThan(0);
116
+ });
117
+
118
+ it('produces different projectHash for different projectRoots', () => {
119
+ const root2 = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t352-other-'));
120
+ try {
121
+ const a = regenerateProjectInfoJson(tmpRoot);
122
+ const b = regenerateProjectInfoJson(root2);
123
+ expect((a.content as Record<string, unknown>)['projectHash']).not.toBe(
124
+ (b.content as Record<string, unknown>)['projectHash'],
125
+ );
126
+ } finally {
127
+ fs.rmSync(root2, { recursive: true, force: true });
128
+ }
129
+ });
130
+
131
+ it('schemas block contains config, sqlite, and projectContext keys', () => {
132
+ const result = regenerateProjectInfoJson(tmpRoot);
133
+ const schemas = (result.content as Record<string, unknown>)['schemas'] as Record<
134
+ string,
135
+ unknown
136
+ >;
137
+ expect(schemas).toHaveProperty('config');
138
+ expect(schemas).toHaveProperty('sqlite');
139
+ expect(schemas).toHaveProperty('projectContext');
140
+ });
141
+ });
142
+
143
+ // ── regenerateProjectContextJson ───────────────────────────────────
144
+
145
+ describe('regenerateProjectContextJson', () => {
146
+ it('returns filename="project-context.json"', () => {
147
+ const result = regenerateProjectContextJson(tmpRoot);
148
+ expect(result.filename).toBe('project-context.json');
149
+ });
150
+
151
+ it('returns a non-null object as content', () => {
152
+ const result = regenerateProjectContextJson(tmpRoot);
153
+ expect(result.content).toBeTypeOf('object');
154
+ expect(result.content).not.toBeNull();
155
+ });
156
+
157
+ it('does NOT write project-context.json to disk', () => {
158
+ regenerateProjectContextJson(tmpRoot);
159
+ expect(fs.existsSync(path.join(tmpRoot, '.cleo', 'project-context.json'))).toBe(false);
160
+ expect(fs.existsSync(path.join(tmpRoot, '.cleo'))).toBe(false);
161
+ });
162
+
163
+ it('content has required schema fields from detectProjectType', () => {
164
+ const result = regenerateProjectContextJson(tmpRoot);
165
+ const content = result.content as Record<string, unknown>;
166
+ expect(content).toHaveProperty('schemaVersion');
167
+ expect(content).toHaveProperty('detectedAt');
168
+ expect(content).toHaveProperty('projectTypes');
169
+ expect(content).toHaveProperty('monorepo');
170
+ });
171
+
172
+ it('detectedAt is a valid ISO timestamp', () => {
173
+ const result = regenerateProjectContextJson(tmpRoot);
174
+ const content = result.content as Record<string, unknown>;
175
+ const detectedAt = content['detectedAt'] as string;
176
+ expect(typeof detectedAt).toBe('string');
177
+ const parsed = new Date(detectedAt);
178
+ expect(Number.isNaN(parsed.getTime())).toBe(false);
179
+ });
180
+ });
181
+
182
+ // ── regenerateAllJson ───────────────────────────────────────────────
183
+
184
+ describe('regenerateAllJson', () => {
185
+ it('returns all three files', () => {
186
+ const all = regenerateAllJson(tmpRoot);
187
+ expect(all.config.filename).toBe('config.json');
188
+ expect(all.projectInfo.filename).toBe('project-info.json');
189
+ expect(all.projectContext.filename).toBe('project-context.json');
190
+ });
191
+
192
+ it('does NOT write any files to disk', () => {
193
+ regenerateAllJson(tmpRoot);
194
+ expect(fs.existsSync(path.join(tmpRoot, '.cleo'))).toBe(false);
195
+ });
196
+
197
+ it('all three content values are non-null objects', () => {
198
+ const all = regenerateAllJson(tmpRoot);
199
+ for (const file of [all.config, all.projectInfo, all.projectContext]) {
200
+ expect(file.content).toBeTypeOf('object');
201
+ expect(file.content).not.toBeNull();
202
+ }
203
+ });
204
+ });
205
+
206
+ // ── Cross-projectRoot differentiation ──────────────────────────────
207
+
208
+ describe('cross-projectRoot differentiation', () => {
209
+ it('regenerated project-info differs across different projectRoots', () => {
210
+ const root2 = fs.mkdtempSync(path.join(os.tmpdir(), 'cleo-t352-other-'));
211
+ try {
212
+ const a = regenerateProjectInfoJson(tmpRoot);
213
+ const b = regenerateProjectInfoJson(root2);
214
+ // At minimum the projectHash must differ (different paths)
215
+ expect(JSON.stringify(a.content)).not.toBe(JSON.stringify(b.content));
216
+ } finally {
217
+ fs.rmSync(root2, { recursive: true, force: true });
218
+ }
219
+ });
220
+ });
221
+
222
+ // ── Stability / shape ───────────────────────────────────────────────
223
+
224
+ describe('stability', () => {
225
+ it('config content has the same type shape across two calls', () => {
226
+ const a = regenerateConfigJson(tmpRoot);
227
+ const b = regenerateConfigJson(tmpRoot);
228
+ expect(typeof a.content).toBe(typeof b.content);
229
+ expect(Object.keys(a.content as Record<string, unknown>).sort()).toEqual(
230
+ Object.keys(b.content as Record<string, unknown>).sort(),
231
+ );
232
+ });
233
+ });
234
+ });