diff --git a/.github/workflows/lint-code-blocks.yml b/.github/workflows/lint-code-blocks.yml new file mode 100644 index 000000000..3022d8c23 --- /dev/null +++ b/.github/workflows/lint-code-blocks.yml @@ -0,0 +1,53 @@ +name: Lint code blocks + +# Syntax-checks fenced code blocks in docs changed by this PR. +# +# Scoped to CHANGED FILES on purpose: the repo has pre-existing debt, and a +# repo-wide gate would block every PR on unrelated pages. This stops new +# breakage landing while the backlog is worked down. To sweep everything: +# node scripts/lint-code-blocks.js --all + +on: + pull_request: + paths: + - 'docs/**/*.md' + - 'docs/**/*.mdx' + - 'scripts/lint-code-blocks.js' + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install PyYAML + run: pip install --quiet pyyaml + + # ruby and bash are already present on ubuntu-latest + + - name: Collect changed docs + id: changed + run: | + git diff --name-only --diff-filter=ACMR \ + "origin/${{ github.base_ref }}" HEAD -- 'docs/**/*.md' 'docs/**/*.mdx' \ + > changed.txt || true + echo "count=$(wc -l < changed.txt | tr -d ' ')" >> "$GITHUB_OUTPUT" + echo "Changed doc files:"; cat changed.txt + + - name: Lint fenced code blocks + if: steps.changed.outputs.count != '0' + run: xargs -a changed.txt node scripts/lint-code-blocks.js + + - name: Nothing to lint + if: steps.changed.outputs.count == '0' + run: echo "No documentation files changed." diff --git a/docs/smartui-appium-java-sdk.md b/docs/smartui-appium-java-sdk.md index 1bbbed7f3..4d27729ad 100644 --- a/docs/smartui-appium-java-sdk.md +++ b/docs/smartui-appium-java-sdk.md @@ -105,14 +105,14 @@ export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` diff --git a/docs/smartui-cypress-sdk.md b/docs/smartui-cypress-sdk.md index 6f1e6b005..7e6026048 100644 --- a/docs/smartui-cypress-sdk.md +++ b/docs/smartui-cypress-sdk.md @@ -135,14 +135,14 @@ export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -586,7 +586,7 @@ cy.smartuiSnapshot('Page Loaded'); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-k6-setup.md b/docs/smartui-k6-setup.md index 0a118d065..70017c502 100644 --- a/docs/smartui-k6-setup.md +++ b/docs/smartui-k6-setup.md @@ -51,14 +51,14 @@ export LT_USERNAME="YOUR_USERNAME" ```bash -set LT_USERNAME=YOUR_USERNAME" +set LT_USERNAME="YOUR_USERNAME" ``` ```powershell -$env:LT_USERNAME=YOUR_USERNAME" +$env:LT_USERNAME="YOUR_USERNAME" ``` diff --git a/docs/smartui-playwright-java-sdk.md b/docs/smartui-playwright-java-sdk.md index e5c5bbb29..c2da37884 100644 --- a/docs/smartui-playwright-java-sdk.md +++ b/docs/smartui-playwright-java-sdk.md @@ -599,7 +599,7 @@ SmartUISnapshot.smartuiSnapshot(driver, "Page Loaded"); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-playwright-sdk.md b/docs/smartui-playwright-sdk.md index 19f30297d..6ae7bb33c 100644 --- a/docs/smartui-playwright-sdk.md +++ b/docs/smartui-playwright-sdk.md @@ -105,21 +105,21 @@ Setup your project token shown in the **SmartUI** app after creating your projec ```bash -export PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -631,7 +631,7 @@ await smartuiSnapshot.smartuiSnapshot(page, Page Loaded"); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-puppeteer-sdk.md b/docs/smartui-puppeteer-sdk.md index fd64bcab2..944491c81 100644 --- a/docs/smartui-puppeteer-sdk.md +++ b/docs/smartui-puppeteer-sdk.md @@ -107,21 +107,21 @@ Setup your project token shown in the **SmartUI** app after creating your projec ```bash -export PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -636,7 +636,7 @@ await smartuiSnapshot(page, Page Loaded"); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-selenium-java-sdk.md b/docs/smartui-selenium-java-sdk.md index 2c2250406..068276d78 100644 --- a/docs/smartui-selenium-java-sdk.md +++ b/docs/smartui-selenium-java-sdk.md @@ -140,7 +140,7 @@ set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -658,7 +658,7 @@ SmartUISnapshot.smartuiSnapshot(driver, "Page Loaded"); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-selenium-js-sdk.md b/docs/smartui-selenium-js-sdk.md index 967862c81..46553d557 100644 --- a/docs/smartui-selenium-js-sdk.md +++ b/docs/smartui-selenium-js-sdk.md @@ -107,21 +107,21 @@ Setup your project token shown in the **SmartUI** app after creating your projec ```bash -export PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -624,7 +624,7 @@ await smartuiSnapshot(driver, Page Loaded"); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-selenium-ruby-sdk.md b/docs/smartui-selenium-ruby-sdk.md index d6dce6c64..69ed8cc86 100644 --- a/docs/smartui-selenium-ruby-sdk.md +++ b/docs/smartui-selenium-ruby-sdk.md @@ -111,21 +111,21 @@ Setup your project token shown in the **SmartUI** app after creating your projec ```bash -export PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -613,7 +613,7 @@ LambdaTest::Selenium::Driver.smartui_snapshot(driver, Page Loaded") 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-testcafe-sdk.md b/docs/smartui-testcafe-sdk.md index 90d574473..5f716f6fe 100644 --- a/docs/smartui-testcafe-sdk.md +++ b/docs/smartui-testcafe-sdk.md @@ -114,14 +114,14 @@ export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -595,7 +595,7 @@ test('Take screenshot after page loads', async t ="> { 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-wdio-sdk.md b/docs/smartui-wdio-sdk.md index df25d62d9..2b78dac78 100644 --- a/docs/smartui-wdio-sdk.md +++ b/docs/smartui-wdio-sdk.md @@ -117,14 +117,14 @@ export PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ```bash -set PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +set PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` ```powershell -$env:PROJECT_TOKEN=123456#1234abcd-****-****-****-************" +$env:PROJECT_TOKEN="123456#1234abcd-****-****-****-************" ``` @@ -568,7 +568,7 @@ await smartuiSnapshot(driver, 'Page Loaded'); 2. Check configuration file syntax 3. Try different port if default is in use: ```bash - npx smartui exec -P 5000 -- + npx smartui exec -P 5000 -- ``` 4. Check file permissions for configuration and project files diff --git a/docs/smartui-with-semaphore.md b/docs/smartui-with-semaphore.md index b0c4f4bd7..9c14e81f8 100644 --- a/docs/smartui-with-semaphore.md +++ b/docs/smartui-with-semaphore.md @@ -265,13 +265,13 @@ env_vars: jobs: - name: Run Tests commands: - - npx smartui exec -- + - npx smartui exec -- - name: Test Group 2 task: jobs: - name: Run Tests commands: - - npx smartui exec -- + - npx smartui exec -- ``` 3. Optimize test execution diff --git a/scripts/lint-code-blocks.js b/scripts/lint-code-blocks.js new file mode 100755 index 000000000..bd58d4006 --- /dev/null +++ b/scripts/lint-code-blocks.js @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/** + * Syntax-check fenced code blocks in Markdown/MDX docs. + * + * Why this exists: an audit of the SmartUI doc set found 118 code blocks that + * could not parse or compile - missing quotes, JavaScript object literals inside + * Python blocks, unclosed fences, curly quotes. Every one would have been caught + * here on the commit that introduced it. + * + * Usage: + * node scripts/lint-code-blocks.js lint specific files + * node scripts/lint-code-blocks.js --all lint every doc + * + * Exit code 1 if any block fails. + */ +'use strict'; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +const args = process.argv.slice(2); +const ALL = args.includes('--all'); +let files = args.filter((a) => !a.startsWith('--')); + +if (ALL) { + const walk = (dir) => + fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const p = path.join(dir, e.name); + if (e.isDirectory()) return walk(p); + return /\.mdx?$/.test(e.name) ? [p] : []; + }); + files = ['docs'].filter(fs.existsSync).flatMap(walk); +} +files = files.filter((f) => /\.mdx?$/.test(f) && fs.existsSync(f)); +if (!files.length) { + console.log('lint-code-blocks: no markdown files to check'); + process.exit(0); +} + +// ── which languages we can actually validate, and how ──────────────────────── +const NORMALISE = { + js: 'javascript', node: 'javascript', nodejs: 'javascript', + py: 'python', rb: 'ruby', cs: 'csharp', sh: 'bash', shell: 'bash', zsh: 'bash', +}; +const have = (cmd) => spawnSync(cmd, ['--version'], { stdio: 'ignore' }).status === 0; +const HAVE = { node: true, python3: have('python3'), ruby: have('ruby'), bash: have('bash') }; + +// Markdown nests blocks inside lists and s, so the content arrives +// uniformly indented. Strip the common leading whitespace before parsing - +// otherwise every indented Python block is a false IndentationError. +function dedent(src) { + const ls = src.split('\n').filter((l) => l.trim()); + if (!ls.length) return src; + const pad = Math.min(...ls.map((l) => l.match(/^[ \t]*/)[0].length)); + return pad ? src.split('\n').map((l) => l.slice(pad)).join('\n') : src; +} + +function tmp(content, ext) { + const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'blk-')), 'block' + ext); + fs.writeFileSync(f, content); + return f; +} +function run(cmd, argv) { + const r = spawnSync(cmd, argv, { encoding: 'utf8' }); + return { ok: r.status === 0, err: ((r.stderr || '') + (r.stdout || '')).trim().split('\n')[0] }; +} + +const CHECKERS = { + javascript: (c) => (HAVE.node ? run('node', ['--check', tmp(c, '.mjs')]) : null), + python: (c) => (HAVE.python3 ? run('python3', ['-c', 'import ast,sys;ast.parse(open(sys.argv[1]).read())', tmp(c, '.py')]) : null), + ruby: (c) => (HAVE.ruby ? run('ruby', ['-c', tmp(c, '.rb')]) : null), + bash: (c) => (HAVE.bash ? run('bash', ['-n', tmp(c, '.sh')]) : null), + json: (c) => { try { JSON.parse(c); return { ok: true }; } catch (e) { return { ok: false, err: e.message }; } }, + yaml: (c) => (HAVE.python3 ? run('python3', ['-c', 'import yaml,sys;list(yaml.safe_load_all(open(sys.argv[1])))', tmp(c, '.yml')]) : null), +}; + +// Blocks that are deliberately partial. A bare object/array fragment or an +// elision marker is legitimate in docs and must not fail the build. +function isFragment(code, lang) { + const t = code.trim(); + if (/^\s*(\.\.\.|\/\/\s*\.\.\.|#\s*\.\.\.)/m.test(t)) return true; + if (lang === 'javascript' && /^['"][^'"]+['"]\s*:/.test(t)) return true; // 'LT:Options': { ... } + if (lang === 'javascript' && /^\{[\s\S]*\}$/.test(t) && !/[;=]/.test(t)) return true; + if (lang === 'json' && /^\s*"[^"]+"\s*:/.test(t) && !t.startsWith('{')) return true; + if (/^[\w.$-]+\s*:\s*[{[]/.test(t)) return true; // bare `key: {` fragment + if (/^(await |const |let |var )?[\w.$]+\([\s\S]*\)[;,]?$/.test(t) && !/\n/.test(t)) return false; + return false; +} +// JSON samples in docs conventionally carry // comments. Strip before parsing, +// but still flag genuinely malformed JSON. +const stripJsonComments = (s) => s.replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1'); + +let failures = 0, checked = 0, skipped = 0; + +for (const file of files) { + const lines = fs.readFileSync(file, 'utf8').split('\n'); + let open = false, lang = '', buf = [], start = 0, fences = 0; + + for (let i = 0; i < lines.length; i++) { + const m = /^\s*```(.*)$/.exec(lines[i]); + if (m) { + fences++; + if (!open) { + open = true; start = i + 1; buf = []; + lang = (m[1].trim().split(/\s+/)[0] || '').toLowerCase(); + } else { + open = false; + const norm = NORMALISE[lang] || lang; + const code = dedent(buf.join('\n')); + const checker = CHECKERS[norm]; + if (checker && code.trim()) { + if (isFragment(code, norm)) { skipped++; continue; } + const payload = norm === 'json' ? stripJsonComments(code) : code; + const res = checker(payload); + if (res === null) { skipped++; continue; } // no runtime available + checked++; + if (!res.ok) { + failures++; + console.error(`\n✖ ${file}:${start} [${norm}]`); + console.error(` ${res.err}`); + console.error(` ${code.trim().split('\n')[0].slice(0, 90)}`); + } + } + } + continue; + } + if (open) buf.push(lines[i]); + } + + if (fences % 2 !== 0) { + failures++; + console.error(`\n✖ ${file} unbalanced code fences (${fences}) — a block is never closed`); + console.error(' This silently inverts every following block: prose renders as code and code as prose.'); + } + // Typographic quotes inside code blocks are a hard syntax error in every language. + const smart = []; + let inb = false; + lines.forEach((ln, i) => { + if (/^\s*```/.test(ln)) { inb = !inb; return; } + if (inb && /[‘’“”]/.test(ln)) smart.push(i + 1); + }); + if (smart.length) { + failures++; + console.error(`\n✖ ${file} curly quotes inside code blocks at line(s) ${smart.slice(0, 8).join(', ')}${smart.length > 8 ? '…' : ''}`); + console.error(' Replace ‘ ’ “ ” with ASCII \' and ".'); + } +} + +const unavailable = Object.entries(HAVE).filter(([, v]) => !v).map(([k]) => k); +console.log( + `\nlint-code-blocks: ${checked} blocks checked, ${skipped} skipped (fragment or no runtime), ` + + `${failures} problem(s) in ${files.length} file(s)` + + (unavailable.length ? `\n note: no runtime for ${unavailable.join(', ')} — those blocks were skipped` : '') +); +process.exit(failures ? 1 : 0);