Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions e2e/tests/regression/plugin.docs-link.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { routesPom } from '@e2e/pom/routes';
import { e2eReq } from '@e2e/utils/req';
import { test } from '@e2e/utils/test';
import { expect } from '@playwright/test';

import { deleteAllRoutes } from '@/apis/routes';

// #3461: the plugin editor never said what a plugin does. The gateway
// carries no description for any plugin (0 of 106 have a
// `schema.description`), so the docs site is the only source, and its URL
// is derivable from the plugin name.

const DOCS = 'https://apisix.apache.org';

test.beforeAll(async () => {
await deleteAllRoutes(e2eReq);
});

test.afterEach(async ({ page }) => {
await page
.evaluate(() => localStorage.removeItem('settings:lang'))
.catch(() => {});
});

test.afterAll(async () => {
await deleteAllRoutes(e2eReq);
});

const openPicker = async (page: import('@playwright/test').Page) => {
await routesPom.toAdd(page);
await routesPom.isAddPage(page);
await page.getByRole('button', { name: 'Select Plugins' }).click();
const picker = page.getByRole('dialog', { name: 'Select Plugins' });
await expect(picker).toBeVisible();
return picker;
};

const openPluginDrawer = async (
page: import('@playwright/test').Page,
plugin: string
) => {
const picker = await openPicker(page);
await picker.getByPlaceholder('Search').fill(plugin);
await picker
.getByTestId(`plugin-${plugin}`)
.getByRole('button', { name: 'Add' })
.click();
const drawer = page.getByRole('dialog', { name: 'Add Plugin' });
await expect(drawer).toBeVisible();
return drawer;
};

test('the plugin editor links that plugin documentation', async ({ page }) => {
const drawer = await openPluginDrawer(page, 'key-auth');

// getByRole('link') is the load-bearing part: it fails if the control
// ever becomes a <button href>, which cannot be cmd-clicked or copied.
const link = drawer.getByRole('link', { name: 'Docs' });
await expect(link).toHaveAttribute(
'href',
`${DOCS}/docs/apisix/plugins/key-auth/`
);
await expect(link).toHaveAttribute('target', '_blank');
});

test('a plugin with no documentation page gets no link', async ({ page }) => {
const drawer = await openPluginDrawer(page, 'example-plugin');

// example-plugin is the gateway's sample plugin and has no docs page.
// A link here would be a broken link, which is worse than none.
await expect(drawer.getByRole('link', { name: 'Docs' })).toHaveCount(0);
});

test('the documentation link follows the UI language', async ({ page }) => {
await routesPom.toIndex(page);
await routesPom.isIndexPage(page);
await page.locator('button[aria-haspopup="menu"]').click();
await page.getByRole('menuitem', { name: '中文' }).click();
await expect(
page.getByRole('link', { name: '路由', exact: true })
).toBeVisible();

await routesPom.toAdd(page);
await page.getByRole('button', { name: '选择插件' }).click();
const picker = page.getByRole('dialog', { name: '选择插件' });
await picker.getByPlaceholder('搜索').fill('key-auth');
await picker
.getByTestId('plugin-key-auth')
.getByRole('button', { name: '新增' })
.click();
const drawer = page.getByRole('dialog', { name: '添加插件' });
await expect(drawer.getByRole('link', { name: '文档' })).toHaveAttribute(
'href',
`${DOCS}/zh/docs/apisix/plugins/key-auth/`
);
});

test('a picker card links that plugin documentation', async ({ page }) => {
const picker = await openPicker(page);
await picker.getByPlaceholder('Search').fill('key-auth');

// Icon-only: the accessible name is what tells a screen reader which of
// a hundred identical icons this one belongs to.
const link = picker
.getByTestId('plugin-key-auth')
.getByRole('link', { name: 'key-auth documentation' });
await expect(link).toHaveAttribute(
'href',
`${DOCS}/docs/apisix/plugins/key-auth/`
);
});

test('a picker card for an undocumented plugin has no link', async ({
page,
}) => {
const picker = await openPicker(page);
await picker.getByPlaceholder('Search').fill('example-plugin');
await expect(
picker.getByTestId('plugin-example-plugin').getByRole('link')
).toHaveCount(0);
});
9 changes: 5 additions & 4 deletions src/components/form-slice/FormItemPlugins/PluginCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { Button, Card,Group, Text } from '@mantine/core';
import { modals } from '@mantine/modals';
import { useTranslation } from 'react-i18next';

import { PluginDocsLink } from './PluginDocsLink';

export type PluginCardProps = {
name: string;
desc?: string;
Expand All @@ -34,10 +36,9 @@ export const PluginCard = (props: PluginCardProps) => {
return (
<Card withBorder radius="md" p="md" data-testid={`plugin-${name}`}>
<Card.Section withBorder inheritPadding py="xs">
<Group justify="space-between">
<Group>
<Text fw={500}>{name}</Text>
</Group>
<Group justify="space-between" wrap="nowrap">
<Text fw={500}>{name}</Text>
<PluginDocsLink name={name} />
</Group>
</Card.Section>

Expand Down
77 changes: 77 additions & 0 deletions src/components/form-slice/FormItemPlugins/PluginDocsLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Anchor, Group, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';

import { getPluginDocsUrl } from '@/utils/pluginDocs';
import IconExternalLink from '~icons/tabler/external-link';

export type PluginDocsLinkProps = {
name: string;
/** Show the visible label beside the icon. Icon-only otherwise. */
withLabel?: boolean;
};

/**
* A real `<a>`, not the repo's `RouteLinkBtn`, which is a Mantine `Button`
* carrying `href`: middle-click, cmd-click and "copy link address" are
* exactly what someone reaching for documentation does, and none of them
* work on a `<button>`.
*
* Renders nothing when the plugin has no page on the released docs —
* a missing affordance beats a broken link.
*/
export const PluginDocsLink = (props: PluginDocsLinkProps) => {
const { name, withLabel = false } = props;
const { t, i18n } = useTranslation();

const href = getPluginDocsUrl(name, i18n.language);
if (!href) return null;

// i18next escapes interpolated values by default; #3459 shipped an
// accessible name reading `R&amp;D` before that was caught. Plugin names
// are gateway-controlled and hyphenated, so this is prophylactic.
const named = t('form.plugins.docsFor', {
name,
interpolation: { escapeValue: false },
});

const link = (
<Anchor
href={href}
target="_blank"
rel="noopener noreferrer"
underline="never"
size="sm"
// With a visible label the label IS the accessible name; overriding
// it with text that does not contain the visible label would break
// WCAG 2.5.3. Icon-only has no visible text, so it needs one.
{...(!withLabel && { 'aria-label': named })}
>
{withLabel ? (
<Group component="span" gap={4} wrap="nowrap">
<IconExternalLink aria-hidden focusable="false" />
{t('docs')}
</Group>
) : (
<IconExternalLink aria-hidden focusable="false" />
)}
</Anchor>
);

return withLabel ? link : <Tooltip label={named}>{link}</Tooltip>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { FormSubmitBtn } from '@/components/form/Btn';
import { FormItemEditor } from '@/components/form/Editor';

import type { PluginCardListProps } from './PluginCardList';
import { PluginDocsLink } from './PluginDocsLink';
import { redactByPaths } from './redact';

// PluginConfig is defined in the API layer (apis/plugins) and re-exported
Expand Down Expand Up @@ -113,9 +114,10 @@ export const PluginEditorDrawer = (props: PluginEditorDrawerProps) => {
{...(mode === 'edit' && { title: t('form.plugins.editPlugin') })}
{...(mode === 'view' && { title: t('form.plugins.viewPlugin') })}
>
<Title order={3} mb={10}>
{name}
</Title>
<Group gap="xs" mb={10} align="center">
<Title order={3}>{name}</Title>
<PluginDocsLink name={name} withLabel />
</Group>
<FormProvider {...methods}>
{mode === 'view' && hasSecrets && (
<Button
Expand Down
1 change: 1 addition & 0 deletions src/locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"plugins": {
"addPlugin": "Plugin hinzufügen",
"configId": "Plugin-Konfigurations-ID",
"docsFor": "Dokumentation zu {{name}}",
"editPlugin": "Plugin bearbeiten",
"hideSecrets": "Geheimnisse verbergen",
"label": "Plugins",
Expand Down
1 change: 1 addition & 0 deletions src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"plugins": {
"addPlugin": "Add Plugin",
"configId": "Plugin Config ID",
"docsFor": "{{name}} documentation",
"editPlugin": "Edit Plugin",
"hideSecrets": "Hide secrets",
"label": "Plugins",
Expand Down
1 change: 1 addition & 0 deletions src/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"plugins": {
"addPlugin": "Añadir Plugin",
"configId": "ID de Configuración de Plugin",
"docsFor": "Documentación de {{name}}",
"editPlugin": "Editar Plugin",
"hideSecrets": "Ocultar secretos",
"label": "Plugins",
Expand Down
1 change: 1 addition & 0 deletions src/locales/tr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"plugins": {
"addPlugin": "Plugin Ekle",
"configId": "Plugin Config ID",
"docsFor": "{{name}} dokümantasyonu",
"editPlugin": "Plugin Düzenle",
"hideSecrets": "Gizli değerleri gizle",
"label": "Plugin'ler",
Expand Down
1 change: 1 addition & 0 deletions src/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"plugins": {
"addPlugin": "添加插件",
"configId": "插件配置ID",
"docsFor": "{{name}} 文档",
"editPlugin": "编辑插件",
"hideSecrets": "隐藏密钥",
"label": "插件",
Expand Down
74 changes: 74 additions & 0 deletions src/utils/pluginDocs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';

import { getPluginDocsUrl } from './pluginDocs';

const BASE = 'https://apisix.apache.org';

describe('getPluginDocsUrl', () => {
it('builds the released-docs URL from the plugin name', () => {
expect(getPluginDocsUrl('key-auth', 'en')).toBe(
`${BASE}/docs/apisix/plugins/key-auth/`
);
});

it.each([
'serverless-pre-function',
'serverless-post-function',
])('sends %s to the shared serverless page', (name) => {
expect(getPluginDocsUrl(name, 'en')).toBe(
`${BASE}/docs/apisix/plugins/serverless/`
);
});

// Verified against the live docs site on 2026-08-03: these are the only
// names with no page on the released docs. `example-plugin` is the
// gateway's sample and `ai` has no page of its own; the other three are
// newer than the current docs release.
it.each([
'example-plugin',
'ai',
'ai-cache',
'ai-lakera-guard',
'mcp-bridge',
])('returns null for %s, which has no released docs page', (name) => {
expect(getPluginDocsUrl(name, 'en')).toBeNull();
});

it('uses the Chinese docs when the UI language is zh', () => {
expect(getPluginDocsUrl('key-auth', 'zh')).toBe(
`${BASE}/zh/docs/apisix/plugins/key-auth/`
);
});

// The docs site publishes en and zh only; /es/, /de/ and /tr/ are 404.
it.each(['es', 'de', 'tr'])(
'falls back to English for %s, which the docs site does not publish',
(language) => {
expect(getPluginDocsUrl('key-auth', language)).toBe(
`${BASE}/docs/apisix/plugins/key-auth/`
);
}
);

it('applies the zh prefix and the slug override together', () => {
expect(getPluginDocsUrl('serverless-pre-function', 'zh')).toBe(
`${BASE}/zh/docs/apisix/plugins/serverless/`
);
});
});
Loading
Loading