@grafana/create-plugin 7.14.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.
- package/CHANGELOG.md +8 -0
- package/dist/codemods/additions/prompts/docs-content.md +24 -12
- package/dist/codemods/additions/scripts/docs.js +229 -11
- package/package.json +1 -1
- package/src/codemods/additions/prompts/docs-content.md +24 -12
- package/src/codemods/additions/scripts/docs-content.test.ts +11 -2
- package/src/codemods/additions/scripts/docs.test.ts +793 -64
- package/src/codemods/additions/scripts/docs.ts +354 -10
- package/templates/docs/app/docs/AGENTS.md +127 -0
- package/templates/docs/app/docs/CLAUDE.md +1 -0
- package/templates/docs/app/docs/README.md +20 -0
- package/templates/docs/app/docs/configure.md +65 -0
- package/templates/docs/app/docs/dashboards.md +29 -0
- package/templates/docs/app/docs/index.md +70 -0
- package/templates/docs/app/docs/permissions.md +45 -0
- package/templates/docs/app/docs/troubleshooting.md +65 -0
- package/templates/docs/app/feature-page.md +26 -0
- package/templates/docs/app/features-index.md +12 -0
- package/templates/docs/datasource/docs/AGENTS.md +123 -0
- package/templates/docs/datasource/docs/CLAUDE.md +1 -0
- package/templates/docs/datasource/docs/README.md +20 -0
- package/templates/docs/datasource/docs/alerting.md +56 -0
- package/templates/docs/datasource/docs/annotations.md +68 -0
- package/templates/docs/datasource/docs/configure.md +114 -0
- package/templates/docs/datasource/docs/dashboards.md +28 -0
- package/templates/docs/datasource/docs/index.md +40 -0
- package/templates/docs/datasource/docs/query-editor.md +72 -0
- package/templates/docs/datasource/docs/template-variables.md +69 -0
- package/templates/docs/datasource/docs/troubleshooting.md +73 -0
- package/templates/docs/panel/docs/AGENTS.md +6 -0
|
@@ -6,6 +6,7 @@ import * as v from 'valibot';
|
|
|
6
6
|
import { parse as parseYaml } from 'yaml';
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
8
|
import { Context } from '../../context.js';
|
|
9
|
+
import { flushChanges } from '../../utils.js';
|
|
9
10
|
import { getPackageManagerWithFallback } from '../../../utils/utils.packageManager.js';
|
|
10
11
|
import docs, { assertSupportedPluginType, schema, setupDocsScaffolding } from './docs.js';
|
|
11
12
|
|
|
@@ -35,6 +36,76 @@ function makeContext(
|
|
|
35
36
|
return context;
|
|
36
37
|
}
|
|
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
|
+
|
|
38
109
|
describe('docs scaffolding', () => {
|
|
39
110
|
const tempDirs: string[] = [];
|
|
40
111
|
|
|
@@ -305,11 +376,14 @@ describe('docs scaffolding', () => {
|
|
|
305
376
|
expect(assertSupportedPluginType(context)).toBe('panel');
|
|
306
377
|
});
|
|
307
378
|
|
|
308
|
-
it
|
|
309
|
-
const context = makeContext({ type, name: 'X' });
|
|
310
|
-
expect(
|
|
311
|
-
|
|
312
|
-
|
|
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');
|
|
313
387
|
});
|
|
314
388
|
|
|
315
389
|
it('reports an unset type against the supported list', () => {
|
|
@@ -550,17 +624,22 @@ describe('docs codemod', () => {
|
|
|
550
624
|
expect(index).not.toMatch(/&|'/);
|
|
551
625
|
});
|
|
552
626
|
|
|
553
|
-
it(
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
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
|
+
}
|
|
562
641
|
}
|
|
563
|
-
|
|
642
|
+
);
|
|
564
643
|
|
|
565
644
|
it('gives the landing page a title that stays short whatever the plugin is called', () => {
|
|
566
645
|
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'A'.repeat(80) });
|
|
@@ -580,12 +659,6 @@ describe('docs codemod', () => {
|
|
|
580
659
|
});
|
|
581
660
|
|
|
582
661
|
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
662
|
it('refuses a plugin with no type set', () => {
|
|
590
663
|
const context = new Context('/virtual');
|
|
591
664
|
context.addFile('src/plugin.json', JSON.stringify({ name: 'X' }));
|
|
@@ -668,10 +741,10 @@ describe('docs codemod', () => {
|
|
|
668
741
|
expect(context.getFile('docs/options.md') ?? '').toContain('<!-- section-brief\nFill this in:');
|
|
669
742
|
});
|
|
670
743
|
|
|
671
|
-
it('closes every brief and never nests a comment inside one', () => {
|
|
672
|
-
const context = makeContext();
|
|
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]);
|
|
673
746
|
docs(context, { docsPath: 'docs' });
|
|
674
|
-
for (const page of [
|
|
747
|
+
for (const page of PAGES_BY_TYPE[type]) {
|
|
675
748
|
const content = context.getFile(`docs/${page}.md`) ?? '';
|
|
676
749
|
const briefs = content.match(/<!-- section-brief\n[\s\S]*?-->/g) ?? [];
|
|
677
750
|
expect(briefs.length, page).toBeGreaterThan(0);
|
|
@@ -681,50 +754,146 @@ describe('docs codemod', () => {
|
|
|
681
754
|
});
|
|
682
755
|
|
|
683
756
|
// text the scaffold keeps on purpose, which agents leave in place
|
|
684
|
-
const FIXED_TEXT: Record<string, string[]
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
'
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
+
},
|
|
711
877
|
};
|
|
712
878
|
|
|
713
|
-
it(
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
.
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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('');
|
|
724
894
|
}
|
|
725
|
-
expect(left.trim(), page).toBe('');
|
|
726
895
|
}
|
|
727
|
-
|
|
896
|
+
);
|
|
728
897
|
|
|
729
898
|
it('interpolates pluginName into the index page', () => {
|
|
730
899
|
const context = makeContext({ type: 'panel', id: 'acme-test-panel', name: 'My Panel' });
|
|
@@ -780,6 +949,557 @@ describe('docs codemod', () => {
|
|
|
780
949
|
});
|
|
781
950
|
});
|
|
782
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
|
+
|
|
783
1503
|
describe('AI authoring assistance', () => {
|
|
784
1504
|
it('does not scaffold any skills', () => {
|
|
785
1505
|
const context = makeContext();
|
|
@@ -812,6 +1532,15 @@ describe('docs codemod', () => {
|
|
|
812
1532
|
}
|
|
813
1533
|
});
|
|
814
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
|
+
|
|
815
1544
|
it('scaffolds CLAUDE.md in the docs folder as an import of AGENTS.md', () => {
|
|
816
1545
|
const context = makeContext();
|
|
817
1546
|
docs(context, { docsPath: 'docs' });
|