@grafana/create-plugin 7.13.0 → 7.15.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/codemods/additions/additions.js +12 -0
  3. package/dist/codemods/additions/prompts/docs-content.md +131 -0
  4. package/dist/codemods/additions/scripts/docs-content.js +30 -0
  5. package/dist/codemods/additions/scripts/docs.js +468 -0
  6. package/dist/codemods/additions/scripts/experimental-app-sdk.js +15 -14
  7. package/dist/codemods/utils.agents.js +64 -0
  8. package/dist/codemods/utils.js +40 -7
  9. package/dist/codemods/utils.workflows.js +94 -0
  10. package/dist/utils/utils.cli.js +3 -2
  11. package/dist/utils/utils.handlebars.js +9 -2
  12. package/package.json +1 -1
  13. package/src/codemods/additions/additions.ts +12 -0
  14. package/src/codemods/additions/prompts/docs-content.md +131 -0
  15. package/src/codemods/additions/scripts/docs-content.test.ts +103 -0
  16. package/src/codemods/additions/scripts/docs-content.ts +32 -0
  17. package/src/codemods/additions/scripts/docs.test.ts +1766 -0
  18. package/src/codemods/additions/scripts/docs.ts +697 -0
  19. package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +4 -3
  20. package/src/codemods/additions/scripts/experimental-app-sdk.ts +15 -15
  21. package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
  22. package/src/codemods/utils.agents.test.ts +140 -0
  23. package/src/codemods/utils.agents.ts +81 -0
  24. package/src/codemods/utils.test.ts +23 -0
  25. package/src/codemods/utils.ts +29 -7
  26. package/src/codemods/utils.workflows.test.ts +153 -0
  27. package/src/codemods/utils.workflows.ts +140 -0
  28. package/src/utils/utils.cli.ts +2 -1
  29. package/src/utils/utils.handlebars.ts +9 -0
  30. package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
  31. package/templates/docs/app/docs/AGENTS.md +127 -0
  32. package/templates/docs/app/docs/CLAUDE.md +1 -0
  33. package/templates/docs/app/docs/README.md +20 -0
  34. package/templates/docs/app/docs/configure.md +65 -0
  35. package/templates/docs/app/docs/dashboards.md +29 -0
  36. package/templates/docs/app/docs/index.md +70 -0
  37. package/templates/docs/app/docs/permissions.md +45 -0
  38. package/templates/docs/app/docs/troubleshooting.md +65 -0
  39. package/templates/docs/app/feature-page.md +26 -0
  40. package/templates/docs/app/features-index.md +12 -0
  41. package/templates/docs/common/workflows/validate-docs.yml +52 -0
  42. package/templates/docs/datasource/docs/AGENTS.md +123 -0
  43. package/templates/docs/datasource/docs/CLAUDE.md +1 -0
  44. package/templates/docs/datasource/docs/README.md +20 -0
  45. package/templates/docs/datasource/docs/alerting.md +56 -0
  46. package/templates/docs/datasource/docs/annotations.md +68 -0
  47. package/templates/docs/datasource/docs/configure.md +114 -0
  48. package/templates/docs/datasource/docs/dashboards.md +28 -0
  49. package/templates/docs/datasource/docs/index.md +40 -0
  50. package/templates/docs/datasource/docs/query-editor.md +72 -0
  51. package/templates/docs/datasource/docs/template-variables.md +69 -0
  52. package/templates/docs/datasource/docs/troubleshooting.md +73 -0
  53. package/templates/docs/panel/docs/AGENTS.md +113 -0
  54. package/templates/docs/panel/docs/CLAUDE.md +1 -0
  55. package/templates/docs/panel/docs/README.md +20 -0
  56. package/templates/docs/panel/docs/data-formats.md +27 -0
  57. package/templates/docs/panel/docs/examples.md +35 -0
  58. package/templates/docs/panel/docs/index.md +26 -0
  59. package/templates/docs/panel/docs/options.md +53 -0
  60. package/templates/docs/panel/docs/troubleshooting.md +35 -0
@@ -0,0 +1,1766 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { stripVTControlCharacters } from 'node:util';
5
+ import * as v from 'valibot';
6
+ import { parse as parseYaml } from 'yaml';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import { Context } from '../../context.js';
9
+ import { flushChanges } from '../../utils.js';
10
+ import { getPackageManagerWithFallback } from '../../../utils/utils.packageManager.js';
11
+ import docs, { assertSupportedPluginType, schema, setupDocsScaffolding } from './docs.js';
12
+
13
+ // capture the real existsSync before mocking so we can delegate to it in beforeEach
14
+ const { existsSync: realExistsSync } = await vi.importActual<typeof import('node:fs')>('node:fs');
15
+
16
+ vi.mock('node:fs', async (importOriginal) => {
17
+ const mod = await importOriginal<typeof import('node:fs')>();
18
+ return {
19
+ ...mod,
20
+ existsSync: vi.fn().mockImplementation(mod.existsSync),
21
+ };
22
+ });
23
+
24
+ vi.mock(import('../../../utils/utils.packageManager.js'), async (importOriginal) => ({
25
+ ...(await importOriginal()),
26
+ getPackageManagerWithFallback: vi.fn(() => ({ packageManagerName: 'npm', packageManagerVersion: '10.9.0' })),
27
+ }));
28
+
29
+ function makeContext(
30
+ pluginJson: Record<string, unknown> = { type: 'panel', id: 'acme-test-panel', name: 'My Plugin' }
31
+ ): Context {
32
+ const context = new Context('/virtual');
33
+ context.addFile('src/plugin.json', JSON.stringify(pluginJson));
34
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
35
+ context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n');
36
+ return context;
37
+ }
38
+
39
+ // a plugin.json per type with every feature that adds a page turned on, so every template is rendered
40
+ const FULL_PLUGIN_JSON: Record<string, Record<string, unknown>> = {
41
+ panel: { type: 'panel', id: 'acme-test-panel', name: 'My Plugin' },
42
+ datasource: {
43
+ type: 'datasource',
44
+ id: 'acme-test-datasource',
45
+ name: 'My Plugin',
46
+ backend: true,
47
+ annotations: true,
48
+ alerting: true,
49
+ includes: [{ type: 'dashboard', name: 'Overview', path: 'dashboards/overview.json' }],
50
+ },
51
+ app: {
52
+ type: 'app',
53
+ id: 'acme-test-app',
54
+ name: 'My Plugin',
55
+ backend: true,
56
+ roles: [],
57
+ includes: [
58
+ { type: 'page', name: 'Overview', path: '/a/%PLUGIN_ID%/overview', role: 'Editor', defaultNav: true },
59
+ { type: 'page', name: 'Configuration', path: '/plugins/%PLUGIN_ID%', role: 'Admin' },
60
+ { type: 'dashboard', name: 'Overview', path: 'dashboards/overview.json' },
61
+ ],
62
+ },
63
+ };
64
+ const PAGES_BY_TYPE: Record<string, string[]> = {
65
+ panel: ['index', 'options', 'data-formats', 'examples', 'troubleshooting'],
66
+ datasource: [
67
+ 'index',
68
+ 'configure',
69
+ 'query-editor',
70
+ 'template-variables',
71
+ 'annotations',
72
+ 'alerting',
73
+ 'dashboards',
74
+ 'troubleshooting',
75
+ ],
76
+ app: ['index', 'configure', 'overview', 'dashboards', 'permissions', 'troubleshooting'],
77
+ };
78
+ const PLUGIN_TYPES = Object.keys(PAGES_BY_TYPE);
79
+
80
+ // scaffolds once and writes it to disk, so the re-run sees a plugin that is already fully set up
81
+ function scaffoldThenRerun(
82
+ pluginJson: Record<string, unknown>,
83
+ addFeature: Record<string, unknown>,
84
+ docsFile?: string,
85
+ deleteFiles: string[] = []
86
+ ) {
87
+ const root = mkdtempSync(join(tmpdir(), 'docs-rerun-feature-'));
88
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ scripts: {}, devDependencies: {} }));
89
+ mkdirSync(join(root, 'src'), { recursive: true });
90
+ writeFileSync(join(root, 'src', 'plugin.json'), JSON.stringify(pluginJson));
91
+ const first = new Context(root);
92
+ docs(first, { docsPath: 'docs' });
93
+ flushChanges(first);
94
+ for (const file of deleteFiles) {
95
+ rmSync(join(root, 'docs', file));
96
+ }
97
+ writeFileSync(join(root, 'src', 'plugin.json'), JSON.stringify({ ...pluginJson, docsPath: 'docs', ...addFeature }));
98
+ if (docsFile) {
99
+ mkdirSync(join(root, 'docs', docsFile, '..'), { recursive: true });
100
+ writeFileSync(join(root, 'docs', docsFile), '# Mine\n');
101
+ }
102
+
103
+ const context = new Context(root);
104
+ docs(context, { docsPath: 'docs' });
105
+ rmSync(root, { recursive: true, force: true });
106
+ return { context, body: stripVTControlCharacters((context.getMessage()?.body ?? []).join('\n')) };
107
+ }
108
+
109
+ describe('docs scaffolding', () => {
110
+ const tempDirs: string[] = [];
111
+
112
+ // build a synthetic plugin-type template folder with the given docs template file contents
113
+ function makeTemplateDir(files: Record<string, string>): string {
114
+ const dir = mkdtempSync(join(tmpdir(), 'docs-templates-'));
115
+ tempDirs.push(dir);
116
+ mkdirSync(join(dir, 'docs'), { recursive: true });
117
+ for (const [relPath, content] of Object.entries(files)) {
118
+ const target = join(dir, 'docs', relPath);
119
+ mkdirSync(join(target, '..'), { recursive: true });
120
+ writeFileSync(target, content);
121
+ }
122
+ return dir;
123
+ }
124
+
125
+ // the templates every plugin type shares, mirroring templates/docs/common
126
+ function makeCommonTemplateDir(): string {
127
+ const dir = mkdtempSync(join(tmpdir(), 'docs-common-'));
128
+ tempDirs.push(dir);
129
+ mkdirSync(join(dir, 'workflows'), { recursive: true });
130
+ writeFileSync(join(dir, 'workflows', 'validate-docs.yml'), 'name: Validate documentation\n');
131
+ return dir;
132
+ }
133
+
134
+ beforeEach(() => {
135
+ vi.mocked(existsSync).mockImplementation(realExistsSync);
136
+ });
137
+
138
+ afterEach(() => {
139
+ while (tempDirs.length > 0) {
140
+ const dir = tempDirs.pop();
141
+ if (dir) {
142
+ rmSync(dir, { recursive: true, force: true });
143
+ }
144
+ }
145
+ });
146
+
147
+ function call(context: Context, overrides: { templates?: Record<string, string>; docsPath?: string } = {}): void {
148
+ const templates = overrides.templates ?? { 'index.md': '# {{pluginName}}\n' };
149
+ setupDocsScaffolding({
150
+ context,
151
+ docsPath: overrides.docsPath ?? 'docs',
152
+ templateDir: makeTemplateDir(templates),
153
+ commonTemplateDir: makeCommonTemplateDir(),
154
+ });
155
+ }
156
+
157
+ describe('early exit', () => {
158
+ it('throws if docs directory already exists on disk', () => {
159
+ const root = mkdtempSync(join(tmpdir(), 'docs-exists-'));
160
+ tempDirs.push(root);
161
+ mkdirSync(join(root, 'docs'), { recursive: true });
162
+ const context = new Context(root);
163
+ context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin' }));
164
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
165
+ expect(() => call(context)).toThrow("A directory already exists at 'docs'");
166
+ });
167
+ });
168
+
169
+ it('is idempotent', async () => {
170
+ const context = makeContext();
171
+ // shared across both runs so the second one reaches the skip-existing-file branches
172
+ const templateDir = makeTemplateDir({ 'index.md': '# {{pluginName}}\n' });
173
+ const commonTemplateDir = makeCommonTemplateDir();
174
+ const migrate = async (ctx: Context) =>
175
+ setupDocsScaffolding({ context: ctx, docsPath: 'docs', templateDir, commonTemplateDir });
176
+
177
+ await expect(migrate).toBeIdempotent(context);
178
+ });
179
+
180
+ describe('plugin.json step', () => {
181
+ it('adds docsPath to src/plugin.json', () => {
182
+ const context = makeContext();
183
+ call(context);
184
+ const parsed = JSON.parse(context.getFile('src/plugin.json') ?? '{}');
185
+ expect(parsed.docsPath).toBe('docs');
186
+ });
187
+
188
+ it('throws if src/plugin.json is missing', () => {
189
+ const context = new Context('/virtual');
190
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
191
+ expect(() => call(context)).toThrow('Cannot find src/plugin.json');
192
+ });
193
+
194
+ it('throws if src/plugin.json is not valid JSON', () => {
195
+ const context = new Context('/virtual');
196
+ context.addFile('src/plugin.json', '{ not valid json');
197
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
198
+ expect(() => call(context)).toThrow(/Cannot parse src\/plugin\.json/);
199
+ });
200
+
201
+ it('throws if src/plugin.json is not a JSON object', () => {
202
+ const context = new Context('/virtual');
203
+ context.addFile('src/plugin.json', JSON.stringify(['panel']));
204
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
205
+ expect(() => call(context)).toThrow(/does not contain a JSON object/);
206
+ });
207
+
208
+ it('throws when docsPath is already set to a different value', () => {
209
+ const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin', docsPath: 'custom-docs' });
210
+ expect(() => call(context)).toThrow(/docsPath set to 'custom-docs'.*--docsPath custom-docs/s);
211
+ });
212
+
213
+ it('uses a custom docsPath when specified', () => {
214
+ const context = makeContext();
215
+ call(context, { docsPath: 'my-docs' });
216
+ expect(context.doesFileExist('my-docs/index.md')).toBe(true);
217
+ });
218
+ });
219
+
220
+ describe('devDependency and scripts', () => {
221
+ it('adds @grafana/plugin-docs-cli to devDependencies', () => {
222
+ const context = makeContext();
223
+ call(context);
224
+ const pkg = JSON.parse(context.getFile('package.json') ?? '{}');
225
+ expect(pkg.devDependencies?.['@grafana/plugin-docs-cli']).toBe('^0.8.0');
226
+ });
227
+
228
+ it('adds docs:serve and docs:validate scripts, with no separate release script', () => {
229
+ const context = makeContext();
230
+ call(context);
231
+ const pkg = JSON.parse(context.getFile('package.json') ?? '{}');
232
+ expect(pkg.scripts?.['docs:serve']).toBe('plugin-docs-cli serve --port 3001 --reload');
233
+ expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate');
234
+ expect(pkg.scripts?.['docs:validate:release']).toBeUndefined();
235
+ });
236
+
237
+ it('leaves a docs:validate:release script from an earlier scaffold alone', () => {
238
+ const context = new Context('/virtual');
239
+ context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin' }));
240
+ context.addFile(
241
+ 'package.json',
242
+ JSON.stringify({ scripts: { 'docs:validate:release': 'custom-command' }, devDependencies: {} })
243
+ );
244
+ context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n');
245
+ call(context);
246
+ const pkg = JSON.parse(context.getFile('package.json') ?? '{}');
247
+ expect(pkg.scripts?.['docs:validate:release']).toBe('custom-command');
248
+ });
249
+
250
+ it('skips docs:serve if already present', () => {
251
+ const context = new Context('/virtual');
252
+ context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin' }));
253
+ context.addFile(
254
+ 'package.json',
255
+ JSON.stringify({ scripts: { 'docs:serve': 'custom-command' }, devDependencies: {} })
256
+ );
257
+ context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n');
258
+ call(context);
259
+ const pkg = JSON.parse(context.getFile('package.json') ?? '{}');
260
+ expect(pkg.scripts?.['docs:serve']).toBe('custom-command');
261
+ expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate');
262
+ });
263
+ });
264
+
265
+ describe('nested template directories', () => {
266
+ it('copies files nested more than one level deep', () => {
267
+ const context = makeContext();
268
+ call(context, { templates: { 'a/b/c.md': 'deeply nested\n' } });
269
+ expect(context.getFile('docs/a/b/c.md')).toBe('deeply nested\n');
270
+ });
271
+ });
272
+
273
+ describe('template copy', () => {
274
+ it('copies every template file under docs/', () => {
275
+ const context = makeContext();
276
+ call(context, {
277
+ templates: {
278
+ 'index.md': '# Overview\n',
279
+ 'configuration.md': '## Configure\n',
280
+ 'nested/deep.md': 'nested\n',
281
+ },
282
+ });
283
+ expect(context.doesFileExist('docs/index.md')).toBe(true);
284
+ expect(context.doesFileExist('docs/configuration.md')).toBe(true);
285
+ expect(context.doesFileExist('docs/nested/deep.md')).toBe(true);
286
+ });
287
+
288
+ it('interpolates {{pluginName}} in template content', () => {
289
+ const context = makeContext();
290
+ call(context, { templates: { 'index.md': '# {{pluginName}}\n' } });
291
+ expect(context.getFile('docs/index.md')).toContain('My Plugin');
292
+ expect(context.getFile('docs/index.md')).not.toContain('{{pluginName}}');
293
+ });
294
+
295
+ it('skips a file already present in the context', () => {
296
+ // docsPath already set, so this docs/ folder is ours from a previous run, not a
297
+ // foreign one - the folder-exists guard must fall through rather than refuse.
298
+ const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin', docsPath: 'docs' });
299
+ context.addFile('docs/index.md', '# Existing\n');
300
+ call(context, { templates: { 'index.md': '# Replacement\n' } });
301
+ expect(context.getFile('docs/index.md')).toBe('# Existing\n');
302
+ });
303
+
304
+ it('throws if the docs template directory is missing (packaging bug guard)', () => {
305
+ const context = makeContext();
306
+ const dir = mkdtempSync(join(tmpdir(), 'docs-empty-'));
307
+ tempDirs.push(dir);
308
+ // no `docs/` subdirectory created under `dir` - simulates a broken build
309
+ expect(() =>
310
+ setupDocsScaffolding({
311
+ context,
312
+ docsPath: 'docs',
313
+ templateDir: dir,
314
+ commonTemplateDir: makeCommonTemplateDir(),
315
+ })
316
+ ).toThrow(/Cannot find templates at .*docs/);
317
+ });
318
+ });
319
+
320
+ describe('validate-docs workflow', () => {
321
+ it('creates .github/workflows/validate-docs.yml', () => {
322
+ const context = makeContext();
323
+ call(context);
324
+ expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true);
325
+ });
326
+
327
+ it('interpolates {{docsPath}} into the workflow path filters', () => {
328
+ const context = makeContext();
329
+ call(context, { docsPath: 'my-docs' });
330
+ // the synthetic test template has no {{docsPath}} placeholder, so this
331
+ // only proves the substitution doesn't error - the real template is
332
+ // covered by index.test.ts against the shipped workflow content.
333
+ expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true);
334
+ });
335
+
336
+ it('does not overwrite an existing .github/workflows/validate-docs.yml', () => {
337
+ const context = makeContext();
338
+ context.addFile('.github/workflows/validate-docs.yml', 'old content');
339
+ call(context);
340
+ expect(context.getFile('.github/workflows/validate-docs.yml')).toBe('old content');
341
+ });
342
+ });
343
+
344
+ describe('release.yml action bumps', () => {
345
+ function bumped(release: string): string | undefined {
346
+ const context = makeContext();
347
+ context.updateFile('.github/workflows/release.yml', release);
348
+ call(context);
349
+ return context.getFile('.github/workflows/release.yml');
350
+ }
351
+
352
+ it('raises build-plugin to build-plugin/v1.2.0', () => {
353
+ expect(bumped('uses: grafana/plugin-actions/build-plugin@v1.0.2\n')).toBe(
354
+ 'uses: grafana/plugin-actions/build-plugin@build-plugin/v1.2.0\n'
355
+ );
356
+ });
357
+
358
+ it('raises package-plugin to package-plugin/v1.3.3', () => {
359
+ expect(bumped('uses: grafana/plugin-actions/package-plugin@package-plugin/v1.3.2\n')).toBe(
360
+ 'uses: grafana/plugin-actions/package-plugin@package-plugin/v1.3.3\n'
361
+ );
362
+ });
363
+
364
+ it('does not throw when release.yml does not exist', () => {
365
+ const context = new Context('/virtual');
366
+ context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin' }));
367
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
368
+ expect(() => call(context)).not.toThrow();
369
+ expect(context.doesFileExist('.github/workflows/release.yml')).toBe(false);
370
+ });
371
+ });
372
+
373
+ describe('assertSupportedPluginType', () => {
374
+ it('returns the plugin type when it is supported', () => {
375
+ const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'X' });
376
+ expect(assertSupportedPluginType(context)).toBe('panel');
377
+ });
378
+
379
+ it('returns datasource for a data source plugin', () => {
380
+ const context = makeContext({ type: 'datasource', id: 'acme-test-datasource', name: 'X' });
381
+ expect(assertSupportedPluginType(context)).toBe('datasource');
382
+ });
383
+
384
+ it('returns app for an app plugin', () => {
385
+ const context = makeContext({ type: 'app', id: 'acme-test-app', name: 'X' });
386
+ expect(assertSupportedPluginType(context)).toBe('app');
387
+ });
388
+
389
+ it('reports an unset type against the supported list', () => {
390
+ const context = makeContext({ name: 'X' });
391
+ expect(() => assertSupportedPluginType(context)).toThrow(/needs a plugin type of panel.*found 'unset'/s);
392
+ });
393
+
394
+ it('reports an unrecognised type against the supported list', () => {
395
+ const context = makeContext({ type: 'renderer', name: 'X' });
396
+ expect(() => assertSupportedPluginType(context)).toThrow(/needs a plugin type of panel.*found 'renderer'/s);
397
+ });
398
+ });
399
+ });
400
+
401
+ describe('docs codemod', () => {
402
+ describe('validate-docs workflow package manager', () => {
403
+ // exercises the real shipped workflow template, so these assert on generated CI content
404
+ function workflowFor(packageManager: string): string {
405
+ const [packageManagerName, packageManagerVersion] = packageManager.split('@');
406
+ vi.mocked(getPackageManagerWithFallback).mockReturnValueOnce({ packageManagerName, packageManagerVersion });
407
+ const context = new Context('/virtual');
408
+ context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'P' }));
409
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
410
+ docs(context, { docsPath: 'docs' });
411
+ return context.getFile('.github/workflows/validate-docs.yml') ?? '';
412
+ }
413
+
414
+ it('runs validation on every push rather than skipping while docs are unfinished', () => {
415
+ // unfilled stubs are warnings, so CI can check links and frontmatter from the first commit
416
+ // instead of waiting until the docs are done
417
+ const workflow = workflowFor('npm@10.2.3');
418
+ expect(workflow).not.toContain('section-brief');
419
+ expect(workflow).toContain('npm run docs:validate');
420
+ });
421
+
422
+ it('runs the pinned CLI through the docs:validate script rather than fetching the latest', () => {
423
+ const workflow = workflowFor('npm@10.2.3');
424
+ expect(workflow).toContain('npm run docs:validate');
425
+ expect(workflow).not.toContain('npx');
426
+ });
427
+
428
+ it.each([
429
+ ['npm@10.2.3', 'npm ci', 'npm'],
430
+ ['pnpm@9.1.0', 'pnpm install --frozen-lockfile --prefer-offline', 'pnpm'],
431
+ ['yarn@4.1.0', 'yarn install --immutable', 'yarn'],
432
+ ])('uses the %s install command and cache', (packageManager, installCmd, name) => {
433
+ const workflow = workflowFor(packageManager);
434
+ expect(workflow).toContain(`run: ${installCmd}`);
435
+ expect(workflow).toContain(`cache: '${name}'`);
436
+ expect(workflow).toContain(`${name} run docs:validate`);
437
+ });
438
+
439
+ it.each([
440
+ ['npm@10.2.3', 'package-lock.json'],
441
+ ['pnpm@9.1.0', 'pnpm-lock.yaml'],
442
+ ['yarn@4.1.0', 'yarn.lock'],
443
+ ])(
444
+ 'also runs for %s when package.json or %s changes, so a docs CLI bump is validated',
445
+ (packageManager, lockFile) => {
446
+ const { on } = parseYaml(workflowFor(packageManager));
447
+ for (const trigger of [on.push, on.pull_request]) {
448
+ expect(trigger.paths).toEqual(['docs/**', 'src/plugin.json', 'package.json', lockFile]);
449
+ }
450
+ }
451
+ );
452
+
453
+ it('runs docs:validate directly, with no docsPath guard', () => {
454
+ const { jobs } = parseYaml(workflowFor('npm@10.2.3'));
455
+ expect(jobs.validate.steps.at(-1)).toEqual({
456
+ name: 'Validate plugin documentation',
457
+ run: 'npm run docs:validate',
458
+ });
459
+ });
460
+
461
+ it('adds the pnpm setup action only for pnpm', () => {
462
+ expect(workflowFor('pnpm@9.1.0')).toContain('pnpm/action-setup@v6');
463
+ expect(workflowFor('npm@10.2.3')).not.toContain('pnpm/action-setup');
464
+ });
465
+
466
+ it('adds the pnpm setup step right after checkout, the way ci.yml does', () => {
467
+ expect(workflowFor('pnpm@9.1.0')).toContain(
468
+ ' persist-credentials: false\n # pnpm action uses the packageManager field in package.json to\n # understand which version to install.\n - uses: pnpm/action-setup@v6\n - name: Setup Node.js environment\n'
469
+ );
470
+ expect(workflowFor('npm@10.2.3')).toContain(
471
+ ' persist-credentials: false\n - name: Setup Node.js environment\n'
472
+ );
473
+ });
474
+ });
475
+
476
+ describe('re-running on an adopted plugin', () => {
477
+ it('is additive when plugin.json already declares the same docsPath', () => {
478
+ const root = mkdtempSync(join(tmpdir(), 'docs-rerun-'));
479
+ mkdirSync(join(root, 'docs'), { recursive: true });
480
+ writeFileSync(join(root, 'docs', 'index.md'), '# Mine\n');
481
+ const context = new Context(root);
482
+ context.addFile(
483
+ 'src/plugin.json',
484
+ JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'P', docsPath: 'docs' })
485
+ );
486
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
487
+
488
+ docs(context, { docsPath: 'docs' });
489
+
490
+ // the author's page is left untouched, and agent files can still be picked up after a bump
491
+ expect(context.listChanges()['docs/index.md']).toBeUndefined();
492
+ expect(context.doesFileExist('.config/AGENTS/instructions.md')).toBe(true);
493
+ });
494
+
495
+ it('does not bring back a page the author deleted, but still adds missing meta files', () => {
496
+ const root = mkdtempSync(join(tmpdir(), 'docs-rerun-deleted-'));
497
+ mkdirSync(join(root, 'docs'), { recursive: true });
498
+ writeFileSync(join(root, 'docs', 'index.md'), '# Mine\n');
499
+ const context = new Context(root);
500
+ context.addFile(
501
+ 'src/plugin.json',
502
+ JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'P', docsPath: 'docs' })
503
+ );
504
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
505
+
506
+ docs(context, { docsPath: 'docs' });
507
+
508
+ expect(context.doesFileExist('docs/troubleshooting.md')).toBe(false);
509
+ expect(context.doesFileExist('docs/options.md')).toBe(false);
510
+ for (const meta of ['README.md', 'AGENTS.md', 'CLAUDE.md']) {
511
+ expect(context.doesFileExist(`docs/${meta}`), meta).toBe(true);
512
+ }
513
+ });
514
+
515
+ it('scaffolds the pages into an empty docs folder that plugin.json already declares', () => {
516
+ const root = mkdtempSync(join(tmpdir(), 'docs-rerun-empty-'));
517
+ mkdirSync(join(root, 'docs'), { recursive: true });
518
+ writeFileSync(join(root, 'docs', 'README.md'), '# Notes\n');
519
+ const context = new Context(root);
520
+ context.addFile(
521
+ 'src/plugin.json',
522
+ JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'P', docsPath: 'docs' })
523
+ );
524
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
525
+
526
+ docs(context, { docsPath: 'docs' });
527
+
528
+ expect(context.doesFileExist('docs/index.md')).toBe(true);
529
+ expect(context.doesFileExist('docs/troubleshooting.md')).toBe(true);
530
+ });
531
+
532
+ it.each<string | number | null>(['./docs', 'docs/', 'docs/v1.0', null, 5])(
533
+ 'refuses an existing docsPath of %s instead of suggesting a --docsPath the schema rejects',
534
+ (existingDocsPath) => {
535
+ const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'P', docsPath: existingDocsPath });
536
+ expect(() => docs(context, { docsPath: 'catalog-docs' })).toThrow(
537
+ `src/plugin.json has docsPath set to '${existingDocsPath}', which create-plugin add docs can't use.`
538
+ );
539
+ }
540
+ );
541
+
542
+ it('scaffolds into "catalog-docs" when neither --docsPath nor plugin.json sets one', () => {
543
+ const context = makeContext();
544
+ docs(context, {});
545
+ expect(JSON.parse(context.getFile('src/plugin.json') ?? '{}').docsPath).toBe('catalog-docs');
546
+ expect(context.doesFileExist('catalog-docs/index.md')).toBe(true);
547
+ });
548
+
549
+ it('reuses the docsPath in plugin.json when --docsPath is omitted', () => {
550
+ const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'P', docsPath: 'docs' });
551
+ docs(context, {});
552
+ expect(context.doesFileExist('docs/AGENTS.md')).toBe(true);
553
+ expect(context.doesFileExist('catalog-docs/AGENTS.md')).toBe(false);
554
+ });
555
+
556
+ it('counts pages that live only in subfolders, so it does not scaffold stubs next to them', () => {
557
+ const root = mkdtempSync(join(tmpdir(), 'docs-rerun-nested-'));
558
+ mkdirSync(join(root, 'docs', 'options'), { recursive: true });
559
+ writeFileSync(join(root, 'docs', 'options', 'index.md'), '# Options\n');
560
+ const context = new Context(root);
561
+ context.addFile(
562
+ 'src/plugin.json',
563
+ JSON.stringify({ type: 'panel', id: 'acme-p-panel', name: 'P', docsPath: 'docs' })
564
+ );
565
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
566
+
567
+ docs(context, {});
568
+
569
+ expect(context.doesFileExist('docs/index.md')).toBe(false);
570
+ });
571
+
572
+ it.each(['CHANGELOG.md', 'readme.md', 'nested/LICENSE.md', 'node_modules/pkg/index.md', 'dist/index.md'])(
573
+ 'does not count %s as a page, the same way plugin-docs-cli does not',
574
+ (file) => {
575
+ const root = mkdtempSync(join(tmpdir(), 'docs-rerun-meta-'));
576
+ mkdirSync(join(root, 'docs', file, '..'), { recursive: true });
577
+ writeFileSync(join(root, 'docs', file), '# Meta\n');
578
+ const context = new Context(root);
579
+ context.addFile(
580
+ 'src/plugin.json',
581
+ JSON.stringify({ type: 'panel', id: 'acme-p-panel', name: 'P', docsPath: 'docs' })
582
+ );
583
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
584
+
585
+ docs(context, {});
586
+
587
+ expect(context.doesFileExist('docs/index.md')).toBe(true);
588
+ }
589
+ );
590
+
591
+ it('refuses a docs folder that plugin.json knows nothing about', () => {
592
+ const root = mkdtempSync(join(tmpdir(), 'docs-unknown-'));
593
+ mkdirSync(join(root, 'docs'), { recursive: true });
594
+ const context = new Context(root);
595
+ context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'P' }));
596
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
597
+ expect(() => docs(context, { docsPath: 'docs' })).toThrow(/has no docsPath/);
598
+ });
599
+ });
600
+
601
+ describe('plugin.json requirements', () => {
602
+ it('refuses a plugin.json with no id, since the catalog links need it', () => {
603
+ const context = makeContext({ type: 'panel', name: 'P' });
604
+ expect(() => docs(context, {})).toThrow(/has no "id"/);
605
+ });
606
+
607
+ it('refuses an empty id', () => {
608
+ const context = makeContext({ type: 'panel', id: '', name: 'P' });
609
+ expect(() => docs(context, {})).toThrow(/has no "id"/);
610
+ });
611
+
612
+ it('refuses a plugin.json with no name rather than scaffolding docs about "my-plugin"', () => {
613
+ const context = new Context('/virtual');
614
+ context.addFile('src/plugin.json', JSON.stringify({ type: 'panel' }));
615
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
616
+ expect(() => docs(context, { docsPath: 'docs' })).toThrow(/has no "name"/);
617
+ });
618
+
619
+ it('writes a plugin name with special characters as-is, not HTML-escaped', () => {
620
+ const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: "Erik's A & B" });
621
+ docs(context, { docsPath: 'docs' });
622
+ const index = context.getFile('docs/index.md') ?? '';
623
+ expect(index).toContain(`description: "Where to start and what to read next in the Erik's A & B docs."`);
624
+ expect(index).not.toMatch(/&amp;|&#x27;/);
625
+ });
626
+
627
+ it.each(PLUGIN_TYPES)(
628
+ 'keeps every %s page frontmatter valid YAML when the plugin name contains YAML syntax',
629
+ (type) => {
630
+ const name = 'Clock: "Analog" #2 \\ *';
631
+ const context = makeContext({ ...FULL_PLUGIN_JSON[type], name });
632
+ docs(context, { docsPath: 'docs' });
633
+ for (const page of PAGES_BY_TYPE[type]) {
634
+ const frontmatter = /^---\n([\s\S]*?)\n---/.exec(context.getFile(`docs/${page}.md`) ?? '')?.[1] ?? '';
635
+ const { description, keywords } = parseYaml(frontmatter);
636
+ expect(description, page).toContain(name);
637
+ if (type === 'datasource') {
638
+ expect(keywords, page).toContain(name);
639
+ }
640
+ }
641
+ }
642
+ );
643
+
644
+ it('gives the landing page a title that stays short whatever the plugin is called', () => {
645
+ const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'A'.repeat(80) });
646
+ docs(context, { docsPath: 'docs' });
647
+ expect(context.getFile('docs/index.md')).toContain('\ntitle: Introduction\n');
648
+ });
649
+
650
+ it('substitutes the plugin id into catalog URLs', () => {
651
+ const context = new Context('/virtual');
652
+ context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', name: 'P', id: 'acme-p-panel' }));
653
+ context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
654
+ docs(context, { docsPath: 'docs' });
655
+ const readme = context.getFile('docs/README.md') ?? '';
656
+ expect(readme).toContain('grafana/plugins/acme-p-panel/');
657
+ expect(readme).not.toContain('<slug>');
658
+ });
659
+ });
660
+
661
+ describe('plugin type support', () => {
662
+ it('refuses a plugin with no type set', () => {
663
+ const context = new Context('/virtual');
664
+ context.addFile('src/plugin.json', JSON.stringify({ name: 'X' }));
665
+ expect(() => docs(context, { docsPath: 'docs' })).toThrow(/needs a plugin type of panel/);
666
+ });
667
+ });
668
+
669
+ describe('docsPath validation', () => {
670
+ it('leaves docsPath unset when omitted, so the codemod can tell it from an explicit value', () => {
671
+ expect(v.parse(schema, {})).toEqual({});
672
+ });
673
+
674
+ it('rejects an empty docsPath', () => {
675
+ expect(() => v.parse(schema, { docsPath: '' })).toThrow();
676
+ });
677
+
678
+ it('rejects an absolute docsPath', () => {
679
+ expect(() => v.parse(schema, { docsPath: '/etc/passwd' })).toThrow();
680
+ });
681
+
682
+ it('rejects a docsPath with ".." segments', () => {
683
+ expect(() => v.parse(schema, { docsPath: '../../etc' })).toThrow();
684
+ });
685
+
686
+ it('accepts a valid custom docsPath', () => {
687
+ expect(v.parse(schema, { docsPath: 'documentation' })).toEqual({ docsPath: 'documentation' });
688
+ });
689
+
690
+ it('accepts a nested docsPath', () => {
691
+ expect(v.parse(schema, { docsPath: 'docs/plugin' })).toEqual({ docsPath: 'docs/plugin' });
692
+ });
693
+
694
+ it('rejects a leading "./"', () => {
695
+ expect(() => v.parse(schema, { docsPath: './docs' })).toThrow();
696
+ });
697
+
698
+ it('rejects a trailing slash', () => {
699
+ expect(() => v.parse(schema, { docsPath: 'docs/' })).toThrow();
700
+ });
701
+
702
+ it('rejects a backslash', () => {
703
+ expect(() => v.parse(schema, { docsPath: 'C:\\docs' })).toThrow();
704
+ });
705
+
706
+ it('rejects a quote character', () => {
707
+ expect(() => v.parse(schema, { docsPath: "docs'" })).toThrow();
708
+ });
709
+
710
+ it('ignores unrelated CLI flags that always ride along in argv', () => {
711
+ const parsed = v.parse(schema, { force: false, f: false, 'experimental-updates': false });
712
+ expect(parsed).toEqual({});
713
+ });
714
+ });
715
+
716
+ describe('generated files', () => {
717
+ it('creates all six panel docs files', () => {
718
+ const context = makeContext();
719
+ docs(context, { docsPath: 'docs' });
720
+ expect(context.doesFileExist('docs/index.md')).toBe(true);
721
+ expect(context.doesFileExist('docs/data-formats.md')).toBe(true);
722
+ expect(context.doesFileExist('docs/options.md')).toBe(true);
723
+ expect(context.doesFileExist('docs/examples.md')).toBe(true);
724
+ expect(context.doesFileExist('docs/troubleshooting.md')).toBe(true);
725
+ expect(context.doesFileExist('docs/README.md')).toBe(true);
726
+ });
727
+
728
+ it('uses the expected H2s in each panel file', () => {
729
+ const context = makeContext();
730
+ docs(context, { docsPath: 'docs' });
731
+ expect(context.getFile('docs/data-formats.md') ?? '').toContain('## Supported data shape');
732
+ expect(context.getFile('docs/options.md') ?? '').toContain('## Panel options');
733
+ expect(context.getFile('docs/examples.md') ?? '').toContain('## Basic example');
734
+ expect(context.getFile('docs/troubleshooting.md') ?? '').toContain('## Get more detail about an error');
735
+ });
736
+
737
+ it('scaffolds section briefs as HTML comments that start with section-brief', () => {
738
+ const context = makeContext();
739
+ docs(context, { docsPath: 'docs' });
740
+ expect(context.getFile('docs/index.md') ?? '').toContain('<!-- section-brief\nFill this in:');
741
+ expect(context.getFile('docs/options.md') ?? '').toContain('<!-- section-brief\nFill this in:');
742
+ });
743
+
744
+ it.each(PLUGIN_TYPES)('closes every %s brief and never nests a comment inside one', (type) => {
745
+ const context = makeContext(FULL_PLUGIN_JSON[type]);
746
+ docs(context, { docsPath: 'docs' });
747
+ for (const page of PAGES_BY_TYPE[type]) {
748
+ const content = context.getFile(`docs/${page}.md`) ?? '';
749
+ const briefs = content.match(/<!-- section-brief\n[\s\S]*?-->/g) ?? [];
750
+ expect(briefs.length, page).toBeGreaterThan(0);
751
+ expect(content.match(/<!--/g)?.length, page).toBe(briefs.length);
752
+ expect(content, page).not.toContain('section-brief:');
753
+ }
754
+ });
755
+
756
+ // text the scaffold keeps on purpose, which agents leave in place
757
+ const FIXED_TEXT: Record<string, Record<string, string[]>> = {
758
+ panel: {
759
+ index: [
760
+ "- [Shape your query results for My Plugin](./data-formats.md)\n- [Configure panel and field options](./options.md)\n- [See worked dashboard.json examples](./examples.md)\n- [Fix a panel that isn't rendering correctly](./troubleshooting.md)",
761
+ ],
762
+ options: [
763
+ 'To change these options, edit the panel and use the options pane.',
764
+ 'For what each standard option does, refer to [Configure standard options](https://grafana.com/docs/grafana/latest/panels-visualizations/configure-standard-options/).',
765
+ ],
766
+ 'data-formats': [
767
+ 'This document explains the data the My Plugin panel needs from a query, so you can build a matching query or check your test data.',
768
+ 'To see the data the panel receives, open the panel editor, click **Query inspector** and select the **Data** tab. You can also turn on **Table view** at the top of the panel editor to see the data as a table.',
769
+ 'If a query returns data in a different shape, you can reshape it without changing the query. Select the **Transform data** tab in the panel editor and add a transformation. For more information, refer to [Transform data](https://grafana.com/docs/grafana/latest/panels-visualizations/query-transform-data/transform-data/).',
770
+ ],
771
+ examples: [
772
+ 'To use an example, open your dashboard and go to **Dashboard settings** > **JSON Model**. Paste the excerpt into the `panels` array and click **Save changes**.',
773
+ 'A panel excerpt in dashboard JSON has this shape:',
774
+ '```json\n{\n "type": "acme-test-panel",\n "options": {},\n "targets": []\n}\n```',
775
+ ],
776
+ troubleshooting: [
777
+ 'This page explains how to fix common issues when you use the My Plugin panel. For the data the panel expects, refer to [Data formats](./data-formats.md).',
778
+ 'To see the data the panel receives, open the panel editor, click **Query inspector** and select the **Data** tab.',
779
+ 'For errors in the panel itself, open the developer tools in your browser and check the console.',
780
+ "If this page doesn't solve your problem:",
781
+ '- Search the [Grafana community forums](https://community.grafana.com/).\n- If your Grafana Cloud or Grafana Enterprise plan includes support, contact Grafana Support.',
782
+ 'When you report an issue, include:',
783
+ '- The Grafana and plugin versions.\n- The error message, with sensitive information removed.\n- The steps to reproduce the issue.\n- The panel options and a sample of the data it shows, with sensitive information removed.',
784
+ ],
785
+ },
786
+ datasource: {
787
+ configure: [
788
+ 'This document explains how to add and configure the My Plugin data source in Grafana. After you configure the data source, refer to the [My Plugin query editor](./query-editor.md) to start querying your data.',
789
+ 'Before you configure the data source, ensure you have:',
790
+ '- **Grafana permissions:** The `Organization administrator` role. Only organization administrators can add data sources.',
791
+ 'To add the My Plugin data source:',
792
+ '1. Click **Connections** in the left-side menu.\n1. Click **Add new connection**.\n1. Enter `My Plugin` in the search bar.\n1. Select **My Plugin**.\n1. Click **Add new data source**.',
793
+ 'Click **Save & test** to verify the connection.',
794
+ 'You can provision the My Plugin data source as code, with a Grafana provisioning file or with the Grafana Terraform provider.',
795
+ 'You can define the data source in a YAML file as part of the Grafana provisioning system. For more information, refer to [Provision Grafana](https://grafana.com/docs/grafana/latest/administration/provisioning/#data-sources).',
796
+ 'You can provision the data source with the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source) using the `grafana_data_source` resource.',
797
+ ],
798
+ 'template-variables': [
799
+ 'Use template variables to create dynamic, reusable dashboards that let you change query parameters without editing individual panels. For general information about template variables, refer to [Templates and variables](https://grafana.com/docs/grafana/latest/dashboards/variables/).',
800
+ '- [Configure the My Plugin data source](./configure.md).\n- Understand [Grafana template variables](https://grafana.com/docs/grafana/latest/dashboards/variables/).\n- Know how to write queries in the [My Plugin query editor](./query-editor.md).',
801
+ 'The My Plugin data source supports the following variable types:',
802
+ 'A query variable fills its drop-down by running a query against My Plugin, so its options stay current with your data. Use one to let viewers pick a value, such as a host, a table or a project, and update every panel that uses it.',
803
+ 'To create a query variable:',
804
+ '1. Navigate to **Dashboard settings** > **Variables**.\n1. Click **Add variable**.\n1. Select **Query** as the variable type.\n1. Select the **My Plugin** data source.',
805
+ ],
806
+ alerting: [
807
+ 'You can create alert rules that run My Plugin queries on a schedule and fire when the result meets a condition, such as a value above a threshold. For general information about alerting, refer to [Grafana Alerting](https://grafana.com/docs/grafana/latest/alerting/).',
808
+ '- [Configure the My Plugin data source](./configure.md).',
809
+ 'To create an alert rule from a My Plugin query:',
810
+ '1. Navigate to **Alerting** > **Alert rules**.\n1. Click **New alert rule**.\n1. Enter a **Name** for the rule.\n1. Select the **My Plugin** data source.',
811
+ ],
812
+ annotations: [
813
+ 'Annotations overlay event markers on time series panels, so you can correlate events from My Plugin, such as deployments or incidents, with the metrics on your dashboards. For general information about annotations, refer to [Annotate visualizations](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/).',
814
+ '- [Configure the My Plugin data source](./configure.md).',
815
+ 'To add an annotation query to a dashboard:',
816
+ '1. Navigate to **Dashboard settings** > **Annotations**.\n1. Click **Add annotation query**.\n1. Enter a **Name** for the annotation.\n1. Select the **My Plugin** data source.',
817
+ ],
818
+ dashboards: [
819
+ 'My Plugin ships with ready-made dashboards that you can import and use as they are, or as a starting point for your own. For general information about dashboards, refer to [Dashboards](https://grafana.com/docs/grafana/latest/dashboards/).',
820
+ 'To import a dashboard that ships with My Plugin:',
821
+ '1. Click **Connections** > **Data sources** in the left-side menu.\n1. Select your My Plugin data source.\n1. Click the **Dashboards** tab.\n1. Click **Import** next to the dashboard.',
822
+ "Imported dashboards don't update when you upgrade the plugin. When a plugin upgrade ships a newer version of a dashboard, click **Update** next to it. This overwrites any changes you made to it.",
823
+ ],
824
+ 'query-editor': [
825
+ 'This document explains how to use the My Plugin query editor to build queries and visualize My Plugin data in Grafana dashboards.',
826
+ '- [Configure the My Plugin data source](./configure.md).',
827
+ ],
828
+ troubleshooting: [
829
+ 'This page explains how to fix common issues when you configure or use the My Plugin data source. For setup instructions, refer to [Configure the My Plugin data source](./configure.md).',
830
+ 'To see the raw request and response for a panel, open the panel editor and click **Query inspector**.',
831
+ 'In self-managed Grafana, you can also find more detail in the Grafana server logs:',
832
+ "1. Set the log level to `debug` in the Grafana configuration file:\n\n ```ini\n [log]\n level = debug\n ```\n\n1. Restart Grafana and reproduce the error.\n1. Look for log lines with `logger=plugin.acme-test-datasource`.\n1. Set the log level back to `info` when you're done, to keep the logs small.",
833
+ "If this page doesn't solve your problem:",
834
+ '- Search the [Grafana community forums](https://community.grafana.com/).\n- If your Grafana Cloud or Grafana Enterprise plan includes support, contact Grafana Support.',
835
+ 'When you report an issue, include:',
836
+ '- The Grafana and plugin versions.\n- The error message, with sensitive information removed.\n- The steps to reproduce the issue.\n- The relevant data source settings, with credentials removed.',
837
+ ],
838
+ },
839
+ app: {
840
+ index: [
841
+ 'Before you set up the app, ensure you have:',
842
+ '- **Grafana permissions:** The `Organization administrator` role. Only organization administrators can enable and configure apps.',
843
+ 'To enable My Plugin:',
844
+ '1. Click **Administration** > **Plugins and data** > **Plugins** in the left-side menu.\n1. Enter `My Plugin` in the search bar.\n1. Select **My Plugin**.\n1. Click **Enable**.',
845
+ 'After you enable the app, refer to [Configure](./configure.md) for its settings.',
846
+ ],
847
+ configure: [
848
+ 'This document explains how to configure My Plugin after you enable it.',
849
+ 'Before you configure the app, ensure you have:',
850
+ '- **Grafana permissions:** The `Organization administrator` role. Only organization administrators can configure apps.\n- **The app enabled:** Follow [Enable the app](./index.md#enable-the-app).',
851
+ 'To open the My Plugin configuration page:',
852
+ '1. Click **Administration** > **Plugins and data** > **Plugins** in the left-side menu.\n1. Enter `My Plugin` in the search bar.\n1. Select **My Plugin**.',
853
+ 'You can provision My Plugin as code with a Grafana provisioning file. For more information, refer to [Provision Grafana](https://grafana.com/docs/grafana/latest/administration/provisioning/#plugins).',
854
+ ],
855
+ overview: ['- **Grafana permissions:** At least the `Editor` role.'],
856
+ dashboards: [
857
+ 'My Plugin ships with ready-made dashboards that you can import and use as they are, or as a starting point for your own. For general information about dashboards, refer to [Dashboards](https://grafana.com/docs/grafana/latest/dashboards/).',
858
+ 'To import a dashboard that ships with My Plugin:',
859
+ '1. Click **Administration** > **Plugins and data** > **Plugins** in the left-side menu.\n1. Enter `My Plugin` in the search bar.\n1. Select **My Plugin**.\n1. Click the **Dashboards** tab.\n1. Click **Import** next to the dashboard.',
860
+ "Imported dashboards don't update when you upgrade the plugin. When a plugin upgrade ships a newer version of a dashboard, click **Update** next to it. This overwrites any changes you made to it.",
861
+ ],
862
+ permissions: [
863
+ 'This document explains who can use each part of My Plugin and what the app can access. For general information, refer to [Role-based access control for app plugins](https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/access-control/rbac-for-app-plugins/).',
864
+ 'To assign a role to a user, team or service account, refer to [Assign RBAC roles](https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/access-control/assign-rbac-roles/).',
865
+ ],
866
+ troubleshooting: [
867
+ 'This page explains how to fix common issues when you set up or use My Plugin. For setup instructions, refer to the [Introduction](./index.md).',
868
+ 'For errors in the app, open the developer tools in your browser and check the console.',
869
+ 'In self-managed Grafana, you can also find more detail in the Grafana server logs:',
870
+ "1. Set the log level to `debug` in the Grafana configuration file:\n\n ```ini\n [log]\n level = debug\n ```\n\n1. Restart Grafana and reproduce the error.\n1. Look for log lines with `logger=plugin.acme-test-app`.\n1. Set the log level back to `info` when you're done, to keep the logs small.",
871
+ "If this page doesn't solve your problem:",
872
+ '- Search the [Grafana community forums](https://community.grafana.com/).\n- If your Grafana Cloud or Grafana Enterprise plan includes support, contact Grafana Support.',
873
+ 'When you report an issue, include:',
874
+ '- The Grafana and plugin versions.\n- The error message, with sensitive information removed.\n- The steps to reproduce the issue.\n- The app page where it happens and the relevant app settings, with credentials removed.',
875
+ ],
876
+ },
877
+ };
878
+
879
+ it.each(PLUGIN_TYPES)(
880
+ 'scaffolds %s pages where every section is only a brief or fixed text, so an untouched scaffold is all stubs',
881
+ (type) => {
882
+ const context = makeContext(FULL_PLUGIN_JSON[type]);
883
+ docs(context, { docsPath: 'docs' });
884
+ for (const page of PAGES_BY_TYPE[type]) {
885
+ let left = (context.getFile(`docs/${page}.md`) ?? '')
886
+ .replace(/^---\n[\s\S]*?\n---\n/, '')
887
+ .replace(/<!-- section-brief\n[\s\S]*?-->/g, '')
888
+ .replace(/^#{1,6} .*$/gm, '');
889
+ for (const text of FIXED_TEXT[type][page] ?? []) {
890
+ expect(left, page).toContain(text);
891
+ left = left.replace(text, '');
892
+ }
893
+ expect(left.trim(), page).toBe('');
894
+ }
895
+ }
896
+ );
897
+
898
+ it('interpolates pluginName into the index page', () => {
899
+ const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Panel' });
900
+ docs(context, { docsPath: 'docs' });
901
+ expect(context.getFile('docs/index.md') ?? '').toContain('My Panel');
902
+ });
903
+
904
+ it('writes the validate-docs workflow', () => {
905
+ const context = makeContext();
906
+ docs(context, { docsPath: 'docs' });
907
+ expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true);
908
+ });
909
+
910
+ it('interpolates a custom docsPath into the workflow path filters', () => {
911
+ const context = makeContext();
912
+ docs(context, { docsPath: 'documentation' });
913
+ const content = context.getFile('.github/workflows/validate-docs.yml') ?? '';
914
+ expect(content).toContain("'documentation/**'");
915
+ expect(content).not.toContain('{{docsPath}}');
916
+ });
917
+
918
+ it('bumps the build-plugin ref in release.yml', () => {
919
+ const context = makeContext();
920
+ docs(context, { docsPath: 'docs' });
921
+ expect(context.getFile('.github/workflows/release.yml') ?? '').toContain(
922
+ 'grafana/plugin-actions/build-plugin@build-plugin/v1.2.0'
923
+ );
924
+ });
925
+
926
+ it('options.md asks for the Panel options table format with the four expected columns', () => {
927
+ const context = makeContext();
928
+ docs(context, { docsPath: 'docs' });
929
+ const content = context.getFile('docs/options.md') ?? '';
930
+ expect(content).toContain('| Option | Type | Default | Description |');
931
+ expect(content).toContain('## Standard field options');
932
+ expect(content).toContain('## Custom field options');
933
+ });
934
+
935
+ it('scaffolds docs/README.md with panel-specific content', () => {
936
+ const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Panel' });
937
+ docs(context, { docsPath: 'docs' });
938
+ const content = context.getFile('docs/README.md') ?? '';
939
+ expect(content).toContain('# My Panel documentation');
940
+ expect(content).toContain('grafana.com/developers/plugin-tools/how-to-guides/plugin-docs');
941
+ expect(content).toContain('npm run docs:validate');
942
+ expect(content).not.toContain('docs:validate:release');
943
+ });
944
+
945
+ it('does not scaffold a docs/README.txt (legacy filename)', () => {
946
+ const context = makeContext();
947
+ docs(context, { docsPath: 'docs' });
948
+ expect(context.doesFileExist('docs/README.txt')).toBe(false);
949
+ });
950
+ });
951
+
952
+ describe('datasource pages', () => {
953
+ const datasource = { type: 'datasource', id: 'acme-test-datasource', name: 'My Data Source' };
954
+ const conditionalPages = ['annotations.md', 'alerting.md', 'dashboards.md'];
955
+
956
+ it('scaffolds the pages every data source needs, and no feature pages when plugin.json declares none', () => {
957
+ const context = makeContext(datasource);
958
+ docs(context, { docsPath: 'docs' });
959
+ const files = [
960
+ ...PAGES_BY_TYPE.datasource.map((page) => `${page}.md`).filter((file) => !conditionalPages.includes(file)),
961
+ 'README.md',
962
+ 'AGENTS.md',
963
+ 'CLAUDE.md',
964
+ ];
965
+ for (const file of files) {
966
+ expect(context.doesFileExist(`docs/${file}`), file).toBe(true);
967
+ }
968
+ for (const file of conditionalPages) {
969
+ expect(context.doesFileExist(`docs/${file}`), file).toBe(false);
970
+ }
971
+ });
972
+
973
+ it.each([
974
+ ['annotations.md', { annotations: true }],
975
+ ['alerting.md', { alerting: true }],
976
+ ['dashboards.md', { includes: [{ type: 'dashboard', name: 'Overview', path: 'dashboards/overview.json' }] }],
977
+ ])('scaffolds %s when plugin.json declares the feature', (page, feature) => {
978
+ const context = makeContext({ ...datasource, ...feature });
979
+ docs(context, { docsPath: 'docs' });
980
+ for (const file of conditionalPages) {
981
+ expect(context.doesFileExist(`docs/${file}`), file).toBe(file === page);
982
+ }
983
+ });
984
+
985
+ it.each([{ annotations: false }, { alerting: 'true' }, { includes: [{ type: 'page' }, { type: 'datasource' }] }])(
986
+ 'scaffolds no feature page for %j',
987
+ (feature) => {
988
+ const context = makeContext({ ...datasource, ...feature });
989
+ docs(context, { docsPath: 'docs' });
990
+ for (const file of conditionalPages) {
991
+ expect(context.doesFileExist(`docs/${file}`), file).toBe(false);
992
+ }
993
+ }
994
+ );
995
+
996
+ it.each([
997
+ ['no feature', datasource],
998
+ ['every feature', FULL_PLUGIN_JSON.datasource],
999
+ ])('links only scaffolded pages from index.md with %s pages', (_, pluginJson) => {
1000
+ const context = makeContext(pluginJson);
1001
+ docs(context, { docsPath: 'docs' });
1002
+ const links = [...(context.getFile('docs/index.md') ?? '').matchAll(/\]\(\.\/([\w-]+\.md)\)/g)].map(
1003
+ ([, file]) => file
1004
+ );
1005
+ expect(links).toContain('configure.md');
1006
+ for (const file of links) {
1007
+ expect(context.doesFileExist(`docs/${file}`), file).toBe(true);
1008
+ }
1009
+ });
1010
+
1011
+ it.each([
1012
+ ['query-editor', '## Macros'],
1013
+ ['configure', '## Private data source connect'],
1014
+ ['troubleshooting', '## Private data source connect errors'],
1015
+ ['troubleshooting', 'level = debug'],
1016
+ ])('renders %s.md "%s" only for a backend data source', (page, heading) => {
1017
+ for (const backend of [true, false]) {
1018
+ const context = makeContext({ ...datasource, backend });
1019
+ docs(context, { docsPath: 'docs' });
1020
+ const content = context.getFile(`docs/${page}.md`) ?? '';
1021
+ expect(content.includes(heading), `backend: ${backend}`).toBe(backend);
1022
+ expect(content).not.toContain('{{');
1023
+ expect(content).not.toMatch(/\n\n\n/);
1024
+ }
1025
+ });
1026
+
1027
+ it.each([
1028
+ ['annotations', '## Annotation errors'],
1029
+ ['alerting', '## Alerting errors'],
1030
+ ])('renders troubleshooting.md "%s" only when plugin.json sets %s', (flag, heading) => {
1031
+ for (const enabled of [true, false]) {
1032
+ const context = makeContext({ ...datasource, [flag]: enabled });
1033
+ docs(context, { docsPath: 'docs' });
1034
+ const content = context.getFile('docs/troubleshooting.md') ?? '';
1035
+ expect(content.includes(heading), `${flag}: ${enabled}`).toBe(enabled);
1036
+ expect(content).not.toContain('{{');
1037
+ expect(content).not.toMatch(/\n\n\n/);
1038
+ }
1039
+ });
1040
+
1041
+ it('keeps the template conditionals out of index.md', () => {
1042
+ const context = makeContext(FULL_PLUGIN_JSON.datasource);
1043
+ docs(context, { docsPath: 'docs' });
1044
+ const index = context.getFile('docs/index.md') ?? '';
1045
+ expect(index).not.toContain('{{');
1046
+ expect(index).not.toMatch(/\n\n\n/);
1047
+ });
1048
+
1049
+ it('scaffolds configure.md, the page plugin-docs-cli requires for data sources', () => {
1050
+ const context = makeContext(datasource);
1051
+ docs(context, { docsPath: 'docs' });
1052
+ expect(context.doesFileExist('docs/configuration.md')).toBe(false);
1053
+ expect(context.getFile('docs/configure.md') ?? '').toContain('## Provision the data source');
1054
+ });
1055
+
1056
+ it('gives every page a unique sidebar_position', () => {
1057
+ const context = makeContext(FULL_PLUGIN_JSON.datasource);
1058
+ docs(context, { docsPath: 'docs' });
1059
+ const positions = PAGES_BY_TYPE.datasource.map(
1060
+ (page) => /\nsidebar_position: (\d+)\n/.exec(context.getFile(`docs/${page}.md`) ?? '')?.[1]
1061
+ );
1062
+ expect(positions.every(Boolean)).toBe(true);
1063
+ expect(new Set(positions).size).toBe(positions.length);
1064
+ });
1065
+
1066
+ it('lists every data source page in the docs AGENTS.md', () => {
1067
+ const context = makeContext(datasource);
1068
+ docs(context, { docsPath: 'docs' });
1069
+ const content = context.getFile('docs/AGENTS.md') ?? '';
1070
+ for (const page of PAGES_BY_TYPE.datasource) {
1071
+ expect(content).toContain(`| \`${page}.md\``);
1072
+ }
1073
+ expect(content).not.toContain('setPanelOptions');
1074
+ });
1075
+
1076
+ it('warns about a feature page on a re-run that changes nothing else, without adding it', () => {
1077
+ const { context, body } = scaffoldThenRerun(datasource, { annotations: true });
1078
+ expect(context.hasChanges()).toBe(false);
1079
+ expect(context.doesFileExist('docs/annotations.md')).toBe(false);
1080
+ expect(body).toContain('docs/annotations.md documents');
1081
+ expect(body).toContain('Optional pages in docs/AGENTS.md');
1082
+ expect(body).not.toContain('alerting.md');
1083
+ });
1084
+
1085
+ it.each(['annotations.md', 'annotations/index.md'])(
1086
+ 'says nothing about a feature page at %s on a re-run',
1087
+ (file) => {
1088
+ const { context, body } = scaffoldThenRerun(datasource, { annotations: true }, file);
1089
+ expect(context.getMessage()).toBeUndefined();
1090
+ expect(body).not.toContain('annotations.md');
1091
+ }
1092
+ );
1093
+
1094
+ it('never warns about data source feature pages for a panel', () => {
1095
+ const panel = { type: 'panel', id: 'acme-test-panel', name: 'My Plugin' };
1096
+ const { context } = scaffoldThenRerun(panel, {
1097
+ annotations: true,
1098
+ alerting: true,
1099
+ includes: [{ type: 'dashboard', name: 'Overview', path: 'dashboards/overview.json' }],
1100
+ });
1101
+ expect(context.getMessage()).toBeUndefined();
1102
+ });
1103
+ });
1104
+
1105
+ describe('app pages', () => {
1106
+ const app = { type: 'app', id: 'acme-test-app', name: 'My App' };
1107
+ const gatedPages = ['configure.md', 'dashboards.md', 'permissions.md', 'traces.md'];
1108
+ const page = (name: string, path?: string, extra: Record<string, unknown> = {}) => ({
1109
+ type: 'page',
1110
+ name,
1111
+ path,
1112
+ ...extra,
1113
+ });
1114
+
1115
+ function scaffold(pluginJson: Record<string, unknown>, files: Record<string, string> = {}) {
1116
+ const context = makeContext(pluginJson);
1117
+ for (const [file, content] of Object.entries(files)) {
1118
+ context.addFile(file, content);
1119
+ }
1120
+ docs(context, { docsPath: 'docs' });
1121
+ return context;
1122
+ }
1123
+
1124
+ function docsFiles(context: Context): string[] {
1125
+ return Object.keys(context.listChanges())
1126
+ .filter((file) => file.startsWith('docs/') && file.endsWith('.md'))
1127
+ .map((file) => file.slice('docs/'.length))
1128
+ .sort();
1129
+ }
1130
+
1131
+ it('scaffolds the pages every app needs, and no gated pages when the app declares nothing', () => {
1132
+ const context = scaffold(app);
1133
+ expect(docsFiles(context)).toEqual(['AGENTS.md', 'CLAUDE.md', 'README.md', 'index.md', 'troubleshooting.md']);
1134
+ });
1135
+
1136
+ it.each([
1137
+ [
1138
+ 'configure.md',
1139
+ { 'src/module.tsx': 'export const plugin = new AppPlugin().addConfigPage({ title: "Config" });' },
1140
+ {},
1141
+ ],
1142
+ ['configure.md', { 'src/module.ts': 'new AppPlugin()\n .addConfigPage({ title: "Config" })' }, {}],
1143
+ ['configure.md', {}, { includes: [page('Configuration', '/plugins/%PLUGIN_ID%')] }],
1144
+ ['configure.md', {}, { includes: [page('Configuration', '/plugins/acme-test-app')] }],
1145
+ ['dashboards.md', {}, { includes: [{ type: 'dashboard', name: 'Overview', path: 'dashboards/overview.json' }] }],
1146
+ ['permissions.md', {}, { roles: [{ role: { name: 'plugins:acme-test-app:reader' }, grants: ['Viewer'] }] }],
1147
+ ['permissions.md', {}, { iam: { permissions: [{ action: 'datasources:read', scope: 'datasources:*' }] } }],
1148
+ ['traces.md', {}, { includes: [page('Traces', '/a/acme-test-app/traces')] }],
1149
+ ])('scaffolds %s when the app declares it (%j %j)', (expected, files, feature) => {
1150
+ const context = scaffold({ ...app, ...feature }, files);
1151
+ for (const file of gatedPages) {
1152
+ expect(context.doesFileExist(`docs/${file}`), file).toBe(file === expected);
1153
+ }
1154
+ });
1155
+
1156
+ it('gives a page only to app pages, not to the config page, dashboard links or example pages', () => {
1157
+ const context = scaffold({
1158
+ ...app,
1159
+ includes: [
1160
+ page('Traces', '/a/acme-test-app/traces'),
1161
+ page('Home', '/a/%PLUGIN_ID%/'),
1162
+ page('Configuration', '/plugins/%PLUGIN_ID%'),
1163
+ page('Redis CLI', '/d/_SGxCBNGk'),
1164
+ page('Page One', '/a/%PLUGIN_ID%/one'),
1165
+ page('Elsewhere', '/a/other-app/elsewhere'),
1166
+ page('No path'),
1167
+ ],
1168
+ });
1169
+ expect(docsFiles(context)).toEqual([
1170
+ 'AGENTS.md',
1171
+ 'CLAUDE.md',
1172
+ 'README.md',
1173
+ 'configure.md',
1174
+ 'home.md',
1175
+ 'index.md',
1176
+ 'traces.md',
1177
+ 'troubleshooting.md',
1178
+ ]);
1179
+ const body = stripVTControlCharacters((context.getMessage()?.body ?? []).join('\n'));
1180
+ expect(body).toContain('Page One (/a/%PLUGIN_ID%/one, an example page from the create-plugin app template)');
1181
+ });
1182
+
1183
+ it('puts up to three app pages at the root, in the order of the nav, with no features folder', () => {
1184
+ const context = scaffold({
1185
+ ...app,
1186
+ includes: [
1187
+ page('Time Travel', '/a/%PLUGIN_ID%/timetravel'),
1188
+ page('Overview', '/a/%PLUGIN_ID%/overview', { defaultNav: true }),
1189
+ page('Queries', '/a/%PLUGIN_ID%/queries'),
1190
+ ],
1191
+ });
1192
+ const position = (file: string) => /\nsidebar_position: (\d+)\n/.exec(context.getFile(`docs/${file}`) ?? '')?.[1];
1193
+ expect(docsFiles(context).some((file) => file.startsWith('features/'))).toBe(false);
1194
+ expect(position('overview.md')).toBe('3');
1195
+ expect(position('time-travel.md')).toBe('4');
1196
+ expect(position('queries.md')).toBe('5');
1197
+ expect(context.getFile('docs/time-travel.md')).toContain('title: "Time Travel"');
1198
+ expect(context.getFile('docs/time-travel.md')).toContain('`/a/acme-test-app/timetravel`');
1199
+ expect(context.getFile('docs/index.md')).toContain('](./overview.md)');
1200
+ });
1201
+
1202
+ it('moves the app pages into a features folder from the fourth page', () => {
1203
+ const context = scaffold({
1204
+ ...app,
1205
+ includes: [
1206
+ page('Overview', '/a/%PLUGIN_ID%/overview', { defaultNav: true }),
1207
+ page('Time Travel', '/a/%PLUGIN_ID%/timetravel'),
1208
+ page('Queries', '/a/%PLUGIN_ID%/queries'),
1209
+ page('Merges', '/a/%PLUGIN_ID%/merges'),
1210
+ ],
1211
+ });
1212
+ const position = (file: string) => /\nsidebar_position: (\d+)\n/.exec(context.getFile(`docs/${file}`) ?? '')?.[1];
1213
+ expect(docsFiles(context).filter((file) => file.startsWith('features/'))).toEqual([
1214
+ 'features/index.md',
1215
+ 'features/merges.md',
1216
+ 'features/overview.md',
1217
+ 'features/queries.md',
1218
+ 'features/time-travel.md',
1219
+ ]);
1220
+ expect(docsFiles(context)).not.toContain('overview.md');
1221
+ expect(position('features/index.md')).toBe('3');
1222
+ expect(position('features/overview.md')).toBe('4');
1223
+ expect(position('features/merges.md')).toBe('7');
1224
+ expect(context.getFile('docs/index.md')).toContain('](./features/overview.md)');
1225
+ });
1226
+
1227
+ it('leaves out an app page that would take the file name of another page', () => {
1228
+ const context = scaffold({ ...app, includes: [page('Permissions', '/a/%PLUGIN_ID%/permissions')] });
1229
+ expect(context.doesFileExist('docs/permissions.md')).toBe(false);
1230
+ expect(context.getFile('docs/index.md')).not.toContain('](./permissions.md)');
1231
+ const body = stripVTControlCharacters((context.getMessage()?.body ?? []).join('\n'));
1232
+ expect(body).toContain('Permissions (/a/%PLUGIN_ID%/permissions, its file name is used by another page)');
1233
+ });
1234
+
1235
+ it.each(['Configuration', 'Configure'])('uses an app page named %s as the root configure page', (name) => {
1236
+ const includes = ['One', 'Two', 'Three', 'Four'].map((n) => page(n, `/a/%PLUGIN_ID%/${n.toLowerCase()}`));
1237
+ const context = scaffold({
1238
+ ...app,
1239
+ includes: [page(name, '/a/%PLUGIN_ID%/configuration', { role: 'Editor' }), ...includes],
1240
+ });
1241
+ const content = context.getFile('docs/configure.md') ?? '';
1242
+ expect(content).toContain(`title: "${name}"`);
1243
+ expect(content).toContain('/a/acme-test-app/configuration');
1244
+ expect(content).toContain('At least the `Editor` role.');
1245
+ expect(content).toMatch(/\nsidebar_position: 2\n/);
1246
+ expect(content).not.toContain('Administration');
1247
+ expect(docsFiles(context)).not.toContain(`features/${name.toLowerCase()}.md`);
1248
+ expect(docsFiles(context)).toContain('features/one.md');
1249
+ expect(context.getFile('docs/index.md')).toContain('](./configure.md)');
1250
+ });
1251
+
1252
+ it('keeps an app page named Configuration among the app pages when the app has a config page', () => {
1253
+ const context = scaffold(
1254
+ { ...app, includes: [page('Configuration', '/a/%PLUGIN_ID%/configuration')] },
1255
+ { 'src/module.tsx': 'new AppPlugin().addConfigPage({ title: "Config" });' }
1256
+ );
1257
+ expect(context.getFile('docs/configure.md')).toContain('Open the configuration page');
1258
+ expect(context.doesFileExist('docs/configuration.md')).toBe(true);
1259
+ });
1260
+
1261
+ it('says nothing on a re-run when the configure page exists', () => {
1262
+ const pluginJson = { ...app, includes: [page('Configuration', '/a/%PLUGIN_ID%/configuration')] };
1263
+ const { context } = scaffoldThenRerun(pluginJson, {});
1264
+ expect(context.getMessage()).toBeUndefined();
1265
+ });
1266
+
1267
+ it('adds a before you begin section to an app page only for a role above Viewer', () => {
1268
+ const context = scaffold({
1269
+ ...app,
1270
+ includes: [
1271
+ page('Admin Tools', '/a/%PLUGIN_ID%/admin', { role: 'Admin' }),
1272
+ page('Open', '/a/%PLUGIN_ID%/open'),
1273
+ page('Reader', '/a/%PLUGIN_ID%/reader', { role: 'Viewer' }),
1274
+ ],
1275
+ });
1276
+ expect(context.getFile('docs/reader.md')).not.toContain('## Before you begin');
1277
+ expect(context.getFile('docs/admin-tools.md')).toContain('At least the `Admin` role.');
1278
+ expect(context.getFile('docs/open.md')).not.toContain('## Before you begin');
1279
+ expect(context.getFile('docs/admin-tools.md')).toContain('remove it, including its heading');
1280
+ });
1281
+
1282
+ it.each([
1283
+ ['no feature', app],
1284
+ ['every feature', FULL_PLUGIN_JSON.app],
1285
+ ])('links only scaffolded pages and leaves no template syntax with %s', (_, pluginJson) => {
1286
+ const context = scaffold(pluginJson);
1287
+ for (const file of docsFiles(context)) {
1288
+ const content = context.getFile(`docs/${file}`) ?? '';
1289
+ expect(content, file).not.toContain('{{');
1290
+ expect(content, file).not.toMatch(/\n\n\n/);
1291
+ if (file === 'AGENTS.md') {
1292
+ continue;
1293
+ }
1294
+ for (const [, link] of content.matchAll(/\]\(\.\/([\w/-]+\.md)\)/g)) {
1295
+ expect(context.doesFileExist(`docs/${link}`), `${file} -> ${link}`).toBe(true);
1296
+ }
1297
+ }
1298
+ });
1299
+
1300
+ it('gives every root page a unique sidebar_position', () => {
1301
+ const context = scaffold(FULL_PLUGIN_JSON.app);
1302
+ const positions = PAGES_BY_TYPE.app.map(
1303
+ (file) => /\nsidebar_position: (\d+)\n/.exec(context.getFile(`docs/${file}.md`) ?? '')?.[1]
1304
+ );
1305
+ expect(positions.every(Boolean)).toBe(true);
1306
+ expect(new Set(positions).size).toBe(positions.length);
1307
+ });
1308
+
1309
+ it('lists every app page in the docs AGENTS.md', () => {
1310
+ const content = scaffold(app).getFile('docs/AGENTS.md') ?? '';
1311
+ for (const file of ['index', 'configure', 'features/index', 'dashboards', 'permissions']) {
1312
+ expect(content).toContain(`| \`${file}.md\``);
1313
+ }
1314
+ expect(content).toContain('| App pages');
1315
+ expect(content).not.toContain('setConfigEditor');
1316
+ });
1317
+
1318
+ it('warns about a new app page on a re-run, without adding it', () => {
1319
+ const { context, body } = scaffoldThenRerun(
1320
+ { ...app, includes: [page('Traces', '/a/%PLUGIN_ID%/traces')] },
1321
+ { includes: [page('Traces', '/a/%PLUGIN_ID%/traces'), page('Logs', '/a/%PLUGIN_ID%/logs')] }
1322
+ );
1323
+ expect(context.hasChanges()).toBe(false);
1324
+ expect(context.doesFileExist('docs/logs.md')).toBe(false);
1325
+ expect(body).toContain('such as docs/logs.md');
1326
+ expect(body).toContain('Optional pages in docs/AGENTS.md');
1327
+ });
1328
+
1329
+ it('finds the app pages of a root layout after a fourth page moves the layout to features/', () => {
1330
+ const three = ['One', 'Two', 'Three'].map((name) => page(name, `/a/%PLUGIN_ID%/${name.toLowerCase()}`));
1331
+ const { context, body } = scaffoldThenRerun(
1332
+ { ...app, includes: three },
1333
+ { includes: [...three, page('Four', '/a/%PLUGIN_ID%/four')] }
1334
+ );
1335
+ expect(context.hasChanges()).toBe(false);
1336
+ expect(body).toContain('such as docs/features/four.md');
1337
+ expect(body).not.toContain('one.md');
1338
+ });
1339
+
1340
+ it('finds an app page that the author turned into a folder', () => {
1341
+ const pluginJson = { ...app, includes: [page('Traces', '/a/%PLUGIN_ID%/traces')] };
1342
+ const { context } = scaffoldThenRerun(pluginJson, {}, 'traces/index.md');
1343
+ expect(context.getMessage()).toBeUndefined();
1344
+ });
1345
+
1346
+ it.each([
1347
+ [
1348
+ 'pages with the same file name',
1349
+ [page('Foo Bar', '/a/%PLUGIN_ID%/a'), page('foo-bar', '/a/%PLUGIN_ID%/b')],
1350
+ 'foo-bar (/a/%PLUGIN_ID%/b, its file name',
1351
+ ],
1352
+ [
1353
+ 'a name that is a docs-cli meta file',
1354
+ [page('README', '/a/%PLUGIN_ID%/readme')],
1355
+ 'README (/a/%PLUGIN_ID%/readme, its file name',
1356
+ ],
1357
+ [
1358
+ 'a name that is the features index',
1359
+ [page('Index', '/a/%PLUGIN_ID%/index')],
1360
+ 'Index (/a/%PLUGIN_ID%/index, its file name',
1361
+ ],
1362
+ ])('leaves out %s and says so', (_, includes, expected) => {
1363
+ const context = scaffold({ ...app, includes });
1364
+ const body = stripVTControlCharacters((context.getMessage()?.body ?? []).join('\n'));
1365
+ expect(body).toContain(expected);
1366
+ expect(docsFiles(context)).not.toContain('readme.md');
1367
+ });
1368
+
1369
+ it('ignores a page whose name is not a string', () => {
1370
+ const context = scaffold({ ...app, includes: [{ type: 'page', name: 123, path: '/a/%PLUGIN_ID%/x' }] });
1371
+ expect(docsFiles(context)).toEqual(['AGENTS.md', 'CLAUDE.md', 'README.md', 'index.md', 'troubleshooting.md']);
1372
+ });
1373
+
1374
+ it.each([
1375
+ ['a trailing comment', 'new AppPlugin().setRootPage(App); // .addConfigPage({})'],
1376
+ ['a string', "const help = 'call .addConfigPage( to add one';"],
1377
+ ])('does not count addConfigPage in %s as a config page', (_, source) => {
1378
+ const context = scaffold(app, { 'src/module.tsx': source });
1379
+ expect(context.doesFileExist('docs/configure.md')).toBe(false);
1380
+ });
1381
+
1382
+ it.each([
1383
+ [
1384
+ 'a glob string before it',
1385
+ "const files = '**/*.svg';\nnew AppPlugin().addConfigPage({ title: 'Config' });",
1386
+ 'src/module.ts',
1387
+ ],
1388
+ [
1389
+ 'JSX',
1390
+ "export const plugin = new AppPlugin().addConfigPage({ title: 'Config', body: () => <div /> });",
1391
+ 'src/module.tsx',
1392
+ ],
1393
+ [
1394
+ 'a JavaScript module',
1395
+ "export const plugin = new AppPlugin().addConfigPage({ title: 'Config' });",
1396
+ 'src/module.js',
1397
+ ],
1398
+ ])('finds addConfigPage after %s', (_, source, file) => {
1399
+ const context = scaffold(app, { [file]: source });
1400
+ expect(context.doesFileExist('docs/configure.md')).toBe(true);
1401
+ });
1402
+
1403
+ it('gives an app page with accented letters a readable file name', () => {
1404
+ const context = scaffold({ ...app, includes: [page('Übersicht', '/a/%PLUGIN_ID%/overview')] });
1405
+ expect(docsFiles(context)).toContain('ubersicht.md');
1406
+ });
1407
+
1408
+ it('leaves out an app page whose name has no letters or digits for a file name, and says so', () => {
1409
+ const context = scaffold({ ...app, includes: [page('概览', '/a/%PLUGIN_ID%/overview')] });
1410
+ const body = stripVTControlCharacters((context.getMessage()?.body ?? []).join('\n'));
1411
+ expect(body).toContain('概览 (/a/%PLUGIN_ID%/overview, its name has no letters or digits for a file name)');
1412
+ });
1413
+
1414
+ it.each([
1415
+ ['a role above Viewer', { role: 'Admin' }, true],
1416
+ ['an action', { action: 'plugins.app:access' }, true],
1417
+ ['the Viewer role', { role: 'Viewer' }, false],
1418
+ ])('scaffolds permissions.md for a page with %s', (_, extra, expected) => {
1419
+ const context = scaffold({ ...app, includes: [page('Traces', '/a/%PLUGIN_ID%/traces', extra)] });
1420
+ expect(context.doesFileExist('docs/permissions.md')).toBe(expected);
1421
+ });
1422
+
1423
+ it('does not count a commented-out addConfigPage as a config page', () => {
1424
+ const context = scaffold(app, { 'src/module.ts': '// plugin.addConfigPage({})\n/* .addConfigPage(x) */\n' });
1425
+ expect(context.doesFileExist('docs/configure.md')).toBe(false);
1426
+ });
1427
+
1428
+ // plugin-docs-cli only compares pages in the same folder, but the sidebar also sorts a folder by its index.md
1429
+ it.each([3, 4, 8])(
1430
+ 'keeps sidebar positions unique in every folder with %i app pages and every gated page',
1431
+ (count) => {
1432
+ const includes = [
1433
+ ...(FULL_PLUGIN_JSON.app.includes as Array<Record<string, unknown>>).filter((i) => i.type !== 'page'),
1434
+ ...Array.from({ length: count }, (_, i) => page(`Page ${i + 1}x`, `/a/%PLUGIN_ID%/p${i + 1}`)),
1435
+ ];
1436
+ const context = scaffold({ ...FULL_PLUGIN_JSON.app, includes });
1437
+ const positionOf = (file: string) =>
1438
+ Number(/\nsidebar_position: (\d+)\n/.exec(context.getFile(`docs/${file}`) ?? '')?.[1]);
1439
+ const pages = docsFiles(context).filter((file) => !['AGENTS.md', 'CLAUDE.md', 'README.md'].includes(file));
1440
+
1441
+ const byFolder = Object.groupBy(pages, (file) => file.split('/').slice(0, -1).join('/'));
1442
+ for (const [folder, files] of Object.entries(byFolder)) {
1443
+ const positions = (files ?? []).map(positionOf);
1444
+ expect(positions.every(Number.isInteger), folder).toBe(true);
1445
+ expect(new Set(positions).size, `duplicate positions in "${folder}"`).toBe(positions.length);
1446
+ }
1447
+
1448
+ // in the sidebar a folder sits among the root pages at the position of its index.md
1449
+ const rootPositions = pages
1450
+ .filter((file) => !file.includes('/') || file === 'features/index.md')
1451
+ .map(positionOf);
1452
+ expect(new Set(rootPositions).size, 'a folder shares a root position').toBe(rootPositions.length);
1453
+ }
1454
+ );
1455
+
1456
+ it('keeps every root position unique with three app pages and every gated page', () => {
1457
+ const includes = [
1458
+ ...(FULL_PLUGIN_JSON.app.includes as Array<Record<string, unknown>>),
1459
+ page('Two', '/a/%PLUGIN_ID%/two'),
1460
+ page('Three', '/a/%PLUGIN_ID%/three'),
1461
+ ];
1462
+ const context = scaffold({ ...FULL_PLUGIN_JSON.app, includes });
1463
+ const positions = docsFiles(context)
1464
+ .filter((file) => !file.includes('/') && !['AGENTS.md', 'CLAUDE.md', 'README.md'].includes(file))
1465
+ .map((file) => /\nsidebar_position: (\d+)\n/.exec(context.getFile(`docs/${file}`) ?? '')?.[1]);
1466
+ expect(positions).toHaveLength(8);
1467
+ expect(positions.every(Boolean)).toBe(true);
1468
+ expect(new Set(positions).size).toBe(positions.length);
1469
+ });
1470
+
1471
+ it.each([
1472
+ ['a flat layout', FULL_PLUGIN_JSON.app],
1473
+ [
1474
+ 'a features folder',
1475
+ {
1476
+ ...FULL_PLUGIN_JSON.app,
1477
+ includes: ['One', 'Two', 'Three', 'Four'].map((name) => page(name, `/a/%PLUGIN_ID%/${name.toLowerCase()}`)),
1478
+ },
1479
+ ],
1480
+ ])('is idempotent with %s', async (_, pluginJson) => {
1481
+ const context = makeContext(pluginJson);
1482
+ await expect((ctx: Context) => docs(ctx, { docsPath: 'docs' })).toBeIdempotent(context);
1483
+ });
1484
+
1485
+ it('warns about a features index that an app with four pages has lost, and names the page first when both are missing', () => {
1486
+ const four = ['One', 'Two', 'Three', 'Four'].map((n) => page(n, `/a/%PLUGIN_ID%/${n.toLowerCase()}`));
1487
+ const lostIndex = scaffoldThenRerun({ ...app, includes: four }, {}, undefined, ['features/index.md']);
1488
+ expect(lostIndex.context.hasChanges()).toBe(false);
1489
+ expect(lostIndex.body).toContain('such as docs/features/index.md');
1490
+
1491
+ const three = four.slice(0, 3);
1492
+ const movedLayout = scaffoldThenRerun({ ...app, includes: three }, { includes: four });
1493
+ expect(movedLayout.body).toContain('such as docs/features/four.md');
1494
+ });
1495
+
1496
+ it('says nothing on a re-run when every app page has a docs page', () => {
1497
+ const pluginJson = { ...app, includes: [page('Traces', '/a/%PLUGIN_ID%/traces')] };
1498
+ const { context } = scaffoldThenRerun(pluginJson, {});
1499
+ expect(context.getMessage()).toBeUndefined();
1500
+ });
1501
+ });
1502
+
1503
+ describe('AI authoring assistance', () => {
1504
+ it('does not scaffold any skills', () => {
1505
+ const context = makeContext();
1506
+ docs(context, { docsPath: 'docs' });
1507
+ const skills = Object.keys(context.listChanges()).filter((file) => /(^|\/)skills\//.test(file));
1508
+ expect(skills).toEqual([]);
1509
+ });
1510
+
1511
+ it('scaffolds AGENTS.md in the docs folder, pointing at the guides shipped with plugin-docs-cli', () => {
1512
+ const context = makeContext();
1513
+ docs(context, { docsPath: 'docs' });
1514
+ const content = context.getFile('docs/AGENTS.md') ?? '';
1515
+ expect(content).toContain('Docs guides:');
1516
+ expect(content).toContain('node_modules/@grafana/plugin-docs-cli/docs/index.md');
1517
+ expect(content).toContain('npm run docs:validate');
1518
+ expect(content).toContain('`unfilled-section-brief` suggestion');
1519
+ expect(content).not.toContain('docs:validate:release');
1520
+ expect(content).toContain('## Pages and their source');
1521
+ expect(content).toContain('## Section briefs');
1522
+ expect(content).not.toContain('{{');
1523
+ });
1524
+
1525
+ it('lists the conventional filenames for optional pages in the docs AGENTS.md', () => {
1526
+ const context = makeContext();
1527
+ docs(context, { docsPath: 'docs' });
1528
+ const content = context.getFile('docs/AGENTS.md') ?? '';
1529
+ expect(content).toContain('## Optional pages');
1530
+ for (const page of ['presets.md', 'permissions.md', 'accessibility.md']) {
1531
+ expect(content).toContain(`\`${page}\``);
1532
+ }
1533
+ });
1534
+
1535
+ it.each(PLUGIN_TYPES)('tells agents never to write installation instructions for a %s', (type) => {
1536
+ const context = makeContext(FULL_PLUGIN_JSON[type]);
1537
+ docs(context, { docsPath: 'docs' });
1538
+ const content = context.getFile('docs/AGENTS.md') ?? '';
1539
+ expect(content).toContain('## What not to write');
1540
+ expect(content).toContain('Never write installation instructions');
1541
+ expect(content).toContain('Installation tab');
1542
+ });
1543
+
1544
+ it('scaffolds CLAUDE.md in the docs folder as an import of AGENTS.md', () => {
1545
+ const context = makeContext();
1546
+ docs(context, { docsPath: 'docs' });
1547
+ expect(context.getFile('docs/CLAUDE.md')).toEqual('@AGENTS.md\n');
1548
+ });
1549
+
1550
+ it('no longer scaffolds an authoring guide under .config', () => {
1551
+ const context = makeContext();
1552
+ docs(context, { docsPath: 'docs' });
1553
+ expect(context.doesFileExist('.config/AGENTS/plugin-docs.md')).toBe(false);
1554
+ });
1555
+
1556
+ it('scaffolds the docs AGENTS.md under the configured docsPath', () => {
1557
+ const context = makeContext();
1558
+ docs(context, { docsPath: 'documentation' });
1559
+ expect(context.doesFileExist('documentation/AGENTS.md')).toBe(true);
1560
+ expect(context.doesFileExist('docs/AGENTS.md')).toBe(false);
1561
+ });
1562
+
1563
+ it('does not overwrite an existing customized docs AGENTS.md', () => {
1564
+ const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin', docsPath: 'docs' });
1565
+ context.addFile('docs/AGENTS.md', 'CUSTOMIZED');
1566
+ docs(context, { docsPath: 'docs' });
1567
+ expect(context.getFile('docs/AGENTS.md')).toEqual('CUSTOMIZED');
1568
+ });
1569
+
1570
+ it('appends a Plugin docs section to .config/AGENTS/instructions.md when present', () => {
1571
+ const context = makeContext();
1572
+ context.addFile('.config/AGENTS/instructions.md', '# Existing instructions\n\nDo the thing.\n');
1573
+ docs(context, { docsPath: 'docs' });
1574
+ expect(context.getFile('.config/AGENTS/instructions.md')).toBe(
1575
+ '# Existing instructions\n\nDo the thing.\n\n## Plugin docs\n\nWhenever you add, remove or change a feature in the plugin, you must update the plugin documentation under `docs/`. Refer to [`docs/AGENTS.md`](../../docs/AGENTS.md) for instructions.\n'
1576
+ );
1577
+ });
1578
+
1579
+ it('names the configured docsPath in the Plugin docs section', () => {
1580
+ const context = makeContext();
1581
+ context.addFile('.config/AGENTS/instructions.md', '# Existing instructions\n');
1582
+ docs(context, { docsPath: 'documentation' });
1583
+ const content = context.getFile('.config/AGENTS/instructions.md') ?? '';
1584
+ expect(content).toContain('under `documentation/`');
1585
+ expect(content).toContain('[`documentation/AGENTS.md`](../../documentation/AGENTS.md)');
1586
+ });
1587
+
1588
+ it.each([
1589
+ ['a re-run', '## Documentation\n\nRefer to [`docs/AGENTS.md`](./docs/AGENTS.md) for instructions.\n'],
1590
+ ['an older one-line pointer', '- Read `docs/AGENTS.md` before writing or modifying plugin documentation.\n'],
1591
+ ])('does not add a second pointer after %s', (_, pointer) => {
1592
+ const context = makeContext();
1593
+ const original = `# Existing\n\n${pointer}`;
1594
+ context.addFile('.config/AGENTS/instructions.md', original);
1595
+ docs(context, { docsPath: 'docs' });
1596
+ expect(context.getFile('.config/AGENTS/instructions.md')).toBe(original);
1597
+ });
1598
+
1599
+ it('creates .config/AGENTS/instructions.md with the Plugin docs section when it is absent', () => {
1600
+ const context = makeContext();
1601
+ docs(context, { docsPath: 'docs' });
1602
+ const content = context.getFile('.config/AGENTS/instructions.md') ?? '';
1603
+ expect(content).toContain('This repository contains a **Grafana plugin**');
1604
+ expect(content).toContain('\n\n## Plugin docs\n\n');
1605
+ expect(content).toContain('[`docs/AGENTS.md`](../../docs/AGENTS.md)');
1606
+ });
1607
+
1608
+ it('creates root AGENTS.md, CLAUDE.md and GEMINI.md when absent, pointing at .config/AGENTS/instructions.md', () => {
1609
+ const context = makeContext();
1610
+ docs(context, { docsPath: 'docs' });
1611
+ for (const filename of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
1612
+ const content = context.getFile(filename) ?? '';
1613
+ expect(content).toContain('.config/AGENTS/instructions.md');
1614
+ }
1615
+ });
1616
+
1617
+ it('keeps existing content in root AGENTS.md, CLAUDE.md and GEMINI.md, appending a pointer to instructions.md', () => {
1618
+ const context = makeContext();
1619
+ context.addFile('AGENTS.md', 'CUSTOM AGENTS');
1620
+ context.addFile('CLAUDE.md', 'CUSTOM CLAUDE');
1621
+ context.addFile('GEMINI.md', 'CUSTOM GEMINI');
1622
+ docs(context, { docsPath: 'docs' });
1623
+ for (const [filename, original] of [
1624
+ ['AGENTS.md', 'CUSTOM AGENTS'],
1625
+ ['CLAUDE.md', 'CUSTOM CLAUDE'],
1626
+ ['GEMINI.md', 'CUSTOM GEMINI'],
1627
+ ] as const) {
1628
+ const content = context.getFile(filename) ?? '';
1629
+ expect(content).toContain(original);
1630
+ expect(content).toContain('.config/AGENTS/instructions.md');
1631
+ }
1632
+ });
1633
+
1634
+ it('creates the e2e-testing.md that instructions.md imports, rendered for the plugin', () => {
1635
+ const context = makeContext();
1636
+ docs(context, { docsPath: 'docs' });
1637
+ expect(context.getFile('.config/AGENTS/instructions.md')).toContain('@./.config/AGENTS/e2e-testing.md');
1638
+ const content = context.getFile('.config/AGENTS/e2e-testing.md') ?? '';
1639
+ expect(content).toContain('npm run e2e');
1640
+ expect(content).not.toContain('{{');
1641
+ });
1642
+
1643
+ it('leaves the rest of .config/AGENTS alone when instructions.md already exists', () => {
1644
+ const context = makeContext();
1645
+ context.addFile('.config/AGENTS/instructions.md', '# Existing instructions\n');
1646
+ docs(context, { docsPath: 'docs' });
1647
+ expect(context.doesFileExist('.config/AGENTS/e2e-testing.md')).toBe(false);
1648
+ });
1649
+
1650
+ it.each([
1651
+ ['npm@10.2.3', 'npx -y @grafana/create-plugin@latest add docs-content'],
1652
+ ['pnpm@9.1.0', 'pnpm dlx @grafana/create-plugin@latest add docs-content'],
1653
+ ['yarn@4.1.0', 'yarn dlx @grafana/create-plugin@latest add docs-content'],
1654
+ ])('points docs/README.md at docs-content with the %s exec command', (packageManager, command) => {
1655
+ const [packageManagerName, packageManagerVersion] = packageManager.split('@');
1656
+ vi.mocked(getPackageManagerWithFallback).mockReturnValueOnce({ packageManagerName, packageManagerVersion });
1657
+ const context = makeContext();
1658
+ docs(context, { docsPath: 'docs' });
1659
+ const content = context.getFile('docs/README.md') ?? '';
1660
+ expect(content).toContain(`\`${command}\``);
1661
+ expect(content).not.toContain('bootstrap-plugin-docs');
1662
+ });
1663
+ });
1664
+
1665
+ describe('scaffold wording', () => {
1666
+ it('never refers to strict mode, the unfilled-stubs flag or a release script', () => {
1667
+ const context = makeContext();
1668
+ docs(context, { docsPath: 'docs' });
1669
+ const stale = /--strict|--no-strict|allow-unfilled|validate:release|strict mode/;
1670
+ // .config/AGENTS is the shared create-plugin template, which has its own wording
1671
+ const files = Object.keys(context.listChanges()).filter((file) => !file.startsWith('.config/'));
1672
+ expect(files.length).toBeGreaterThan(10);
1673
+ for (const file of files) {
1674
+ expect(context.getFile(file) ?? '', file).not.toMatch(stale);
1675
+ }
1676
+ expect((context.getMessage()?.body ?? []).join('\n')).not.toMatch(stale);
1677
+ });
1678
+ });
1679
+
1680
+ describe('next steps', () => {
1681
+ function nextSteps(context: Context): string[] {
1682
+ docs(context, { docsPath: 'docs' });
1683
+ return (context.getMessage()?.body ?? []).map((line) => stripVTControlCharacters(line));
1684
+ }
1685
+
1686
+ it('numbers the steps in the order the author does them', () => {
1687
+ expect(nextSteps(makeContext()).filter((line) => /^ \d\. /.test(line))).toEqual([
1688
+ ' 1. Read docs/README.md',
1689
+ ' 2. Write the pages, yourself or with an AI agent',
1690
+ ' 3. Preview the pages',
1691
+ ]);
1692
+ });
1693
+
1694
+ it('tells the author to commit before running docs-content, since add needs a clean tree', () => {
1695
+ const body = nextSteps(makeContext());
1696
+ const step = body.indexOf(' 2. Write the pages, yourself or with an AI agent');
1697
+ expect(body.slice(step + 1, step + 5)).toEqual([
1698
+ ' Fill in or review the pages under docs/ by hand.',
1699
+ ' Or commit these changes, then have an agent draft them from your source code and README:',
1700
+ ' npx -y @grafana/create-plugin@latest add docs-content',
1701
+ ' Review its work either way.',
1702
+ ]);
1703
+ });
1704
+
1705
+ it('indents each detail line under its step', () => {
1706
+ const body = nextSteps(makeContext());
1707
+ const step = body.indexOf(' 3. Preview the pages');
1708
+ expect(body[step + 1]).toBe(' Run npm run docs:serve, which also lists every section left to fill.');
1709
+ expect(body.join('\n')).not.toContain('docs:validate:release');
1710
+ expect(body.join('\n')).not.toContain('strict');
1711
+ });
1712
+
1713
+ it('leaves out the attention section when there is nothing to check', () => {
1714
+ expect(nextSteps(makeContext())).not.toContain('Needs your attention:');
1715
+ });
1716
+
1717
+ it('says nothing about agent files, since root files that miss instructions.md get fixed instead of flagged', () => {
1718
+ const context = makeContext();
1719
+ context.addFile('CLAUDE.md', '# My own instructions\n\nRead docs/AGENTS.md for docs.\n');
1720
+ context.addFile('GEMINI.md', '# Gemini notes\n');
1721
+ expect(nextSteps(context).join('\n')).not.toContain("won't find docs/AGENTS.md");
1722
+ });
1723
+
1724
+ it('reports each existing root agent file it appended the instructions pointer to', () => {
1725
+ const context = makeContext();
1726
+ context.addFile('CLAUDE.md', '# My own instructions\n');
1727
+ const body = nextSteps(context);
1728
+ expect(body).toContain('Needs your attention:');
1729
+ expect(body).toContain(' ! Added a pointer to .config/AGENTS/instructions.md at the end of CLAUDE.md');
1730
+ expect(body.join('\n')).not.toContain('end of AGENTS.md');
1731
+ });
1732
+
1733
+ it('says nothing about the release pipeline when build-plugin was bumped', () => {
1734
+ expect(nextSteps(makeContext()).join('\n')).not.toContain('build-plugin');
1735
+ });
1736
+
1737
+ it('asks to check the release pipeline when release.yml has no build-plugin step', () => {
1738
+ const context = makeContext();
1739
+ context.updateFile('.github/workflows/release.yml', 'name: Release\n');
1740
+ expect(nextSteps(context).join('\n')).toContain('Make sure your release pipeline builds docs/');
1741
+ });
1742
+
1743
+ it('asks to check a build-plugin ref pinned to a branch or commit', () => {
1744
+ const context = makeContext();
1745
+ context.updateFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@my-branch\n');
1746
+ expect(nextSteps(context).join('\n')).toContain('Docs need build-plugin/v1.2.0 or later.');
1747
+ });
1748
+
1749
+ it('asks to check a package-plugin ref pinned to a commit', () => {
1750
+ const context = makeContext();
1751
+ context.updateFile(
1752
+ '.github/workflows/release.yml',
1753
+ 'uses: grafana/plugin-actions/package-plugin@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n'
1754
+ );
1755
+ const body = nextSteps(context).join('\n');
1756
+ expect(body).toContain('Docs need package-plugin/v1.3.3 or later.');
1757
+ expect(body).not.toContain('Make sure your release pipeline');
1758
+ });
1759
+
1760
+ it('says nothing about an action on main, which always runs the latest release', () => {
1761
+ const context = makeContext();
1762
+ context.updateFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@main\n');
1763
+ expect(nextSteps(context).join('\n')).not.toContain('build-plugin');
1764
+ });
1765
+ });
1766
+ });