diff --git a/.gitignore b/.gitignore
index a701b05a8..20fef5ab8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,8 @@ node_modules
!telegraf-build/scripts
!telegraf-build/README.md
/cypress/screenshots/*
+/cypress/videos/*
+test-results.xml
/influxdb3cli-build-scripts/content
.vscode/*
.idea
diff --git a/.husky/_/serve b/.husky/_/serve
new file mode 100755
index 000000000..df25a7d09
--- /dev/null
+++ b/.husky/_/serve
@@ -0,0 +1,57 @@
+#!/bin/sh
+
+if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then
+ set -x
+fi
+
+if [ "$LEFTHOOK" = "0" ]; then
+ exit 0
+fi
+
+call_lefthook()
+{
+ if test -n "$LEFTHOOK_BIN"
+ then
+ "$LEFTHOOK_BIN" "$@"
+ elif lefthook -h >/dev/null 2>&1
+ then
+ lefthook "$@"
+ else
+ dir="$(git rev-parse --show-toplevel)"
+ osArch=$(uname | tr '[:upper:]' '[:lower:]')
+ cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')
+ if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook"
+ then
+ "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@"
+ elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook"
+ then
+ "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@"
+ elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook"
+ then
+ "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@"
+ elif test -f "$dir/node_modules/lefthook/bin/index.js"
+ then
+ "$dir/node_modules/lefthook/bin/index.js" "$@"
+
+ elif bundle exec lefthook -h >/dev/null 2>&1
+ then
+ bundle exec lefthook "$@"
+ elif yarn lefthook -h >/dev/null 2>&1
+ then
+ yarn lefthook "$@"
+ elif pnpm lefthook -h >/dev/null 2>&1
+ then
+ pnpm lefthook "$@"
+ elif swift package plugin lefthook >/dev/null 2>&1
+ then
+ swift package --disable-sandbox plugin lefthook "$@"
+ elif command -v mint >/dev/null 2>&1
+ then
+ mint run csjones/lefthook-plugin "$@"
+ else
+ echo "Can't find lefthook in PATH"
+ fi
+ fi
+}
+
+call_lefthook run "serve" "$@"
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 000000000..945c17819
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+v23.10.0
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index dad714a79..ecf5c9cd4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -28,8 +28,10 @@ For the linting and tests to run, you need to install Docker and Node.js
dependencies.
\_**Note:**
-We strongly recommend running linting and tests, but you can skip them
-(and avoid installing dependencies)
+The git pre-commit and pre-push hooks are configured to run linting and tests automatically
+when you commit or push changes.
+We strongly recommend letting them run, but you can skip them
+(and avoid installing related dependencies)
by including the `--no-verify` flag with your commit--for example, enter the following command in your terminal:
```sh
@@ -51,7 +53,7 @@ dev dependencies used in pre-commit hooks for linting, syntax-checking, and test
Dev dependencies include:
- [Lefthook](https://github.com/evilmartians/lefthook): configures and
-manages pre-commit hooks for linting and testing Markdown content.
+manages git pre-commit and pre-push hooks for linting and testing Markdown content.
- [prettier](https://prettier.io/docs/en/): formats code, including Markdown, according to style rules for consistency
- [Cypress]: e2e testing for UI elements and URLs in content
@@ -93,9 +95,11 @@ Make your suggested changes being sure to follow the [style and formatting guide
## Lint and test your changes
+`package.json` contains scripts for running tests and linting.
+
### Automatic pre-commit checks
-docs-v2 uses Lefthook to manage Git hooks, such as pre-commit hooks that lint Markdown and test code blocks.
+docs-v2 uses Lefthook to manage Git hooks that run during pre-commit and pre-push. The hooks run the scripts defined in `package.json` to lint Markdown and test code blocks.
When you try to commit changes (`git commit`), Git runs
the commands configured in `lefthook.yml` which pass your **staged** files to Vale,
Prettier, Cypress (for UI tests and link-checking), and Pytest (for testing Python and shell code in code blocks).
diff --git a/assets/js/index.js b/assets/js/index.js
new file mode 100644
index 000000000..f63ad8b5d
--- /dev/null
+++ b/assets/js/index.js
@@ -0,0 +1 @@
+export * from './main.js';
diff --git a/assets/js/main.js b/assets/js/main.js
index 57b92a837..5c2289720 100644
--- a/assets/js/main.js
+++ b/assets/js/main.js
@@ -6,9 +6,6 @@
/** Import modules that are not components.
* TODO: Refactor these into single-purpose component modules.
*/
-// import * as codeblocksPreferences from './api-libs.js';
-// import * as datetime from './datetime.js';
-// import * as featureCallouts from './feature-callouts.js';
import * as apiLibs from './api-libs.js';
import * as codeControls from './code-controls.js';
import * as contentInteractions from './content-interactions.js';
@@ -21,15 +18,6 @@ import * as pageContext from './page-context.js';
import * as pageFeedback from './page-feedback.js';
import * as tabbedContent from './tabbed-content.js';
import * as v3Wayfinding from './v3-wayfinding.js';
-// import * as homeInteractions from './home-interactions.js';
-// import { getUrls, getReferrerHost, InfluxDBUrl } from './influxdb-url.js';
-// import * as keybindings from './keybindings.js';
-// import * as listFilters from './list-filters.js';
-// import { Modal } from './modal.js';
-// import { showNotifications } from './notifications.js';
-// import ReleaseTOC from './release-toc.js';
-// import * as scroll from './scroll.js';
-// import { TabbedContent } from './tabbed-content.js';
/** Import component modules
* The component pattern organizes JavaScript, CSS, and HTML for a specific UI element or interaction:
@@ -41,40 +29,95 @@ import * as v3Wayfinding from './v3-wayfinding.js';
import AskAITrigger from './ask-ai-trigger.js';
import CodePlaceholder from './code-placeholders.js';
import { CustomTimeTrigger } from './custom-timestamps.js';
+import FluxInfluxDBVersionsTrigger from './flux-influxdb-versions.js';
import { SearchButton } from './search-button.js';
import { SidebarToggle } from './sidebar-toggle.js';
import Theme from './theme.js';
import ThemeSwitch from './theme-switch.js';
-// import CodeControls from './code-controls.js';
-// import ContentInteractions from './content-interactions.js';
-// import CustomTimestamps from './custom-timestamps.js';
-// import Diagram from './Diagram.js';
-// import FluxGroupKeysExample from './FluxGroupKeysExample.js';
-import FluxInfluxDBVersionsTrigger from './flux-influxdb-versions.js';
-// import PageFeedback from './page-feedback.js';
-// import SearchInput from './SearchInput.js';
-// import Sidebar from './Sidebar.js';
-// import V3Wayfinding from './v3-wayfinding.js';
-// import VersionSelector from './VersionSelector.js';
-// Expose libraries and components within a namespaced object (for backwards compatibility or testing)
-// Expose libraries and components within a namespaced object (for backwards compatibility or testing)
+/**
+ * Component Registry
+ * A central registry that maps component names to their constructor functions.
+ * Add new components to this registry as they are created or migrated from non-component modules.
+ * This allows for:
+ * 1. Automatic component initialization based on data-component attributes
+ * 2. Centralized component management
+ * 3. Easy addition/removal of components
+ * 4. Simplified testing and debugging
+ */
+const componentRegistry = {
+ 'ask-ai-trigger': AskAITrigger,
+ 'code-placeholder': CodePlaceholder,
+ 'custom-time-trigger': CustomTimeTrigger,
+ 'flux-influxdb-versions-trigger': FluxInfluxDBVersionsTrigger,
+ 'search-button': SearchButton,
+ 'sidebar-toggle': SidebarToggle,
+ 'theme': Theme,
+ 'theme-switch': ThemeSwitch
+};
-
-
-document.addEventListener('DOMContentLoaded', function () {
+/**
+ * Initialize global namespace for documentation JavaScript
+ * Exposes core modules for debugging, testing, and backwards compatibility
+ */
+function initGlobals() {
if (typeof window.influxdatadocs === 'undefined') {
window.influxdatadocs = {};
}
- // Expose modules to the global object for debugging, testing, and backwards compatibility for non-ES6 modules.
+ // Expose modules to the global object for debugging, testing, and backwards compatibility
window.influxdatadocs.delay = delay;
window.influxdatadocs.localStorage = window.LocalStorageAPI = localStorage;
window.influxdatadocs.pageContext = pageContext;
window.influxdatadocs.toggleModal = modals.toggleModal;
+ window.influxdatadocs.componentRegistry = componentRegistry;
+
+ return window.influxdatadocs;
+}
- // On content loaded, initialize (not-component-ready) UI interaction modules
- // To differentiate these from component-ready modules, these modules typically export an initialize function that wraps UI interactions and event listeners.
+/**
+ * Initialize components based on data-component attributes
+ * @param {Object} globals - The global influxdatadocs namespace
+ */
+function initComponents(globals) {
+ const components = document.querySelectorAll('[data-component]');
+
+ components.forEach((component) => {
+ const componentName = component.getAttribute('data-component');
+ const ComponentConstructor = componentRegistry[componentName];
+
+ if (ComponentConstructor) {
+ // Initialize the component and store its instance in the global namespace
+ try {
+ const instance = ComponentConstructor({ component });
+ globals[componentName] = ComponentConstructor;
+
+ // Optionally store component instances for future reference
+ if (!globals.instances) {
+ globals.instances = {};
+ }
+
+ if (!globals.instances[componentName]) {
+ globals.instances[componentName] = [];
+ }
+
+ globals.instances[componentName].push({
+ element: component,
+ instance
+ });
+ } catch (error) {
+ console.error(`Error initializing component "${componentName}":`, error);
+ }
+ } else {
+ console.warn(`Unknown component: "${componentName}"`);
+ }
+ });
+}
+
+/**
+ * Initialize all non-component modules
+ */
+function initModules() {
modals.initialize();
apiLibs.initialize();
codeControls.initialize();
@@ -84,67 +127,24 @@ document.addEventListener('DOMContentLoaded', function () {
pageFeedback.initialize();
tabbedContent.initialize();
v3Wayfinding.initialize();
+}
- /** Initialize components
- Component Structure: Each component is structured as a jQuery anonymous function that listens for the document ready state.
- Initialization in main.js: Each component is called in main.js inside a jQuery document ready function to ensure they are initialized when the document is ready.
- Note: These components should *not* be called directly in the HTML.
- */
- const components = document.querySelectorAll('[data-component]');
- components.forEach((component) => {
- const componentName = component.getAttribute('data-component');
- switch (componentName) {
- case 'ask-ai-trigger':
- AskAITrigger({ component });
- window.influxdatadocs[componentName] = AskAITrigger;
- break;
- case 'code-placeholder':
- CodePlaceholder({ component });
- window.influxdatadocs[componentName] = CodePlaceholder;
- break;
- case 'custom-time-trigger':
- CustomTimeTrigger({ component });
- window.influxdatadocs[componentName] = CustomTimeTrigger;
- break;
- case 'flux-influxdb-versions-trigger':
- FluxInfluxDBVersionsTrigger({ component });
- window.influxdatadocs[componentName] = FluxInfluxDBVersionsTrigger;
- break;
- case 'search-button':
- SearchButton({ component });
- window.influxdatadocs[componentName] = SearchButton;
- break;
- case 'sidebar-toggle':
- SidebarToggle({ component });
- window.influxdatadocs[componentName] = SidebarToggle;
- break;
- case 'theme':
- Theme({ component });
- window.influxdatadocs[componentName] = Theme;
- break;
- // CodeControls();
- // ContentInteractions();
- // CustomTimestamps();
- // Diagram();
- // FluxGroupKeysExample();
- // FluxInfluxDBVersionsModal();
- // InfluxDBUrl();
- // Modal();
- // PageFeedback();
- // ReleaseTOC();
- // SearchInput();
- // showNotifications();
- // Sidebar();
- // TabbedContent();
- // ThemeSwitch({});
- // V3Wayfinding();
- // VersionSelector();
- case 'theme-switch':
- ThemeSwitch({ component });
- window.influxdatadocs[componentName] = ThemeSwitch;
- break;
- default:
- console.warn(`Unknown component: ${componentName}`);
- }
- });
-});
+/**
+ * Main initialization function
+ */
+function init() {
+ // Initialize global namespace and expose core modules
+ const globals = initGlobals();
+
+ // Initialize non-component UI modules
+ initModules();
+
+ // Initialize components from registry
+ initComponents(globals);
+}
+
+// Initialize everything when the DOM is ready
+document.addEventListener('DOMContentLoaded', init);
+
+// Export public API
+export { initGlobals, componentRegistry };
\ No newline at end of file
diff --git a/assets/jsconfig.json b/assets/jsconfig.json
index 377218ccb..4ad710c10 100644
--- a/assets/jsconfig.json
+++ b/assets/jsconfig.json
@@ -3,7 +3,8 @@
"baseUrl": ".",
"paths": {
"*": [
- "*"
+ "*",
+ "../node_modules/*"
]
}
}
diff --git a/cypress.config.js b/cypress.config.js
index 6bc148d05..eb82bea59 100644
--- a/cypress.config.js
+++ b/cypress.config.js
@@ -1,10 +1,16 @@
-const { defineConfig } = require('cypress');
-const process = require('process');
+import { defineConfig } from 'cypress';
+import { cwd as _cwd } from 'process';
+import * as fs from 'fs';
+import * as yaml from 'js-yaml';
+import {
+ BROKEN_LINKS_FILE,
+ initializeReport,
+ readBrokenLinksReport,
+} from './cypress/support/link-reporter.js';
-module.exports = defineConfig({
+export default defineConfig({
e2e: {
- // Automatically prefix cy.visit() and cy.request() commands with a baseUrl.
- baseUrl: 'http://localhost:1313',
+ baseUrl: 'http://localhost:1315',
defaultCommandTimeout: 10000,
pageLoadTimeout: 30000,
responseTimeout: 30000,
@@ -12,34 +18,128 @@ module.exports = defineConfig({
numTestsKeptInMemory: 5,
projectId: 'influxdata-docs',
setupNodeEvents(on, config) {
- // implement node event listeners here
+ // Browser setup
on('before:browser:launch', (browser, launchOptions) => {
if (browser.name === 'chrome' && browser.isHeadless) {
- // Force Chrome to use a less memory-intensive approach
launchOptions.args.push('--disable-dev-shm-usage');
launchOptions.args.push('--disable-gpu');
launchOptions.args.push('--disable-extensions');
return launchOptions;
}
});
+
on('task', {
// Fetch the product list configured in /data/products.yml
getData(filename) {
return new Promise((resolve, reject) => {
- const yq = require('js-yaml');
- const fs = require('fs');
- const cwd = process.cwd();
+ const cwd = _cwd();
try {
resolve(
- yq.load(fs.readFileSync(`${cwd}/data/${filename}.yml`, 'utf8'))
+ yaml.load(
+ fs.readFileSync(`${cwd}/data/${filename}.yml`, 'utf8')
+ )
);
} catch (e) {
reject(e);
}
});
},
+
+ // Log task for reporting
+ log(message) {
+ if (typeof message === 'object') {
+ if (message.type === 'error') {
+ console.error(`\x1b[31m${message.message}\x1b[0m`); // Red
+ } else if (message.type === 'warning') {
+ console.warn(`\x1b[33m${message.message}\x1b[0m`); // Yellow
+ } else if (message.type === 'success') {
+ console.log(`\x1b[32m${message.message}\x1b[0m`); // Green
+ } else if (message.type === 'divider') {
+ console.log(`\x1b[90m${message.message}\x1b[0m`); // Gray
+ } else {
+ console.log(message.message || message);
+ }
+ } else {
+ console.log(message);
+ }
+ return null;
+ },
+
+ // File tasks
+ writeFile({ path, content }) {
+ try {
+ fs.writeFileSync(path, content);
+ return true;
+ } catch (error) {
+ console.error(`Error writing to file ${path}: ${error.message}`);
+ return { error: error.message };
+ }
+ },
+
+ readFile(path) {
+ try {
+ return fs.existsSync(path) ? fs.readFileSync(path, 'utf8') : null;
+ } catch (error) {
+ console.error(`Error reading file ${path}: ${error.message}`);
+ return { error: error.message };
+ }
+ },
+
+ // Broken links reporting tasks
+ initializeBrokenLinksReport() {
+ return initializeReport();
+ },
+
+ reportBrokenLink(linkData) {
+ try {
+ // Read current report
+ const report = readBrokenLinksReport();
+
+ // Find or create entry for this page
+ let pageReport = report.find((r) => r.page === linkData.page);
+ if (!pageReport) {
+ pageReport = { page: linkData.page, links: [] };
+ report.push(pageReport);
+ }
+
+ // Add the broken link to the page's report
+ pageReport.links.push({
+ url: linkData.url,
+ status: linkData.status,
+ type: linkData.type,
+ linkText: linkData.linkText,
+ });
+
+ // Write updated report back to file
+ fs.writeFileSync(
+ BROKEN_LINKS_FILE,
+ JSON.stringify(report, null, 2)
+ );
+
+ // Log the broken link immediately to console
+ console.error(
+ `❌ BROKEN LINK: ${linkData.url} (${linkData.status}) - ${linkData.type} on page ${linkData.page}`
+ );
+
+ return true;
+ } catch (error) {
+ console.error(`Error reporting broken link: ${error.message}`);
+ return false;
+ }
+ },
+ });
+
+ // Load plugins file using dynamic import for ESM compatibility
+ return import('./cypress/plugins/index.js').then((module) => {
+ return module.default(on, config);
});
- return config;
},
+ specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
+ supportFile: 'cypress/support/e2e.js',
+ viewportWidth: 1280,
+ viewportHeight: 720,
+ },
+ env: {
+ test_subjects: '',
},
});
diff --git a/cypress/e2e/content/article-links.cy.js b/cypress/e2e/content/article-links.cy.js
index 3aca5298c..ee1672cb2 100644
--- a/cypress/e2e/content/article-links.cy.js
+++ b/cypress/e2e/content/article-links.cy.js
@@ -1,11 +1,11 @@
///
-describe('Article links', () => {
+describe('Article', () => {
const subjects = Cypress.env('test_subjects').split(',');
// Always use HEAD for downloads to avoid timeouts
const useHeadForDownloads = true;
- // Helper function to identify download links - improved
+ // Helper function to identify download links
function isDownloadLink(href) {
// Check for common download file extensions
const downloadExtensions = [
@@ -45,130 +45,163 @@ describe('Article links', () => {
}
// Helper function to make appropriate request based on link type
- function testLink(href) {
+ function testLink(href, linkText = '', pageUrl) {
+ // Common request options for both methods
+ const requestOptions = {
+ failOnStatusCode: true,
+ timeout: 15000, // Increased timeout for reliability
+ followRedirect: true, // Explicitly follow redirects
+ retryOnNetworkFailure: true, // Retry on network issues
+ retryOnStatusCodeFailure: true, // Retry on 5xx errors
+ };
+
+ function handleFailedLink(url, status, type, redirectChain = '') {
+ // Report broken link to the task which will handle reporting
+ cy.task('reportBrokenLink', {
+ url: url + redirectChain,
+ status,
+ type,
+ linkText,
+ page: pageUrl,
+ });
+
+ throw new Error(
+ `BROKEN ${type.toUpperCase()} LINK: ${url} (status: ${status})${redirectChain} on ${pageUrl}`
+ );
+ }
+
if (useHeadForDownloads && isDownloadLink(href)) {
cy.log(`** Testing download link with HEAD: ${href} **`);
cy.request({
method: 'HEAD',
url: href,
+ ...requestOptions,
}).then((response) => {
- const message = `Link is broken: ${href} (status: ${response.status})`;
- try {
- expect(response.status).to.be.lt(400);
- } catch (e) {
- // Log the broken link with the URL for better visibility in reports
- cy.log(`❌ BROKEN LINK: ${href} (${response.status})`);
- throw new Error(message);
+ // Check final status after following any redirects
+ if (response.status >= 400) {
+ // Build redirect info string if available
+ const redirectInfo =
+ response.redirects && response.redirects.length > 0
+ ? ` (redirected to: ${response.redirects.join(' -> ')})`
+ : '';
+
+ handleFailedLink(href, response.status, 'download', redirectInfo);
}
});
} else {
cy.log(`** Testing link: ${href} **`);
+ cy.log(JSON.stringify(requestOptions));
cy.request({
url: href,
- failOnStatusCode: false,
- timeout: 10000, // 10 second timeout for regular links
+ ...requestOptions,
}).then((response) => {
- const message = `Link is broken: ${href} (status: ${response.status})`;
- try {
- expect(response.status).to.be.lt(400);
- } catch (e) {
- // Log the broken link with the URL for better visibility in reports
- cy.log(`❌ BROKEN LINK: ${href} (${response.status})`);
- throw new Error(message);
+ // Check final status after following any redirects
+ if (response.status >= 400) {
+ // Build redirect info string if available
+ const redirectInfo =
+ response.redirects && response.redirects.length > 0
+ ? ` (redirected to: ${response.redirects.join(' -> ')})`
+ : '';
+
+ handleFailedLink(href, response.status, 'regular', redirectInfo);
}
});
}
}
+ // Before all tests, initialize the report
+ before(() => {
+ cy.task('initializeBrokenLinksReport');
+ });
+
subjects.forEach((subject) => {
- it(`contains valid internal links on ${subject}`, function () {
- cy.visit(`${subject}`);
+ it(`${subject} has valid internal links`, function () {
+ cy.visit(`${subject}`, { timeout: 20000 });
+
// Test internal links
- // 1. Timeout and fail the test if article is not found
- // 2. Check each link.
- // 3. If no links are found, continue without failing
- cy.get('article').then(($article) => {
+ cy.get('article, .api-content').then(($article) => {
// Find links without failing the test if none are found
const $links = $article.find('a[href^="/"]');
if ($links.length === 0) {
cy.log('No internal links found on this page');
return;
}
+
+ // Now test each link
cy.wrap($links).each(($a) => {
const href = $a.attr('href');
- testLink(href);
+ const linkText = $a.text().trim();
+ testLink(href, linkText, subject);
});
});
});
- it(`checks anchor links on ${subject} (with warnings for missing targets)`, function () {
+ it(`${subject} has valid anchor links`, function () {
cy.visit(`${subject}`);
- // Track missing anchors for summary
- const missingAnchors = [];
+ // Define selectors for anchor links to ignore, such as behavior triggers
+ const ignoreLinks = ['.tabs a[href^="#"]', '.code-tabs a[href^="#"]'];
- // Process anchor links individually
- cy.get('article').then(($article) => {
- const $anchorLinks = $article.find('a[href^="#"]');
+ const anchorSelector =
+ 'a[href^="#"]:not(' + ignoreLinks.join('):not(') + ')';
+
+ cy.get('article, .api-content').then(($article) => {
+ const $anchorLinks = $article.find(anchorSelector);
if ($anchorLinks.length === 0) {
cy.log('No anchor links found on this page');
return;
}
+
cy.wrap($anchorLinks).each(($a) => {
- const href = $a.prop('href');
- if (href && href.length > 1) {
- // Skip empty anchors (#)
- // Get just the fragment part
- const url = new URL(href);
- const anchorId = url.hash.substring(1); // Remove the # character
+ const href = $a.prop('href');
+ const linkText = $a.text().trim();
- if (!anchorId) {
- cy.log(`Skipping empty anchor in ${href}`);
- return;
+ if (href && href.length > 1) {
+ // Get just the fragment part
+ const url = new URL(href);
+ const anchorId = url.hash.substring(1); // Remove the # character
+
+ if (!anchorId) {
+ cy.log(`Skipping empty anchor in ${href}`);
+ return;
+ }
+
+ // Use DOM to check if the element exists
+ cy.window().then((win) => {
+ const element = win.document.getElementById(anchorId);
+ if (!element) {
+ cy.task('reportBrokenLink', {
+ url: `#${anchorId}`,
+ status: 404,
+ type: 'anchor',
+ linkText,
+ page: subject,
+ });
+ cy.log(`⚠️ Missing anchor target: #${anchorId}`);
}
-
- // Use DOM to check if the element exists, but don't fail if missing
- cy.window().then((win) => {
- const element = win.document.getElementById(anchorId);
- if (element) {
- cy.log(`✅ Anchor target exists: #${anchorId}`);
- } else {
- // Just warn about the missing anchor
- cy.log(`⚠️ WARNING: Missing anchor target: #${anchorId}`);
- missingAnchors.push(anchorId);
- }
- });
- }
- })
- .then(() => {
- // After checking all anchors, log a summary
- if (missingAnchors.length > 0) {
- cy.log(
- `⚠️ Found ${missingAnchors.length} missing anchor targets: ${missingAnchors.join(', ')}`
- );
- } else {
- cy.log('✅ All anchor targets are valid');
- }
- });
- });
-
- it(`contains valid external links on ${subject}`, function () {
- cy.visit(`${subject}`);
- // Test external links
- // 1. Timeout and fail the test if article is not found
- // 2. Check each link.
- // 3. If no links are found, continue without failing
- cy.get('article').then(($article) => {
- // Find links without failing the test if none are found
- const $links = $article.find('a[href^="http"]');
- if ($links.length === 0) {
- cy.log('No external links found on this page');
- return;
+ });
}
- cy.wrap($links).each(($a) => {
- const href = $a.attr('href');
- testLink(href);
- });
+ });
+ });
+ });
+
+ it(`${subject} has valid external links`, function () {
+ cy.visit(`${subject}`);
+
+ // Test external links
+ cy.get('article, .api-content').then(($article) => {
+ // Find links without failing the test if none are found
+ const $links = $article.find('a[href^="http"]');
+ if ($links.length === 0) {
+ cy.log('No external links found on this page');
+ return;
+ }
+
+ cy.debug(`Found ${$links.length} external links`);
+ cy.wrap($links).each(($a) => {
+ const href = $a.attr('href');
+ const linkText = $a.text().trim();
+ testLink(href, linkText, subject);
});
});
});
diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js
new file mode 100644
index 000000000..904971cc0
--- /dev/null
+++ b/cypress/plugins/index.js
@@ -0,0 +1,26 @@
+///
+// ***********************************************************
+// This example plugins/index.js can be used to load plugins
+//
+// You can change the location of this file or turn off loading
+// the plugins file with the 'pluginsFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/plugins-guide
+// ***********************************************************
+
+// This function is called when a project is opened or re-opened (e.g. due to
+// the project's config changing)
+
+/**
+ * @type {Cypress.PluginConfig}
+ */
+export default (on, config) => {
+ // `on` is used to hook into various events Cypress emits
+ // `config` is the resolved Cypress config
+
+ // NOTE: The log task is now defined in cypress.config.js
+ // We don't need to register it here to avoid duplication
+
+ return config;
+};
diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js
index 3eaffffa6..b0265634d 100644
--- a/cypress/support/e2e.js
+++ b/cypress/support/e2e.js
@@ -14,4 +14,4 @@
// ***********************************************************
// Import commands.js using ES2015 syntax:
-import './commands'
\ No newline at end of file
+import './commands';
diff --git a/cypress/support/hugo-server.js b/cypress/support/hugo-server.js
new file mode 100644
index 000000000..42411bfd9
--- /dev/null
+++ b/cypress/support/hugo-server.js
@@ -0,0 +1,174 @@
+import { spawn } from 'child_process';
+import fs from 'fs';
+import http from 'http';
+import net from 'net';
+
+// Hugo server constants
+export const HUGO_PORT = 1315;
+export const HUGO_LOG_FILE = '/tmp/hugo_server.log';
+
+/**
+ * Check if a port is already in use
+ * @param {number} port - The port to check
+ * @returns {Promise} True if port is in use, false otherwise
+ */
+export async function isPortInUse(port) {
+ return new Promise((resolve) => {
+ const tester = net
+ .createServer()
+ .once('error', () => resolve(true))
+ .once('listening', () => {
+ tester.close();
+ resolve(false);
+ })
+ .listen(port, '127.0.0.1');
+ });
+}
+
+/**
+ * Start the Hugo server with the specified options
+ * @param {Object} options - Configuration options for Hugo
+ * @param {string} options.configFile - Path to Hugo config file (e.g., 'hugo.testing.yml')
+ * @param {number} options.port - Port number for Hugo server
+ * @param {boolean} options.buildDrafts - Whether to build draft content
+ * @param {boolean} options.noHTTPCache - Whether to disable HTTP caching
+ * @param {string} options.logFile - Path to write Hugo logs
+ * @returns {Promise