@caperjs/core 0.5.2 → 0.6.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 (39) hide show
  1. package/build/assetpack.mjs +6 -0
  2. package/build/plugins/assetTypes.mjs +7 -2
  3. package/build/plugins/caperConfig.mjs +3 -0
  4. package/cli/agent.mjs +10 -4
  5. package/cli/doctor.mjs +193 -0
  6. package/cli/doctor.test.mjs +142 -0
  7. package/cli/probe.mjs +368 -0
  8. package/cli/probe.test.mjs +74 -0
  9. package/cli/types.mjs +101 -0
  10. package/cli/types.test.mjs +58 -0
  11. package/cli.mjs +8 -0
  12. package/extras/llms.txt +21 -7
  13. package/extras/skills/caper/SKILL.md +14 -10
  14. package/lib/{CaptionsPlugin-CDbGZrN9.js → CaptionsPlugin-BtVJiQs_.js} +2 -2
  15. package/lib/{CaptionsPlugin-CDbGZrN9.js.map → CaptionsPlugin-BtVJiQs_.js.map} +1 -1
  16. package/lib/{DataAdapter-BTlNzWqg.js → DataAdapter-BtNz4yi2.js} +2 -2
  17. package/lib/{DataAdapter-BTlNzWqg.js.map → DataAdapter-BtNz4yi2.js.map} +1 -1
  18. package/lib/{DebugRenderer-eSVpon4c.js → DebugRenderer-DK8FAED1.js} +4 -4
  19. package/lib/{DebugRenderer-eSVpon4c.js.map → DebugRenderer-DK8FAED1.js.map} +1 -1
  20. package/lib/{DevToolsPlugin-Ci6r9j-m.js → DevToolsPlugin-BS9idGRi.js} +3 -3
  21. package/lib/{DevToolsPlugin-Ci6r9j-m.js.map → DevToolsPlugin-BS9idGRi.js.map} +1 -1
  22. package/lib/{GSAPPlugin-C8zo-fyt.js → GSAPPlugin-DVDbDVP8.js} +2 -2
  23. package/lib/{GSAPPlugin-C8zo-fyt.js.map → GSAPPlugin-DVDbDVP8.js.map} +1 -1
  24. package/lib/{LayoutPlugin-Cs2lcXKb.js → LayoutPlugin-qQJ5qJud.js} +26 -26
  25. package/lib/{LayoutPlugin-Cs2lcXKb.js.map → LayoutPlugin-qQJ5qJud.js.map} +1 -1
  26. package/lib/{SpinePlugin-CLgSXBQm.js → SpinePlugin-CPNRM2J1.js} +2 -2
  27. package/lib/{SpinePlugin-CLgSXBQm.js.map → SpinePlugin-CPNRM2J1.js.map} +1 -1
  28. package/lib/{StatsPlugin-D_90U3BG.js → StatsPlugin-BHihicBl.js} +2 -2
  29. package/lib/{StatsPlugin-D_90U3BG.js.map → StatsPlugin-BHihicBl.js.map} +1 -1
  30. package/lib/{VoiceOverPlugin-CAE1dtOw.js → VoiceOverPlugin-BdTHxgku.js} +2 -2
  31. package/lib/{VoiceOverPlugin-CAE1dtOw.js.map → VoiceOverPlugin-BdTHxgku.js.map} +1 -1
  32. package/lib/caper.mjs +5 -5
  33. package/lib/caper.mjs.map +1 -1
  34. package/lib/{const-BTDpIdRO.js → const-BQItSP39.js} +2 -2
  35. package/lib/{const-BTDpIdRO.js.map → const-BQItSP39.js.map} +1 -1
  36. package/lib/{registries-nN_hU44y.js → registries-DlqHRO90.js} +16 -16
  37. package/lib/{registries-nN_hU44y.js.map → registries-DlqHRO90.js.map} +1 -1
  38. package/package.json +3 -3
  39. package/src/version.ts +2 -2
@@ -302,6 +302,12 @@ export function assetpackPlugin(manifestUrl = defaultManifestUrl, pixiPipesConfi
302
302
 
303
303
  return {
304
304
  name: 'vite-plugin-assetpack',
305
+ api: {
306
+ runOnce: async () => {
307
+ await getConfig();
308
+ await new AssetPack(apConfig).run();
309
+ },
310
+ },
305
311
  async configResolved(resolvedConfig) {
306
312
  mode = resolvedConfig.command;
307
313
  // Captured before getConfig() below, which is what consumes it.
@@ -398,8 +398,10 @@ export function assetTypesPlugin(manifestUrl = 'assets.json') {
398
398
  }
399
399
  const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
400
400
  await writeAssetTypes(manifest, path.dirname(manifestPath), path.join(publicDir, 'assets'), root);
401
- logger.info('Caper asset types plugin:: manifest changed, reloading browser...');
402
- viteServer?.ws?.send({ type: 'full-reload' });
401
+ if (viteServer) {
402
+ logger.info('Caper asset types plugin:: manifest changed, reloading browser...');
403
+ viteServer.ws.send({ type: 'full-reload' });
404
+ }
403
405
  } catch (error) {
404
406
  logger.error('Caper asset types plugin:: Error handling manifest change:', error);
405
407
  }
@@ -409,6 +411,9 @@ export function assetTypesPlugin(manifestUrl = 'assets.json') {
409
411
 
410
412
  return {
411
413
  name: 'vite-plugin-asset-types',
414
+ api: {
415
+ generateTypes: () => generate(manifestUrl),
416
+ },
412
417
  configResolved(config) {
413
418
  publicDir = config.publicDir;
414
419
  root = config.root;
@@ -418,6 +418,9 @@ declare module '@caperjs/core' {
418
418
 
419
419
  return {
420
420
  name: 'vite-plugin-caper-config',
421
+ api: {
422
+ generateTypes: () => build('Generating types from caper.config.ts'),
423
+ },
421
424
  configResolved(config) {
422
425
  publicDir = config.publicDir;
423
426
  root = config.root;
package/cli/agent.mjs CHANGED
@@ -3,11 +3,12 @@ import { bgRed, bold, cyan, green, white } from 'kleur/colors';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { probe } from './probe.mjs';
6
7
 
7
8
  /**
8
- * `caper agent init` installs the shipped `caper` agent skill into the
9
- * current app and upserts a marker-delimited pointer block into the app's
10
- * agent context file (`AGENTS.md` or `CLAUDE.md`).
9
+ * `caper agent <subcommand>` — `init` installs the shipped `caper` agent skill
10
+ * into the current app; `probe` drives a running Caper app through the
11
+ * Playwright automation bridge.
11
12
  */
12
13
 
13
14
  const START_MARKER = '<!-- caper:agent-start -->';
@@ -100,7 +101,12 @@ export async function agent(args) {
100
101
  return;
101
102
  }
102
103
 
104
+ if (args[0] === 'probe') {
105
+ await probe(args.slice(1));
106
+ return;
107
+ }
108
+
103
109
  const subcommand = args[0] ?? '(none)';
104
- console.error(bold(bgRed(white(`Unknown agent command: "${subcommand}". Please use "init".`))));
110
+ console.error(bold(bgRed(white(`Unknown agent command: "${subcommand}". Please use "init" or "probe".`))));
105
111
  process.exit(1);
106
112
  }
package/cli/doctor.mjs ADDED
@@ -0,0 +1,193 @@
1
+ import { dim, green, red, yellow } from 'kleur/colors';
2
+
3
+ import { execFile } from 'node:child_process';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ /**
9
+ * `caper doctor` — one-shot health report for a Caper app.
10
+ *
11
+ * Answers the recurring agent questions: which caper is active, is it current,
12
+ * are generated types fresh, and are the agent pointers installed.
13
+ */
14
+
15
+ const START_MARKER = '<!-- caper:agent-start -->';
16
+ const END_MARKER = '<!-- caper:agent-end -->';
17
+
18
+ const readInstalledVersion = () => {
19
+ const url = new URL('../package.json', import.meta.url);
20
+ const pkgPath = url.protocol === 'file:' ? fileURLToPath(url.href) : path.resolve(process.cwd(), 'package.json');
21
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf-8')).version;
22
+ };
23
+
24
+ const rel = (cwd, p) => {
25
+ const r = path.relative(cwd, p);
26
+ return r.startsWith('..') ? r : `.${path.sep}${r}`;
27
+ };
28
+
29
+ const newestMtime = (dirOrFile) => {
30
+ if (!fs.existsSync(dirOrFile)) return null;
31
+ const stat = fs.statSync(dirOrFile);
32
+ if (stat.isFile()) return stat.mtime;
33
+ if (!stat.isDirectory()) return null;
34
+ let newest = null;
35
+ for (const entry of fs.readdirSync(dirOrFile)) {
36
+ if (entry === 'node_modules' || entry === '.git') continue;
37
+ const childNewest = newestMtime(path.join(dirOrFile, entry));
38
+ if (childNewest && (!newest || childNewest > newest)) newest = childNewest;
39
+ }
40
+ return newest;
41
+ };
42
+
43
+ const compareVersions = (a, b) => {
44
+ const ap = a.split('.').map(Number);
45
+ const bp = b.split('.').map(Number);
46
+ for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
47
+ const av = ap[i] ?? 0;
48
+ const bv = bp[i] ?? 0;
49
+ if (av < bv) return -1;
50
+ if (av > bv) return 1;
51
+ }
52
+ return 0;
53
+ };
54
+
55
+ const npmLatestVersion = (pkg, timeoutMs = 5000) =>
56
+ new Promise((resolve) => {
57
+ const timer = setTimeout(() => resolve(null), timeoutMs);
58
+ execFile('npm', ['view', pkg, 'version'], { timeout: timeoutMs }, (err, stdout) => {
59
+ clearTimeout(timer);
60
+ resolve(err || !stdout ? null : stdout.trim());
61
+ });
62
+ });
63
+
64
+ const push = (checks, id, status, label, hint) => {
65
+ checks.push({ id, status, label, ...(hint ? { hint } : {}) });
66
+ };
67
+
68
+ export async function runChecks(cwd, { online = true } = {}) {
69
+ const checks = [];
70
+ const installedVersion = readInstalledVersion();
71
+ const nodeModulesCaper = path.join(cwd, 'node_modules/@caperjs/core');
72
+
73
+ push(checks, 'version', 'ok', `@caperjs/core ${installedVersion}`);
74
+
75
+ if (online) {
76
+ const latest = await npmLatestVersion('@caperjs/core');
77
+ if (!latest) push(checks, 'npm', 'warn', 'latest on npm', 'lookup failed (offline?)');
78
+ else if (compareVersions(installedVersion, latest) < 0) {
79
+ push(checks, 'npm', 'warn', `latest on npm ${latest}`, 'pnpm update @caperjs/core@latest');
80
+ } else push(checks, 'npm', 'ok', `latest on npm ${latest}`);
81
+ }
82
+
83
+ if (!fs.existsSync(nodeModulesCaper)) {
84
+ push(checks, 'link', 'fail', '@caperjs/core not in node_modules', 'pnpm install');
85
+ } else {
86
+ let real;
87
+ try {
88
+ real = fs.realpathSync(nodeModulesCaper);
89
+ } catch {
90
+ push(checks, 'link', 'fail', 'node_modules/@caperjs/core is broken', 'pnpm install');
91
+ }
92
+ if (real) {
93
+ if (real.startsWith(path.join(cwd, 'node_modules') + path.sep)) {
94
+ push(checks, 'link', 'ok', 'registry build');
95
+ } else {
96
+ const buildFile = path.join(real, 'lib/caper.mjs');
97
+ const label = `linked from ${rel(cwd, real)}`;
98
+ if (!fs.existsSync(buildFile)) {
99
+ push(checks, 'link', 'fail', label, 'pnpm build in linked package');
100
+ } else {
101
+ const srcMtime = newestMtime(path.join(real, 'src'));
102
+ const stale = srcMtime && srcMtime > fs.statSync(buildFile).mtime;
103
+ push(checks, 'link', stale ? 'warn' : 'ok', label, stale ? 'engine source edited after last build; run pnpm build there' : undefined);
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ const appTypesFile = path.join(cwd, 'src/types/caper-app.d.ts');
110
+ if (!fs.existsSync(appTypesFile)) {
111
+ push(checks, 'app-types', 'fail', 'generated app types missing', 'npx caper types');
112
+ } else {
113
+ const sources = ['caper.config.ts', 'src/scenes', 'src/plugins', 'src/popups', 'src/entities', 'src/ui', 'src/locales'].map((s) => path.join(cwd, s));
114
+ const newestSource = sources.reduce((best, s) => {
115
+ const m = newestMtime(s);
116
+ return m && (!best || m > best) ? m : best;
117
+ }, null);
118
+ const stale = newestSource && newestSource > fs.statSync(appTypesFile).mtime;
119
+ push(checks, 'app-types', stale ? 'warn' : 'ok', stale ? 'generated app types stale' : 'generated app types fresh', stale ? 'npx caper types (or restart dev server)' : undefined);
120
+ }
121
+
122
+ const assetTypesFile = path.join(cwd, 'src/types/caper-assets.d.ts');
123
+ const assetsManifest = path.join(cwd, 'public/assets/assets.json');
124
+ if (fs.existsSync(assetTypesFile)) push(checks, 'asset-types', 'ok', 'generated asset types present');
125
+ else push(checks, 'asset-types', 'warn', 'generated asset types missing', 'npx caper types (app may have assets: false)');
126
+ if (fs.existsSync(assetsManifest)) push(checks, 'asset-manifest', 'ok', 'asset manifest present');
127
+ else push(checks, 'asset-manifest', 'warn', 'asset manifest missing', 'asset pipeline has not run; npx caper types or pnpm dev once');
128
+
129
+ const agentContext = ['AGENTS.md', 'CLAUDE.md'].find((name) => fs.existsSync(path.join(cwd, name)));
130
+ if (!agentContext) {
131
+ push(checks, 'agent', 'warn', 'agent context missing', 'npx caper agent init');
132
+ } else {
133
+ const contents = fs.readFileSync(path.join(cwd, agentContext), 'utf-8');
134
+ const start = contents.indexOf(START_MARKER);
135
+ const end = contents.indexOf(END_MARKER);
136
+ if (start === -1 || end === -1 || end <= start) {
137
+ push(checks, 'agent', 'warn', 'agent pointers not installed', 'npx caper agent init');
138
+ } else {
139
+ const block = contents.slice(start, end + END_MARKER.length);
140
+ const versionMatch = block.match(/@caperjs\/core@(\d+\.\d+\.\d+)/);
141
+ const skillMatch = block.match(/load the `caper` skill at `([^`]+)`/);
142
+ const hints = [];
143
+ if (versionMatch && versionMatch[1] !== installedVersion) {
144
+ hints.push(`pointer is @caperjs/core@${versionMatch[1]}; re-run npx caper agent init`);
145
+ }
146
+ if (skillMatch) {
147
+ if (!fs.existsSync(path.join(cwd, skillMatch[1].replace(/\//g, path.sep)))) {
148
+ hints.push(`skill file missing at ${skillMatch[1]}; npx caper agent init`);
149
+ }
150
+ } else hints.push('skill path not found in pointer block; npx caper agent init');
151
+ push(checks, 'agent', hints.length ? 'warn' : 'ok', 'agent pointers', hints.length ? hints.join('; ') : undefined);
152
+ }
153
+ }
154
+
155
+ const peerDeps = ['pixi.js', 'gsap', '@pixi/sound', 'vite'];
156
+ const required = new Set(['pixi.js', 'vite']);
157
+ let peerStatus = 'ok';
158
+ // Direct lookup rather than require.resolve: pixi.js's `exports` map does not
159
+ // expose ./package.json, so resolve() throws even when it is installed.
160
+ const peerResults = peerDeps.map((pkg) => {
161
+ try {
162
+ const pkgJsonPath = path.join(cwd, 'node_modules', pkg, 'package.json');
163
+ return `${pkg}@${JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')).version}`;
164
+ } catch {
165
+ if (required.has(pkg)) peerStatus = 'fail';
166
+ else if (peerStatus === 'ok') peerStatus = 'warn';
167
+ return `${pkg} missing`;
168
+ }
169
+ });
170
+ push(checks, 'peers', peerStatus, `peer deps: ${peerResults.join(', ')}`, peerStatus !== 'ok' ? 'pnpm install' : undefined);
171
+
172
+ const caches = ['.assetpack', '.cache', 'dist'].filter((name) => fs.existsSync(path.join(cwd, name)));
173
+ push(checks, 'caches', 'ok', `caches${caches.length ? `: ${caches.join(', ')}` : ' clean'}`, caches.length ? 'rm -rf them on weird asset/name mismatches' : undefined);
174
+
175
+ return checks;
176
+ }
177
+
178
+ export async function doctor(args) {
179
+ const offline = args.includes('--offline');
180
+ const json = args.includes('--json');
181
+ const checks = await runChecks(process.cwd(), { online: !offline });
182
+
183
+ if (json) {
184
+ console.log(JSON.stringify(checks, null, 2));
185
+ } else {
186
+ for (const check of checks) {
187
+ const symbol = check.status === 'ok' ? green('✓') : check.status === 'warn' ? yellow('⚠') : red('✗');
188
+ console.log(`${symbol} ${check.label}${check.hint ? ` ${dim(check.hint)}` : ''}`);
189
+ }
190
+ }
191
+
192
+ if (checks.some((c) => c.status === 'fail')) process.exit(1);
193
+ }
@@ -0,0 +1,142 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import { agentInit } from './agent.mjs';
7
+ import { runChecks } from './doctor.mjs';
8
+
9
+ const START_MARKER = '<!-- caper:agent-start -->';
10
+ const END_MARKER = '<!-- caper:agent-end -->';
11
+
12
+ const installedVersion = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), 'package.json'), 'utf-8')).version;
13
+
14
+ let tempDir = null;
15
+
16
+ afterEach(() => {
17
+ if (tempDir && fs.existsSync(tempDir)) {
18
+ fs.rmSync(tempDir, { recursive: true, force: true });
19
+ }
20
+ tempDir = null;
21
+ });
22
+
23
+ function makeTempDir() {
24
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'caper-doctor-'));
25
+ return tempDir;
26
+ }
27
+
28
+ function setMtime(file, secondsAgo) {
29
+ const t = Date.now() / 1000 - secondsAgo;
30
+ fs.utimesSync(file, t, t);
31
+ }
32
+
33
+ function find(checks, id) {
34
+ return checks.find((c) => c.id === id);
35
+ }
36
+
37
+ describe('runChecks', () => {
38
+ it('reports failures/warnings for an empty app directory', async () => {
39
+ const cwd = makeTempDir();
40
+
41
+ const checks = await runChecks(cwd, { online: false });
42
+
43
+ expect(find(checks, 'version').status).toBe('ok');
44
+ expect(find(checks, 'link').status).toBe('fail');
45
+ expect(find(checks, 'app-types').status).toBe('fail');
46
+ expect(find(checks, 'agent').status).toBe('warn');
47
+ expect(find(checks, 'asset-types').status).toBe('warn');
48
+ expect(find(checks, 'asset-manifest').status).toBe('warn');
49
+ expect(find(checks, 'caches').status).toBe('ok');
50
+ });
51
+
52
+ it('marks app types ok when fresh and warns after a newer source file', async () => {
53
+ const cwd = makeTempDir();
54
+
55
+ fs.mkdirSync(path.join(cwd, 'src/types'), { recursive: true });
56
+ fs.writeFileSync(path.join(cwd, 'caper.config.ts'), '// config', 'utf-8');
57
+ fs.writeFileSync(path.join(cwd, 'src/types/caper-app.d.ts'), '// types', 'utf-8');
58
+ setMtime(path.join(cwd, 'caper.config.ts'), 100);
59
+ setMtime(path.join(cwd, 'src/types/caper-app.d.ts'), 50);
60
+
61
+ const first = await runChecks(cwd, { online: false });
62
+ expect(find(first, 'app-types').status).toBe('ok');
63
+
64
+ fs.mkdirSync(path.join(cwd, 'src/scenes'), { recursive: true });
65
+ fs.writeFileSync(path.join(cwd, 'src/scenes/Example.ts'), '// scene', 'utf-8');
66
+ setMtime(path.join(cwd, 'src/scenes/Example.ts'), 10);
67
+
68
+ const second = await runChecks(cwd, { online: false });
69
+ expect(find(second, 'app-types').status).toBe('warn');
70
+ });
71
+
72
+ it('warns when the agent pointer version does not match the installed version', async () => {
73
+ const cwd = makeTempDir();
74
+
75
+ const block = `${START_MARKER}
76
+ ## Caper agent pointers
77
+
78
+ This app runs on \`@caperjs/core@0.0.1\`.
79
+
80
+ - Before engine-facing work, load the \`caper\` skill at \`.claude/skills/caper/SKILL.md\`.
81
+ ${END_MARKER}`;
82
+ fs.writeFileSync(path.join(cwd, 'AGENTS.md'), block, 'utf-8');
83
+ fs.mkdirSync(path.join(cwd, '.claude/skills/caper'), { recursive: true });
84
+ fs.writeFileSync(path.join(cwd, '.claude/skills/caper/SKILL.md'), '# skill', 'utf-8');
85
+
86
+ const checks = await runChecks(cwd, { online: false });
87
+ const agent = find(checks, 'agent');
88
+
89
+ expect(agent.status).toBe('warn');
90
+ expect(agent.hint).toContain('@caperjs/core@0.0.1');
91
+ });
92
+
93
+ it('reports agent pointers ok when the block and skill are current', async () => {
94
+ const cwd = makeTempDir();
95
+
96
+ await agentInit(cwd);
97
+
98
+ const checks = await runChecks(cwd, { online: false });
99
+ expect(find(checks, 'agent').status).toBe('ok');
100
+ });
101
+
102
+ it('reports a linked package missing its build', async () => {
103
+ const cwd = makeTempDir();
104
+ const checkout = path.join(cwd, 'checkout');
105
+ fs.mkdirSync(path.join(checkout, 'src'), { recursive: true });
106
+ fs.writeFileSync(path.join(checkout, 'src/a.ts'), '// a', 'utf-8');
107
+
108
+ const linkPath = path.join(cwd, 'node_modules/@caperjs/core');
109
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
110
+ fs.symlinkSync(checkout, linkPath, 'dir');
111
+
112
+ const checks = await runChecks(cwd, { online: false });
113
+ const link = find(checks, 'link');
114
+
115
+ expect(link.status).toBe('fail');
116
+ expect(link.label).toContain('linked from');
117
+ expect(link.hint).toContain('pnpm build');
118
+ });
119
+
120
+ it('reports a linked package ok when the build is newer than source', async () => {
121
+ const cwd = makeTempDir();
122
+ const checkout = path.join(cwd, 'checkout');
123
+
124
+ fs.mkdirSync(path.join(checkout, 'src'), { recursive: true });
125
+ fs.writeFileSync(path.join(checkout, 'src/a.ts'), '// a', 'utf-8');
126
+ setMtime(path.join(checkout, 'src/a.ts'), 50);
127
+
128
+ fs.mkdirSync(path.join(checkout, 'lib'), { recursive: true });
129
+ fs.writeFileSync(path.join(checkout, 'lib/caper.mjs'), '// build', 'utf-8');
130
+ setMtime(path.join(checkout, 'lib/caper.mjs'), 10);
131
+
132
+ const linkPath = path.join(cwd, 'node_modules/@caperjs/core');
133
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
134
+ fs.symlinkSync(checkout, linkPath, 'dir');
135
+
136
+ const checks = await runChecks(cwd, { online: false });
137
+ const link = find(checks, 'link');
138
+
139
+ expect(link.status).toBe('ok');
140
+ expect(link.label).toContain('linked from');
141
+ });
142
+ });