@grafana/create-plugin 7.13.0 → 7.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/dist/codemods/additions/additions.js +12 -0
- package/dist/codemods/additions/prompts/docs-content.md +119 -0
- package/dist/codemods/additions/scripts/docs-content.js +30 -0
- package/dist/codemods/additions/scripts/docs.js +250 -0
- package/dist/codemods/additions/scripts/experimental-app-sdk.js +15 -14
- package/dist/codemods/utils.agents.js +64 -0
- package/dist/codemods/utils.js +40 -7
- package/dist/codemods/utils.workflows.js +94 -0
- package/dist/utils/utils.cli.js +3 -2
- package/dist/utils/utils.handlebars.js +9 -2
- package/package.json +1 -1
- package/src/codemods/additions/additions.ts +12 -0
- package/src/codemods/additions/prompts/docs-content.md +119 -0
- package/src/codemods/additions/scripts/docs-content.test.ts +94 -0
- package/src/codemods/additions/scripts/docs-content.ts +32 -0
- package/src/codemods/additions/scripts/docs.test.ts +1037 -0
- package/src/codemods/additions/scripts/docs.ts +353 -0
- package/src/codemods/additions/scripts/experimental-app-sdk.test.ts +4 -3
- package/src/codemods/additions/scripts/experimental-app-sdk.ts +15 -15
- package/src/codemods/migrations/scripts/004-eslint9-flat-config.ts +1 -3
- package/src/codemods/utils.agents.test.ts +140 -0
- package/src/codemods/utils.agents.ts +81 -0
- package/src/codemods/utils.test.ts +23 -0
- package/src/codemods/utils.ts +29 -7
- package/src/codemods/utils.workflows.test.ts +153 -0
- package/src/codemods/utils.workflows.ts +140 -0
- package/src/utils/utils.cli.ts +2 -1
- package/src/utils/utils.handlebars.ts +9 -0
- package/templates/app-sdk/.config/app-sdk/generate-kinds.mjs +1 -1
- package/templates/docs/common/workflows/validate-docs.yml +52 -0
- package/templates/docs/panel/docs/AGENTS.md +107 -0
- package/templates/docs/panel/docs/CLAUDE.md +1 -0
- package/templates/docs/panel/docs/README.md +20 -0
- package/templates/docs/panel/docs/data-formats.md +27 -0
- package/templates/docs/panel/docs/examples.md +35 -0
- package/templates/docs/panel/docs/index.md +26 -0
- package/templates/docs/panel/docs/options.md +53 -0
- package/templates/docs/panel/docs/troubleshooting.md +35 -0
|
@@ -0,0 +1,1037 @@
|
|
|
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 { getPackageManagerWithFallback } from '../../../utils/utils.packageManager.js';
|
|
10
|
+
import docs, { assertSupportedPluginType, schema, setupDocsScaffolding } from './docs.js';
|
|
11
|
+
|
|
12
|
+
// capture the real existsSync before mocking so we can delegate to it in beforeEach
|
|
13
|
+
const { existsSync: realExistsSync } = await vi.importActual<typeof import('node:fs')>('node:fs');
|
|
14
|
+
|
|
15
|
+
vi.mock('node:fs', async (importOriginal) => {
|
|
16
|
+
const mod = await importOriginal<typeof import('node:fs')>();
|
|
17
|
+
return {
|
|
18
|
+
...mod,
|
|
19
|
+
existsSync: vi.fn().mockImplementation(mod.existsSync),
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
vi.mock(import('../../../utils/utils.packageManager.js'), async (importOriginal) => ({
|
|
24
|
+
...(await importOriginal()),
|
|
25
|
+
getPackageManagerWithFallback: vi.fn(() => ({ packageManagerName: 'npm', packageManagerVersion: '10.9.0' })),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
function makeContext(
|
|
29
|
+
pluginJson: Record<string, unknown> = { type: 'panel', id: 'acme-test-panel', name: 'My Plugin' }
|
|
30
|
+
): Context {
|
|
31
|
+
const context = new Context('/virtual');
|
|
32
|
+
context.addFile('src/plugin.json', JSON.stringify(pluginJson));
|
|
33
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
34
|
+
context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n');
|
|
35
|
+
return context;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('docs scaffolding', () => {
|
|
39
|
+
const tempDirs: string[] = [];
|
|
40
|
+
|
|
41
|
+
// build a synthetic plugin-type template folder with the given docs template file contents
|
|
42
|
+
function makeTemplateDir(files: Record<string, string>): string {
|
|
43
|
+
const dir = mkdtempSync(join(tmpdir(), 'docs-templates-'));
|
|
44
|
+
tempDirs.push(dir);
|
|
45
|
+
mkdirSync(join(dir, 'docs'), { recursive: true });
|
|
46
|
+
for (const [relPath, content] of Object.entries(files)) {
|
|
47
|
+
const target = join(dir, 'docs', relPath);
|
|
48
|
+
mkdirSync(join(target, '..'), { recursive: true });
|
|
49
|
+
writeFileSync(target, content);
|
|
50
|
+
}
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// the templates every plugin type shares, mirroring templates/docs/common
|
|
55
|
+
function makeCommonTemplateDir(): string {
|
|
56
|
+
const dir = mkdtempSync(join(tmpdir(), 'docs-common-'));
|
|
57
|
+
tempDirs.push(dir);
|
|
58
|
+
mkdirSync(join(dir, 'workflows'), { recursive: true });
|
|
59
|
+
writeFileSync(join(dir, 'workflows', 'validate-docs.yml'), 'name: Validate documentation\n');
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
vi.mocked(existsSync).mockImplementation(realExistsSync);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
while (tempDirs.length > 0) {
|
|
69
|
+
const dir = tempDirs.pop();
|
|
70
|
+
if (dir) {
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
function call(context: Context, overrides: { templates?: Record<string, string>; docsPath?: string } = {}): void {
|
|
77
|
+
const templates = overrides.templates ?? { 'index.md': '# {{pluginName}}\n' };
|
|
78
|
+
setupDocsScaffolding({
|
|
79
|
+
context,
|
|
80
|
+
docsPath: overrides.docsPath ?? 'docs',
|
|
81
|
+
templateDir: makeTemplateDir(templates),
|
|
82
|
+
commonTemplateDir: makeCommonTemplateDir(),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('early exit', () => {
|
|
87
|
+
it('throws if docs directory already exists on disk', () => {
|
|
88
|
+
const root = mkdtempSync(join(tmpdir(), 'docs-exists-'));
|
|
89
|
+
tempDirs.push(root);
|
|
90
|
+
mkdirSync(join(root, 'docs'), { recursive: true });
|
|
91
|
+
const context = new Context(root);
|
|
92
|
+
context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin' }));
|
|
93
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
94
|
+
expect(() => call(context)).toThrow("A directory already exists at 'docs'");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('is idempotent', async () => {
|
|
99
|
+
const context = makeContext();
|
|
100
|
+
// shared across both runs so the second one reaches the skip-existing-file branches
|
|
101
|
+
const templateDir = makeTemplateDir({ 'index.md': '# {{pluginName}}\n' });
|
|
102
|
+
const commonTemplateDir = makeCommonTemplateDir();
|
|
103
|
+
const migrate = async (ctx: Context) =>
|
|
104
|
+
setupDocsScaffolding({ context: ctx, docsPath: 'docs', templateDir, commonTemplateDir });
|
|
105
|
+
|
|
106
|
+
await expect(migrate).toBeIdempotent(context);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('plugin.json step', () => {
|
|
110
|
+
it('adds docsPath to src/plugin.json', () => {
|
|
111
|
+
const context = makeContext();
|
|
112
|
+
call(context);
|
|
113
|
+
const parsed = JSON.parse(context.getFile('src/plugin.json') ?? '{}');
|
|
114
|
+
expect(parsed.docsPath).toBe('docs');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('throws if src/plugin.json is missing', () => {
|
|
118
|
+
const context = new Context('/virtual');
|
|
119
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
120
|
+
expect(() => call(context)).toThrow('Cannot find src/plugin.json');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('throws if src/plugin.json is not valid JSON', () => {
|
|
124
|
+
const context = new Context('/virtual');
|
|
125
|
+
context.addFile('src/plugin.json', '{ not valid json');
|
|
126
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
127
|
+
expect(() => call(context)).toThrow(/Cannot parse src\/plugin\.json/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('throws if src/plugin.json is not a JSON object', () => {
|
|
131
|
+
const context = new Context('/virtual');
|
|
132
|
+
context.addFile('src/plugin.json', JSON.stringify(['panel']));
|
|
133
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
134
|
+
expect(() => call(context)).toThrow(/does not contain a JSON object/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('throws when docsPath is already set to a different value', () => {
|
|
138
|
+
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin', docsPath: 'custom-docs' });
|
|
139
|
+
expect(() => call(context)).toThrow(/docsPath set to 'custom-docs'.*--docsPath custom-docs/s);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('uses a custom docsPath when specified', () => {
|
|
143
|
+
const context = makeContext();
|
|
144
|
+
call(context, { docsPath: 'my-docs' });
|
|
145
|
+
expect(context.doesFileExist('my-docs/index.md')).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('devDependency and scripts', () => {
|
|
150
|
+
it('adds @grafana/plugin-docs-cli to devDependencies', () => {
|
|
151
|
+
const context = makeContext();
|
|
152
|
+
call(context);
|
|
153
|
+
const pkg = JSON.parse(context.getFile('package.json') ?? '{}');
|
|
154
|
+
expect(pkg.devDependencies?.['@grafana/plugin-docs-cli']).toBe('^0.8.0');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('adds docs:serve and docs:validate scripts, with no separate release script', () => {
|
|
158
|
+
const context = makeContext();
|
|
159
|
+
call(context);
|
|
160
|
+
const pkg = JSON.parse(context.getFile('package.json') ?? '{}');
|
|
161
|
+
expect(pkg.scripts?.['docs:serve']).toBe('plugin-docs-cli serve --port 3001 --reload');
|
|
162
|
+
expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate');
|
|
163
|
+
expect(pkg.scripts?.['docs:validate:release']).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('leaves a docs:validate:release script from an earlier scaffold alone', () => {
|
|
167
|
+
const context = new Context('/virtual');
|
|
168
|
+
context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin' }));
|
|
169
|
+
context.addFile(
|
|
170
|
+
'package.json',
|
|
171
|
+
JSON.stringify({ scripts: { 'docs:validate:release': 'custom-command' }, devDependencies: {} })
|
|
172
|
+
);
|
|
173
|
+
context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n');
|
|
174
|
+
call(context);
|
|
175
|
+
const pkg = JSON.parse(context.getFile('package.json') ?? '{}');
|
|
176
|
+
expect(pkg.scripts?.['docs:validate:release']).toBe('custom-command');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('skips docs:serve if already present', () => {
|
|
180
|
+
const context = new Context('/virtual');
|
|
181
|
+
context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin' }));
|
|
182
|
+
context.addFile(
|
|
183
|
+
'package.json',
|
|
184
|
+
JSON.stringify({ scripts: { 'docs:serve': 'custom-command' }, devDependencies: {} })
|
|
185
|
+
);
|
|
186
|
+
context.addFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@v1.0.2\n');
|
|
187
|
+
call(context);
|
|
188
|
+
const pkg = JSON.parse(context.getFile('package.json') ?? '{}');
|
|
189
|
+
expect(pkg.scripts?.['docs:serve']).toBe('custom-command');
|
|
190
|
+
expect(pkg.scripts?.['docs:validate']).toBe('plugin-docs-cli validate');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('nested template directories', () => {
|
|
195
|
+
it('copies files nested more than one level deep', () => {
|
|
196
|
+
const context = makeContext();
|
|
197
|
+
call(context, { templates: { 'a/b/c.md': 'deeply nested\n' } });
|
|
198
|
+
expect(context.getFile('docs/a/b/c.md')).toBe('deeply nested\n');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('template copy', () => {
|
|
203
|
+
it('copies every template file under docs/', () => {
|
|
204
|
+
const context = makeContext();
|
|
205
|
+
call(context, {
|
|
206
|
+
templates: {
|
|
207
|
+
'index.md': '# Overview\n',
|
|
208
|
+
'configuration.md': '## Configure\n',
|
|
209
|
+
'nested/deep.md': 'nested\n',
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
expect(context.doesFileExist('docs/index.md')).toBe(true);
|
|
213
|
+
expect(context.doesFileExist('docs/configuration.md')).toBe(true);
|
|
214
|
+
expect(context.doesFileExist('docs/nested/deep.md')).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('interpolates {{pluginName}} in template content', () => {
|
|
218
|
+
const context = makeContext();
|
|
219
|
+
call(context, { templates: { 'index.md': '# {{pluginName}}\n' } });
|
|
220
|
+
expect(context.getFile('docs/index.md')).toContain('My Plugin');
|
|
221
|
+
expect(context.getFile('docs/index.md')).not.toContain('{{pluginName}}');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('skips a file already present in the context', () => {
|
|
225
|
+
// docsPath already set, so this docs/ folder is ours from a previous run, not a
|
|
226
|
+
// foreign one - the folder-exists guard must fall through rather than refuse.
|
|
227
|
+
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin', docsPath: 'docs' });
|
|
228
|
+
context.addFile('docs/index.md', '# Existing\n');
|
|
229
|
+
call(context, { templates: { 'index.md': '# Replacement\n' } });
|
|
230
|
+
expect(context.getFile('docs/index.md')).toBe('# Existing\n');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('throws if the docs template directory is missing (packaging bug guard)', () => {
|
|
234
|
+
const context = makeContext();
|
|
235
|
+
const dir = mkdtempSync(join(tmpdir(), 'docs-empty-'));
|
|
236
|
+
tempDirs.push(dir);
|
|
237
|
+
// no `docs/` subdirectory created under `dir` - simulates a broken build
|
|
238
|
+
expect(() =>
|
|
239
|
+
setupDocsScaffolding({
|
|
240
|
+
context,
|
|
241
|
+
docsPath: 'docs',
|
|
242
|
+
templateDir: dir,
|
|
243
|
+
commonTemplateDir: makeCommonTemplateDir(),
|
|
244
|
+
})
|
|
245
|
+
).toThrow(/Cannot find templates at .*docs/);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('validate-docs workflow', () => {
|
|
250
|
+
it('creates .github/workflows/validate-docs.yml', () => {
|
|
251
|
+
const context = makeContext();
|
|
252
|
+
call(context);
|
|
253
|
+
expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('interpolates {{docsPath}} into the workflow path filters', () => {
|
|
257
|
+
const context = makeContext();
|
|
258
|
+
call(context, { docsPath: 'my-docs' });
|
|
259
|
+
// the synthetic test template has no {{docsPath}} placeholder, so this
|
|
260
|
+
// only proves the substitution doesn't error - the real template is
|
|
261
|
+
// covered by index.test.ts against the shipped workflow content.
|
|
262
|
+
expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('does not overwrite an existing .github/workflows/validate-docs.yml', () => {
|
|
266
|
+
const context = makeContext();
|
|
267
|
+
context.addFile('.github/workflows/validate-docs.yml', 'old content');
|
|
268
|
+
call(context);
|
|
269
|
+
expect(context.getFile('.github/workflows/validate-docs.yml')).toBe('old content');
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('release.yml action bumps', () => {
|
|
274
|
+
function bumped(release: string): string | undefined {
|
|
275
|
+
const context = makeContext();
|
|
276
|
+
context.updateFile('.github/workflows/release.yml', release);
|
|
277
|
+
call(context);
|
|
278
|
+
return context.getFile('.github/workflows/release.yml');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
it('raises build-plugin to build-plugin/v1.2.0', () => {
|
|
282
|
+
expect(bumped('uses: grafana/plugin-actions/build-plugin@v1.0.2\n')).toBe(
|
|
283
|
+
'uses: grafana/plugin-actions/build-plugin@build-plugin/v1.2.0\n'
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('raises package-plugin to package-plugin/v1.3.3', () => {
|
|
288
|
+
expect(bumped('uses: grafana/plugin-actions/package-plugin@package-plugin/v1.3.2\n')).toBe(
|
|
289
|
+
'uses: grafana/plugin-actions/package-plugin@package-plugin/v1.3.3\n'
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('does not throw when release.yml does not exist', () => {
|
|
294
|
+
const context = new Context('/virtual');
|
|
295
|
+
context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin' }));
|
|
296
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
297
|
+
expect(() => call(context)).not.toThrow();
|
|
298
|
+
expect(context.doesFileExist('.github/workflows/release.yml')).toBe(false);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('assertSupportedPluginType', () => {
|
|
303
|
+
it('returns the plugin type when it is supported', () => {
|
|
304
|
+
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'X' });
|
|
305
|
+
expect(assertSupportedPluginType(context)).toBe('panel');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it.each(['app', 'datasource'])('reports %s as not supported yet', (type) => {
|
|
309
|
+
const context = makeContext({ type, name: 'X' });
|
|
310
|
+
expect(() => assertSupportedPluginType(context)).toThrow(
|
|
311
|
+
new RegExp(`does not support '${type}' plugins yet.*Supported so far: panel`, 's')
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('reports an unset type against the supported list', () => {
|
|
316
|
+
const context = makeContext({ name: 'X' });
|
|
317
|
+
expect(() => assertSupportedPluginType(context)).toThrow(/needs a plugin type of panel.*found 'unset'/s);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('reports an unrecognised type against the supported list', () => {
|
|
321
|
+
const context = makeContext({ type: 'renderer', name: 'X' });
|
|
322
|
+
expect(() => assertSupportedPluginType(context)).toThrow(/needs a plugin type of panel.*found 'renderer'/s);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('docs codemod', () => {
|
|
328
|
+
describe('validate-docs workflow package manager', () => {
|
|
329
|
+
// exercises the real shipped workflow template, so these assert on generated CI content
|
|
330
|
+
function workflowFor(packageManager: string): string {
|
|
331
|
+
const [packageManagerName, packageManagerVersion] = packageManager.split('@');
|
|
332
|
+
vi.mocked(getPackageManagerWithFallback).mockReturnValueOnce({ packageManagerName, packageManagerVersion });
|
|
333
|
+
const context = new Context('/virtual');
|
|
334
|
+
context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'P' }));
|
|
335
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
336
|
+
docs(context, { docsPath: 'docs' });
|
|
337
|
+
return context.getFile('.github/workflows/validate-docs.yml') ?? '';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
it('runs validation on every push rather than skipping while docs are unfinished', () => {
|
|
341
|
+
// unfilled stubs are warnings, so CI can check links and frontmatter from the first commit
|
|
342
|
+
// instead of waiting until the docs are done
|
|
343
|
+
const workflow = workflowFor('npm@10.2.3');
|
|
344
|
+
expect(workflow).not.toContain('section-brief');
|
|
345
|
+
expect(workflow).toContain('npm run docs:validate');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('runs the pinned CLI through the docs:validate script rather than fetching the latest', () => {
|
|
349
|
+
const workflow = workflowFor('npm@10.2.3');
|
|
350
|
+
expect(workflow).toContain('npm run docs:validate');
|
|
351
|
+
expect(workflow).not.toContain('npx');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it.each([
|
|
355
|
+
['npm@10.2.3', 'npm ci', 'npm'],
|
|
356
|
+
['pnpm@9.1.0', 'pnpm install --frozen-lockfile --prefer-offline', 'pnpm'],
|
|
357
|
+
['yarn@4.1.0', 'yarn install --immutable', 'yarn'],
|
|
358
|
+
])('uses the %s install command and cache', (packageManager, installCmd, name) => {
|
|
359
|
+
const workflow = workflowFor(packageManager);
|
|
360
|
+
expect(workflow).toContain(`run: ${installCmd}`);
|
|
361
|
+
expect(workflow).toContain(`cache: '${name}'`);
|
|
362
|
+
expect(workflow).toContain(`${name} run docs:validate`);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it.each([
|
|
366
|
+
['npm@10.2.3', 'package-lock.json'],
|
|
367
|
+
['pnpm@9.1.0', 'pnpm-lock.yaml'],
|
|
368
|
+
['yarn@4.1.0', 'yarn.lock'],
|
|
369
|
+
])(
|
|
370
|
+
'also runs for %s when package.json or %s changes, so a docs CLI bump is validated',
|
|
371
|
+
(packageManager, lockFile) => {
|
|
372
|
+
const { on } = parseYaml(workflowFor(packageManager));
|
|
373
|
+
for (const trigger of [on.push, on.pull_request]) {
|
|
374
|
+
expect(trigger.paths).toEqual(['docs/**', 'src/plugin.json', 'package.json', lockFile]);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
it('runs docs:validate directly, with no docsPath guard', () => {
|
|
380
|
+
const { jobs } = parseYaml(workflowFor('npm@10.2.3'));
|
|
381
|
+
expect(jobs.validate.steps.at(-1)).toEqual({
|
|
382
|
+
name: 'Validate plugin documentation',
|
|
383
|
+
run: 'npm run docs:validate',
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('adds the pnpm setup action only for pnpm', () => {
|
|
388
|
+
expect(workflowFor('pnpm@9.1.0')).toContain('pnpm/action-setup@v6');
|
|
389
|
+
expect(workflowFor('npm@10.2.3')).not.toContain('pnpm/action-setup');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('adds the pnpm setup step right after checkout, the way ci.yml does', () => {
|
|
393
|
+
expect(workflowFor('pnpm@9.1.0')).toContain(
|
|
394
|
+
' 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'
|
|
395
|
+
);
|
|
396
|
+
expect(workflowFor('npm@10.2.3')).toContain(
|
|
397
|
+
' persist-credentials: false\n - name: Setup Node.js environment\n'
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe('re-running on an adopted plugin', () => {
|
|
403
|
+
it('is additive when plugin.json already declares the same docsPath', () => {
|
|
404
|
+
const root = mkdtempSync(join(tmpdir(), 'docs-rerun-'));
|
|
405
|
+
mkdirSync(join(root, 'docs'), { recursive: true });
|
|
406
|
+
writeFileSync(join(root, 'docs', 'index.md'), '# Mine\n');
|
|
407
|
+
const context = new Context(root);
|
|
408
|
+
context.addFile(
|
|
409
|
+
'src/plugin.json',
|
|
410
|
+
JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'P', docsPath: 'docs' })
|
|
411
|
+
);
|
|
412
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
413
|
+
|
|
414
|
+
docs(context, { docsPath: 'docs' });
|
|
415
|
+
|
|
416
|
+
// the author's page is left untouched, and agent files can still be picked up after a bump
|
|
417
|
+
expect(context.listChanges()['docs/index.md']).toBeUndefined();
|
|
418
|
+
expect(context.doesFileExist('.config/AGENTS/instructions.md')).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('does not bring back a page the author deleted, but still adds missing meta files', () => {
|
|
422
|
+
const root = mkdtempSync(join(tmpdir(), 'docs-rerun-deleted-'));
|
|
423
|
+
mkdirSync(join(root, 'docs'), { recursive: true });
|
|
424
|
+
writeFileSync(join(root, 'docs', 'index.md'), '# Mine\n');
|
|
425
|
+
const context = new Context(root);
|
|
426
|
+
context.addFile(
|
|
427
|
+
'src/plugin.json',
|
|
428
|
+
JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'P', docsPath: 'docs' })
|
|
429
|
+
);
|
|
430
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
431
|
+
|
|
432
|
+
docs(context, { docsPath: 'docs' });
|
|
433
|
+
|
|
434
|
+
expect(context.doesFileExist('docs/troubleshooting.md')).toBe(false);
|
|
435
|
+
expect(context.doesFileExist('docs/options.md')).toBe(false);
|
|
436
|
+
for (const meta of ['README.md', 'AGENTS.md', 'CLAUDE.md']) {
|
|
437
|
+
expect(context.doesFileExist(`docs/${meta}`), meta).toBe(true);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('scaffolds the pages into an empty docs folder that plugin.json already declares', () => {
|
|
442
|
+
const root = mkdtempSync(join(tmpdir(), 'docs-rerun-empty-'));
|
|
443
|
+
mkdirSync(join(root, 'docs'), { recursive: true });
|
|
444
|
+
writeFileSync(join(root, 'docs', 'README.md'), '# Notes\n');
|
|
445
|
+
const context = new Context(root);
|
|
446
|
+
context.addFile(
|
|
447
|
+
'src/plugin.json',
|
|
448
|
+
JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'P', docsPath: 'docs' })
|
|
449
|
+
);
|
|
450
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
451
|
+
|
|
452
|
+
docs(context, { docsPath: 'docs' });
|
|
453
|
+
|
|
454
|
+
expect(context.doesFileExist('docs/index.md')).toBe(true);
|
|
455
|
+
expect(context.doesFileExist('docs/troubleshooting.md')).toBe(true);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it.each<string | number | null>(['./docs', 'docs/', 'docs/v1.0', null, 5])(
|
|
459
|
+
'refuses an existing docsPath of %s instead of suggesting a --docsPath the schema rejects',
|
|
460
|
+
(existingDocsPath) => {
|
|
461
|
+
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'P', docsPath: existingDocsPath });
|
|
462
|
+
expect(() => docs(context, { docsPath: 'catalog-docs' })).toThrow(
|
|
463
|
+
`src/plugin.json has docsPath set to '${existingDocsPath}', which create-plugin add docs can't use.`
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
it('scaffolds into "catalog-docs" when neither --docsPath nor plugin.json sets one', () => {
|
|
469
|
+
const context = makeContext();
|
|
470
|
+
docs(context, {});
|
|
471
|
+
expect(JSON.parse(context.getFile('src/plugin.json') ?? '{}').docsPath).toBe('catalog-docs');
|
|
472
|
+
expect(context.doesFileExist('catalog-docs/index.md')).toBe(true);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('reuses the docsPath in plugin.json when --docsPath is omitted', () => {
|
|
476
|
+
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'P', docsPath: 'docs' });
|
|
477
|
+
docs(context, {});
|
|
478
|
+
expect(context.doesFileExist('docs/AGENTS.md')).toBe(true);
|
|
479
|
+
expect(context.doesFileExist('catalog-docs/AGENTS.md')).toBe(false);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('counts pages that live only in subfolders, so it does not scaffold stubs next to them', () => {
|
|
483
|
+
const root = mkdtempSync(join(tmpdir(), 'docs-rerun-nested-'));
|
|
484
|
+
mkdirSync(join(root, 'docs', 'options'), { recursive: true });
|
|
485
|
+
writeFileSync(join(root, 'docs', 'options', 'index.md'), '# Options\n');
|
|
486
|
+
const context = new Context(root);
|
|
487
|
+
context.addFile(
|
|
488
|
+
'src/plugin.json',
|
|
489
|
+
JSON.stringify({ type: 'panel', id: 'acme-p-panel', name: 'P', docsPath: 'docs' })
|
|
490
|
+
);
|
|
491
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
492
|
+
|
|
493
|
+
docs(context, {});
|
|
494
|
+
|
|
495
|
+
expect(context.doesFileExist('docs/index.md')).toBe(false);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it.each(['CHANGELOG.md', 'readme.md', 'nested/LICENSE.md', 'node_modules/pkg/index.md', 'dist/index.md'])(
|
|
499
|
+
'does not count %s as a page, the same way plugin-docs-cli does not',
|
|
500
|
+
(file) => {
|
|
501
|
+
const root = mkdtempSync(join(tmpdir(), 'docs-rerun-meta-'));
|
|
502
|
+
mkdirSync(join(root, 'docs', file, '..'), { recursive: true });
|
|
503
|
+
writeFileSync(join(root, 'docs', file), '# Meta\n');
|
|
504
|
+
const context = new Context(root);
|
|
505
|
+
context.addFile(
|
|
506
|
+
'src/plugin.json',
|
|
507
|
+
JSON.stringify({ type: 'panel', id: 'acme-p-panel', name: 'P', docsPath: 'docs' })
|
|
508
|
+
);
|
|
509
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
510
|
+
|
|
511
|
+
docs(context, {});
|
|
512
|
+
|
|
513
|
+
expect(context.doesFileExist('docs/index.md')).toBe(true);
|
|
514
|
+
}
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
it('refuses a docs folder that plugin.json knows nothing about', () => {
|
|
518
|
+
const root = mkdtempSync(join(tmpdir(), 'docs-unknown-'));
|
|
519
|
+
mkdirSync(join(root, 'docs'), { recursive: true });
|
|
520
|
+
const context = new Context(root);
|
|
521
|
+
context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', id: 'acme-test-panel', name: 'P' }));
|
|
522
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
523
|
+
expect(() => docs(context, { docsPath: 'docs' })).toThrow(/has no docsPath/);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
describe('plugin.json requirements', () => {
|
|
528
|
+
it('refuses a plugin.json with no id, since the catalog links need it', () => {
|
|
529
|
+
const context = makeContext({ type: 'panel', name: 'P' });
|
|
530
|
+
expect(() => docs(context, {})).toThrow(/has no "id"/);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('refuses an empty id', () => {
|
|
534
|
+
const context = makeContext({ type: 'panel', id: '', name: 'P' });
|
|
535
|
+
expect(() => docs(context, {})).toThrow(/has no "id"/);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('refuses a plugin.json with no name rather than scaffolding docs about "my-plugin"', () => {
|
|
539
|
+
const context = new Context('/virtual');
|
|
540
|
+
context.addFile('src/plugin.json', JSON.stringify({ type: 'panel' }));
|
|
541
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
542
|
+
expect(() => docs(context, { docsPath: 'docs' })).toThrow(/has no "name"/);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('writes a plugin name with special characters as-is, not HTML-escaped', () => {
|
|
546
|
+
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: "Erik's A & B" });
|
|
547
|
+
docs(context, { docsPath: 'docs' });
|
|
548
|
+
const index = context.getFile('docs/index.md') ?? '';
|
|
549
|
+
expect(index).toContain(`description: "Where to start and what to read next in the Erik's A & B docs."`);
|
|
550
|
+
expect(index).not.toMatch(/&|'/);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('keeps every page frontmatter valid YAML when the plugin name contains YAML syntax', () => {
|
|
554
|
+
const name = 'Clock: "Analog" #2 \\ *';
|
|
555
|
+
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name });
|
|
556
|
+
docs(context, { docsPath: 'docs' });
|
|
557
|
+
const pages = ['index.md', 'options.md', 'data-formats.md', 'examples.md', 'troubleshooting.md'];
|
|
558
|
+
for (const page of pages) {
|
|
559
|
+
const frontmatter = /^---\n([\s\S]*?)\n---/.exec(context.getFile(`docs/${page}`) ?? '')?.[1] ?? '';
|
|
560
|
+
const { description } = parseYaml(frontmatter);
|
|
561
|
+
expect(description, page).toContain(name);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('gives the landing page a title that stays short whatever the plugin is called', () => {
|
|
566
|
+
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'A'.repeat(80) });
|
|
567
|
+
docs(context, { docsPath: 'docs' });
|
|
568
|
+
expect(context.getFile('docs/index.md')).toContain('\ntitle: Introduction\n');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('substitutes the plugin id into catalog URLs', () => {
|
|
572
|
+
const context = new Context('/virtual');
|
|
573
|
+
context.addFile('src/plugin.json', JSON.stringify({ type: 'panel', name: 'P', id: 'acme-p-panel' }));
|
|
574
|
+
context.addFile('package.json', JSON.stringify({ scripts: {}, devDependencies: {} }));
|
|
575
|
+
docs(context, { docsPath: 'docs' });
|
|
576
|
+
const readme = context.getFile('docs/README.md') ?? '';
|
|
577
|
+
expect(readme).toContain('grafana/plugins/acme-p-panel/');
|
|
578
|
+
expect(readme).not.toContain('<slug>');
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
describe('plugin type support', () => {
|
|
583
|
+
it.each(['app', 'datasource'])('refuses a %s plugin until its templates exist', (type) => {
|
|
584
|
+
const context = new Context('/virtual');
|
|
585
|
+
context.addFile('src/plugin.json', JSON.stringify({ type, name: 'X' }));
|
|
586
|
+
expect(() => docs(context, { docsPath: 'docs' })).toThrow(new RegExp(`does not support '${type}' plugins yet`));
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('refuses a plugin with no type set', () => {
|
|
590
|
+
const context = new Context('/virtual');
|
|
591
|
+
context.addFile('src/plugin.json', JSON.stringify({ name: 'X' }));
|
|
592
|
+
expect(() => docs(context, { docsPath: 'docs' })).toThrow(/needs a plugin type of panel/);
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
describe('docsPath validation', () => {
|
|
597
|
+
it('leaves docsPath unset when omitted, so the codemod can tell it from an explicit value', () => {
|
|
598
|
+
expect(v.parse(schema, {})).toEqual({});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('rejects an empty docsPath', () => {
|
|
602
|
+
expect(() => v.parse(schema, { docsPath: '' })).toThrow();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('rejects an absolute docsPath', () => {
|
|
606
|
+
expect(() => v.parse(schema, { docsPath: '/etc/passwd' })).toThrow();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('rejects a docsPath with ".." segments', () => {
|
|
610
|
+
expect(() => v.parse(schema, { docsPath: '../../etc' })).toThrow();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('accepts a valid custom docsPath', () => {
|
|
614
|
+
expect(v.parse(schema, { docsPath: 'documentation' })).toEqual({ docsPath: 'documentation' });
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('accepts a nested docsPath', () => {
|
|
618
|
+
expect(v.parse(schema, { docsPath: 'docs/plugin' })).toEqual({ docsPath: 'docs/plugin' });
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('rejects a leading "./"', () => {
|
|
622
|
+
expect(() => v.parse(schema, { docsPath: './docs' })).toThrow();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('rejects a trailing slash', () => {
|
|
626
|
+
expect(() => v.parse(schema, { docsPath: 'docs/' })).toThrow();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('rejects a backslash', () => {
|
|
630
|
+
expect(() => v.parse(schema, { docsPath: 'C:\\docs' })).toThrow();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('rejects a quote character', () => {
|
|
634
|
+
expect(() => v.parse(schema, { docsPath: "docs'" })).toThrow();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('ignores unrelated CLI flags that always ride along in argv', () => {
|
|
638
|
+
const parsed = v.parse(schema, { force: false, f: false, 'experimental-updates': false });
|
|
639
|
+
expect(parsed).toEqual({});
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
describe('generated files', () => {
|
|
644
|
+
it('creates all six panel docs files', () => {
|
|
645
|
+
const context = makeContext();
|
|
646
|
+
docs(context, { docsPath: 'docs' });
|
|
647
|
+
expect(context.doesFileExist('docs/index.md')).toBe(true);
|
|
648
|
+
expect(context.doesFileExist('docs/data-formats.md')).toBe(true);
|
|
649
|
+
expect(context.doesFileExist('docs/options.md')).toBe(true);
|
|
650
|
+
expect(context.doesFileExist('docs/examples.md')).toBe(true);
|
|
651
|
+
expect(context.doesFileExist('docs/troubleshooting.md')).toBe(true);
|
|
652
|
+
expect(context.doesFileExist('docs/README.md')).toBe(true);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('uses the expected H2s in each panel file', () => {
|
|
656
|
+
const context = makeContext();
|
|
657
|
+
docs(context, { docsPath: 'docs' });
|
|
658
|
+
expect(context.getFile('docs/data-formats.md') ?? '').toContain('## Supported data shape');
|
|
659
|
+
expect(context.getFile('docs/options.md') ?? '').toContain('## Panel options');
|
|
660
|
+
expect(context.getFile('docs/examples.md') ?? '').toContain('## Basic example');
|
|
661
|
+
expect(context.getFile('docs/troubleshooting.md') ?? '').toContain('## Get more detail about an error');
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('scaffolds section briefs as HTML comments that start with section-brief', () => {
|
|
665
|
+
const context = makeContext();
|
|
666
|
+
docs(context, { docsPath: 'docs' });
|
|
667
|
+
expect(context.getFile('docs/index.md') ?? '').toContain('<!-- section-brief\nFill this in:');
|
|
668
|
+
expect(context.getFile('docs/options.md') ?? '').toContain('<!-- section-brief\nFill this in:');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('closes every brief and never nests a comment inside one', () => {
|
|
672
|
+
const context = makeContext();
|
|
673
|
+
docs(context, { docsPath: 'docs' });
|
|
674
|
+
for (const page of ['index', 'options', 'data-formats', 'examples', 'troubleshooting']) {
|
|
675
|
+
const content = context.getFile(`docs/${page}.md`) ?? '';
|
|
676
|
+
const briefs = content.match(/<!-- section-brief\n[\s\S]*?-->/g) ?? [];
|
|
677
|
+
expect(briefs.length, page).toBeGreaterThan(0);
|
|
678
|
+
expect(content.match(/<!--/g)?.length, page).toBe(briefs.length);
|
|
679
|
+
expect(content, page).not.toContain('section-brief:');
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// text the scaffold keeps on purpose, which agents leave in place
|
|
684
|
+
const FIXED_TEXT: Record<string, string[]> = {
|
|
685
|
+
index: [
|
|
686
|
+
"- [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)",
|
|
687
|
+
],
|
|
688
|
+
options: [
|
|
689
|
+
'To change these options, edit the panel and use the options pane.',
|
|
690
|
+
'For what each standard option does, refer to [Configure standard options](https://grafana.com/docs/grafana/latest/panels-visualizations/configure-standard-options/).',
|
|
691
|
+
],
|
|
692
|
+
'data-formats': [
|
|
693
|
+
'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.',
|
|
694
|
+
'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.',
|
|
695
|
+
'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/).',
|
|
696
|
+
],
|
|
697
|
+
examples: [
|
|
698
|
+
'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**.',
|
|
699
|
+
'A panel excerpt in dashboard JSON has this shape:',
|
|
700
|
+
'```json\n{\n "type": "acme-test-panel",\n "options": {},\n "targets": []\n}\n```',
|
|
701
|
+
],
|
|
702
|
+
troubleshooting: [
|
|
703
|
+
'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).',
|
|
704
|
+
'To see the data the panel receives, open the panel editor, click **Query inspector** and select the **Data** tab.',
|
|
705
|
+
'For errors in the panel itself, open the developer tools in your browser and check the console.',
|
|
706
|
+
"If this page doesn't solve your problem:",
|
|
707
|
+
'- Search the [Grafana community forums](https://community.grafana.com/).\n- If your Grafana Cloud or Grafana Enterprise plan includes support, contact Grafana Support.',
|
|
708
|
+
'When you report an issue, include:',
|
|
709
|
+
'- 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.',
|
|
710
|
+
],
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
it('scaffolds pages where every section is only a brief or fixed text, so an untouched scaffold is all stubs', () => {
|
|
714
|
+
const context = makeContext();
|
|
715
|
+
docs(context, { docsPath: 'docs' });
|
|
716
|
+
for (const page of ['index', 'options', 'data-formats', 'examples', 'troubleshooting']) {
|
|
717
|
+
let left = (context.getFile(`docs/${page}.md`) ?? '')
|
|
718
|
+
.replace(/^---\n[\s\S]*?\n---\n/, '')
|
|
719
|
+
.replace(/<!-- section-brief\n[\s\S]*?-->/g, '')
|
|
720
|
+
.replace(/^#{1,6} .*$/gm, '');
|
|
721
|
+
for (const text of FIXED_TEXT[page] ?? []) {
|
|
722
|
+
expect(left, page).toContain(text);
|
|
723
|
+
left = left.replace(text, '');
|
|
724
|
+
}
|
|
725
|
+
expect(left.trim(), page).toBe('');
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('interpolates pluginName into the index page', () => {
|
|
730
|
+
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Panel' });
|
|
731
|
+
docs(context, { docsPath: 'docs' });
|
|
732
|
+
expect(context.getFile('docs/index.md') ?? '').toContain('My Panel');
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('writes the validate-docs workflow', () => {
|
|
736
|
+
const context = makeContext();
|
|
737
|
+
docs(context, { docsPath: 'docs' });
|
|
738
|
+
expect(context.doesFileExist('.github/workflows/validate-docs.yml')).toBe(true);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('interpolates a custom docsPath into the workflow path filters', () => {
|
|
742
|
+
const context = makeContext();
|
|
743
|
+
docs(context, { docsPath: 'documentation' });
|
|
744
|
+
const content = context.getFile('.github/workflows/validate-docs.yml') ?? '';
|
|
745
|
+
expect(content).toContain("'documentation/**'");
|
|
746
|
+
expect(content).not.toContain('{{docsPath}}');
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('bumps the build-plugin ref in release.yml', () => {
|
|
750
|
+
const context = makeContext();
|
|
751
|
+
docs(context, { docsPath: 'docs' });
|
|
752
|
+
expect(context.getFile('.github/workflows/release.yml') ?? '').toContain(
|
|
753
|
+
'grafana/plugin-actions/build-plugin@build-plugin/v1.2.0'
|
|
754
|
+
);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('options.md asks for the Panel options table format with the four expected columns', () => {
|
|
758
|
+
const context = makeContext();
|
|
759
|
+
docs(context, { docsPath: 'docs' });
|
|
760
|
+
const content = context.getFile('docs/options.md') ?? '';
|
|
761
|
+
expect(content).toContain('| Option | Type | Default | Description |');
|
|
762
|
+
expect(content).toContain('## Standard field options');
|
|
763
|
+
expect(content).toContain('## Custom field options');
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('scaffolds docs/README.md with panel-specific content', () => {
|
|
767
|
+
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Panel' });
|
|
768
|
+
docs(context, { docsPath: 'docs' });
|
|
769
|
+
const content = context.getFile('docs/README.md') ?? '';
|
|
770
|
+
expect(content).toContain('# My Panel documentation');
|
|
771
|
+
expect(content).toContain('grafana.com/developers/plugin-tools/how-to-guides/plugin-docs');
|
|
772
|
+
expect(content).toContain('npm run docs:validate');
|
|
773
|
+
expect(content).not.toContain('docs:validate:release');
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it('does not scaffold a docs/README.txt (legacy filename)', () => {
|
|
777
|
+
const context = makeContext();
|
|
778
|
+
docs(context, { docsPath: 'docs' });
|
|
779
|
+
expect(context.doesFileExist('docs/README.txt')).toBe(false);
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
describe('AI authoring assistance', () => {
|
|
784
|
+
it('does not scaffold any skills', () => {
|
|
785
|
+
const context = makeContext();
|
|
786
|
+
docs(context, { docsPath: 'docs' });
|
|
787
|
+
const skills = Object.keys(context.listChanges()).filter((file) => /(^|\/)skills\//.test(file));
|
|
788
|
+
expect(skills).toEqual([]);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it('scaffolds AGENTS.md in the docs folder, pointing at the guides shipped with plugin-docs-cli', () => {
|
|
792
|
+
const context = makeContext();
|
|
793
|
+
docs(context, { docsPath: 'docs' });
|
|
794
|
+
const content = context.getFile('docs/AGENTS.md') ?? '';
|
|
795
|
+
expect(content).toContain('Docs guides:');
|
|
796
|
+
expect(content).toContain('node_modules/@grafana/plugin-docs-cli/docs/index.md');
|
|
797
|
+
expect(content).toContain('npm run docs:validate');
|
|
798
|
+
expect(content).toContain('`unfilled-section-brief` suggestion');
|
|
799
|
+
expect(content).not.toContain('docs:validate:release');
|
|
800
|
+
expect(content).toContain('## Pages and their source');
|
|
801
|
+
expect(content).toContain('## Section briefs');
|
|
802
|
+
expect(content).not.toContain('{{');
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('lists the conventional filenames for optional pages in the docs AGENTS.md', () => {
|
|
806
|
+
const context = makeContext();
|
|
807
|
+
docs(context, { docsPath: 'docs' });
|
|
808
|
+
const content = context.getFile('docs/AGENTS.md') ?? '';
|
|
809
|
+
expect(content).toContain('## Optional pages');
|
|
810
|
+
for (const page of ['presets.md', 'permissions.md', 'accessibility.md']) {
|
|
811
|
+
expect(content).toContain(`\`${page}\``);
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('scaffolds CLAUDE.md in the docs folder as an import of AGENTS.md', () => {
|
|
816
|
+
const context = makeContext();
|
|
817
|
+
docs(context, { docsPath: 'docs' });
|
|
818
|
+
expect(context.getFile('docs/CLAUDE.md')).toEqual('@AGENTS.md\n');
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('no longer scaffolds an authoring guide under .config', () => {
|
|
822
|
+
const context = makeContext();
|
|
823
|
+
docs(context, { docsPath: 'docs' });
|
|
824
|
+
expect(context.doesFileExist('.config/AGENTS/plugin-docs.md')).toBe(false);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it('scaffolds the docs AGENTS.md under the configured docsPath', () => {
|
|
828
|
+
const context = makeContext();
|
|
829
|
+
docs(context, { docsPath: 'documentation' });
|
|
830
|
+
expect(context.doesFileExist('documentation/AGENTS.md')).toBe(true);
|
|
831
|
+
expect(context.doesFileExist('docs/AGENTS.md')).toBe(false);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it('does not overwrite an existing customized docs AGENTS.md', () => {
|
|
835
|
+
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Plugin', docsPath: 'docs' });
|
|
836
|
+
context.addFile('docs/AGENTS.md', 'CUSTOMIZED');
|
|
837
|
+
docs(context, { docsPath: 'docs' });
|
|
838
|
+
expect(context.getFile('docs/AGENTS.md')).toEqual('CUSTOMIZED');
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('appends a Plugin docs section to .config/AGENTS/instructions.md when present', () => {
|
|
842
|
+
const context = makeContext();
|
|
843
|
+
context.addFile('.config/AGENTS/instructions.md', '# Existing instructions\n\nDo the thing.\n');
|
|
844
|
+
docs(context, { docsPath: 'docs' });
|
|
845
|
+
expect(context.getFile('.config/AGENTS/instructions.md')).toBe(
|
|
846
|
+
'# 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'
|
|
847
|
+
);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it('names the configured docsPath in the Plugin docs section', () => {
|
|
851
|
+
const context = makeContext();
|
|
852
|
+
context.addFile('.config/AGENTS/instructions.md', '# Existing instructions\n');
|
|
853
|
+
docs(context, { docsPath: 'documentation' });
|
|
854
|
+
const content = context.getFile('.config/AGENTS/instructions.md') ?? '';
|
|
855
|
+
expect(content).toContain('under `documentation/`');
|
|
856
|
+
expect(content).toContain('[`documentation/AGENTS.md`](../../documentation/AGENTS.md)');
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it.each([
|
|
860
|
+
['a re-run', '## Documentation\n\nRefer to [`docs/AGENTS.md`](./docs/AGENTS.md) for instructions.\n'],
|
|
861
|
+
['an older one-line pointer', '- Read `docs/AGENTS.md` before writing or modifying plugin documentation.\n'],
|
|
862
|
+
])('does not add a second pointer after %s', (_, pointer) => {
|
|
863
|
+
const context = makeContext();
|
|
864
|
+
const original = `# Existing\n\n${pointer}`;
|
|
865
|
+
context.addFile('.config/AGENTS/instructions.md', original);
|
|
866
|
+
docs(context, { docsPath: 'docs' });
|
|
867
|
+
expect(context.getFile('.config/AGENTS/instructions.md')).toBe(original);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('creates .config/AGENTS/instructions.md with the Plugin docs section when it is absent', () => {
|
|
871
|
+
const context = makeContext();
|
|
872
|
+
docs(context, { docsPath: 'docs' });
|
|
873
|
+
const content = context.getFile('.config/AGENTS/instructions.md') ?? '';
|
|
874
|
+
expect(content).toContain('This repository contains a **Grafana plugin**');
|
|
875
|
+
expect(content).toContain('\n\n## Plugin docs\n\n');
|
|
876
|
+
expect(content).toContain('[`docs/AGENTS.md`](../../docs/AGENTS.md)');
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it('creates root AGENTS.md, CLAUDE.md and GEMINI.md when absent, pointing at .config/AGENTS/instructions.md', () => {
|
|
880
|
+
const context = makeContext();
|
|
881
|
+
docs(context, { docsPath: 'docs' });
|
|
882
|
+
for (const filename of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
|
|
883
|
+
const content = context.getFile(filename) ?? '';
|
|
884
|
+
expect(content).toContain('.config/AGENTS/instructions.md');
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('keeps existing content in root AGENTS.md, CLAUDE.md and GEMINI.md, appending a pointer to instructions.md', () => {
|
|
889
|
+
const context = makeContext();
|
|
890
|
+
context.addFile('AGENTS.md', 'CUSTOM AGENTS');
|
|
891
|
+
context.addFile('CLAUDE.md', 'CUSTOM CLAUDE');
|
|
892
|
+
context.addFile('GEMINI.md', 'CUSTOM GEMINI');
|
|
893
|
+
docs(context, { docsPath: 'docs' });
|
|
894
|
+
for (const [filename, original] of [
|
|
895
|
+
['AGENTS.md', 'CUSTOM AGENTS'],
|
|
896
|
+
['CLAUDE.md', 'CUSTOM CLAUDE'],
|
|
897
|
+
['GEMINI.md', 'CUSTOM GEMINI'],
|
|
898
|
+
] as const) {
|
|
899
|
+
const content = context.getFile(filename) ?? '';
|
|
900
|
+
expect(content).toContain(original);
|
|
901
|
+
expect(content).toContain('.config/AGENTS/instructions.md');
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('creates the e2e-testing.md that instructions.md imports, rendered for the plugin', () => {
|
|
906
|
+
const context = makeContext();
|
|
907
|
+
docs(context, { docsPath: 'docs' });
|
|
908
|
+
expect(context.getFile('.config/AGENTS/instructions.md')).toContain('@./.config/AGENTS/e2e-testing.md');
|
|
909
|
+
const content = context.getFile('.config/AGENTS/e2e-testing.md') ?? '';
|
|
910
|
+
expect(content).toContain('npm run e2e');
|
|
911
|
+
expect(content).not.toContain('{{');
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it('leaves the rest of .config/AGENTS alone when instructions.md already exists', () => {
|
|
915
|
+
const context = makeContext();
|
|
916
|
+
context.addFile('.config/AGENTS/instructions.md', '# Existing instructions\n');
|
|
917
|
+
docs(context, { docsPath: 'docs' });
|
|
918
|
+
expect(context.doesFileExist('.config/AGENTS/e2e-testing.md')).toBe(false);
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
it.each([
|
|
922
|
+
['npm@10.2.3', 'npx -y @grafana/create-plugin@latest add docs-content'],
|
|
923
|
+
['pnpm@9.1.0', 'pnpm dlx @grafana/create-plugin@latest add docs-content'],
|
|
924
|
+
['yarn@4.1.0', 'yarn dlx @grafana/create-plugin@latest add docs-content'],
|
|
925
|
+
])('points docs/README.md at docs-content with the %s exec command', (packageManager, command) => {
|
|
926
|
+
const [packageManagerName, packageManagerVersion] = packageManager.split('@');
|
|
927
|
+
vi.mocked(getPackageManagerWithFallback).mockReturnValueOnce({ packageManagerName, packageManagerVersion });
|
|
928
|
+
const context = makeContext();
|
|
929
|
+
docs(context, { docsPath: 'docs' });
|
|
930
|
+
const content = context.getFile('docs/README.md') ?? '';
|
|
931
|
+
expect(content).toContain(`\`${command}\``);
|
|
932
|
+
expect(content).not.toContain('bootstrap-plugin-docs');
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
describe('scaffold wording', () => {
|
|
937
|
+
it('never refers to strict mode, the unfilled-stubs flag or a release script', () => {
|
|
938
|
+
const context = makeContext();
|
|
939
|
+
docs(context, { docsPath: 'docs' });
|
|
940
|
+
const stale = /--strict|--no-strict|allow-unfilled|validate:release|strict mode/;
|
|
941
|
+
// .config/AGENTS is the shared create-plugin template, which has its own wording
|
|
942
|
+
const files = Object.keys(context.listChanges()).filter((file) => !file.startsWith('.config/'));
|
|
943
|
+
expect(files.length).toBeGreaterThan(10);
|
|
944
|
+
for (const file of files) {
|
|
945
|
+
expect(context.getFile(file) ?? '', file).not.toMatch(stale);
|
|
946
|
+
}
|
|
947
|
+
expect((context.getMessage()?.body ?? []).join('\n')).not.toMatch(stale);
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
describe('next steps', () => {
|
|
952
|
+
function nextSteps(context: Context): string[] {
|
|
953
|
+
docs(context, { docsPath: 'docs' });
|
|
954
|
+
return (context.getMessage()?.body ?? []).map((line) => stripVTControlCharacters(line));
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
it('numbers the steps in the order the author does them', () => {
|
|
958
|
+
expect(nextSteps(makeContext()).filter((line) => /^ \d\. /.test(line))).toEqual([
|
|
959
|
+
' 1. Read docs/README.md',
|
|
960
|
+
' 2. Write the pages, yourself or with an AI agent',
|
|
961
|
+
' 3. Preview the pages',
|
|
962
|
+
]);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it('tells the author to commit before running docs-content, since add needs a clean tree', () => {
|
|
966
|
+
const body = nextSteps(makeContext());
|
|
967
|
+
const step = body.indexOf(' 2. Write the pages, yourself or with an AI agent');
|
|
968
|
+
expect(body.slice(step + 1, step + 5)).toEqual([
|
|
969
|
+
' Fill in or review the pages under docs/ by hand.',
|
|
970
|
+
' Or commit these changes, then have an agent draft them from your source code and README:',
|
|
971
|
+
' npx -y @grafana/create-plugin@latest add docs-content',
|
|
972
|
+
' Review its work either way.',
|
|
973
|
+
]);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
it('indents each detail line under its step', () => {
|
|
977
|
+
const body = nextSteps(makeContext());
|
|
978
|
+
const step = body.indexOf(' 3. Preview the pages');
|
|
979
|
+
expect(body[step + 1]).toBe(' Run npm run docs:serve, which also lists every section left to fill.');
|
|
980
|
+
expect(body.join('\n')).not.toContain('docs:validate:release');
|
|
981
|
+
expect(body.join('\n')).not.toContain('strict');
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it('leaves out the attention section when there is nothing to check', () => {
|
|
985
|
+
expect(nextSteps(makeContext())).not.toContain('Needs your attention:');
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it('says nothing about agent files, since root files that miss instructions.md get fixed instead of flagged', () => {
|
|
989
|
+
const context = makeContext();
|
|
990
|
+
context.addFile('CLAUDE.md', '# My own instructions\n\nRead docs/AGENTS.md for docs.\n');
|
|
991
|
+
context.addFile('GEMINI.md', '# Gemini notes\n');
|
|
992
|
+
expect(nextSteps(context).join('\n')).not.toContain("won't find docs/AGENTS.md");
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
it('reports each existing root agent file it appended the instructions pointer to', () => {
|
|
996
|
+
const context = makeContext();
|
|
997
|
+
context.addFile('CLAUDE.md', '# My own instructions\n');
|
|
998
|
+
const body = nextSteps(context);
|
|
999
|
+
expect(body).toContain('Needs your attention:');
|
|
1000
|
+
expect(body).toContain(' ! Added a pointer to .config/AGENTS/instructions.md at the end of CLAUDE.md');
|
|
1001
|
+
expect(body.join('\n')).not.toContain('end of AGENTS.md');
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it('says nothing about the release pipeline when build-plugin was bumped', () => {
|
|
1005
|
+
expect(nextSteps(makeContext()).join('\n')).not.toContain('build-plugin');
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it('asks to check the release pipeline when release.yml has no build-plugin step', () => {
|
|
1009
|
+
const context = makeContext();
|
|
1010
|
+
context.updateFile('.github/workflows/release.yml', 'name: Release\n');
|
|
1011
|
+
expect(nextSteps(context).join('\n')).toContain('Make sure your release pipeline builds docs/');
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
it('asks to check a build-plugin ref pinned to a branch or commit', () => {
|
|
1015
|
+
const context = makeContext();
|
|
1016
|
+
context.updateFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@my-branch\n');
|
|
1017
|
+
expect(nextSteps(context).join('\n')).toContain('Docs need build-plugin/v1.2.0 or later.');
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it('asks to check a package-plugin ref pinned to a commit', () => {
|
|
1021
|
+
const context = makeContext();
|
|
1022
|
+
context.updateFile(
|
|
1023
|
+
'.github/workflows/release.yml',
|
|
1024
|
+
'uses: grafana/plugin-actions/package-plugin@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n'
|
|
1025
|
+
);
|
|
1026
|
+
const body = nextSteps(context).join('\n');
|
|
1027
|
+
expect(body).toContain('Docs need package-plugin/v1.3.3 or later.');
|
|
1028
|
+
expect(body).not.toContain('Make sure your release pipeline');
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it('says nothing about an action on main, which always runs the latest release', () => {
|
|
1032
|
+
const context = makeContext();
|
|
1033
|
+
context.updateFile('.github/workflows/release.yml', 'uses: grafana/plugin-actions/build-plugin@main\n');
|
|
1034
|
+
expect(nextSteps(context).join('\n')).not.toContain('build-plugin');
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
1037
|
+
});
|