@ashforge/assert-guard
v1.0.0
Published
A smart test quality gate CLI that lints your test suite for anti-patterns, architectural violations, and bad practices.
Maintainers
Readme
The Problem
A green CI pipeline tells you tests ran. It tells you nothing about test quality.
Every test suite silently accumulates debt:
cy.wait(3000)scattered throughout — tests that pass on fast machines, fail on slow onesit.only()committed by accident — silently skipping 200 other tests in CI, every single run- Hardcoded passwords and API keys committed to version control
nth-child(2)selectors that shatter every time a designer rearranges a listif/elselogic inside test blocks making failures impossible to diagnose
None of this shows up in your test results. All of it costs you time.
assert-guard catches it at the gate.
How It Works
Your test files → AST parser → Rule engine → Report → Exit code
*.spec.ts @babel/parser 7 built-in CLI 0 / 1
*.test.js no test rules JSON for CI
*.cy.ts runner needed pluggable HTMLassert-guard parses your test files as an Abstract Syntax Tree — the same technique ESLint uses. No test runner required. No Cypress. No Playwright. Just your files and a set of rules that encode what a senior QA architect would flag in a code review.
Scans 40+ files in under 2 seconds.
Terminal Output
▲ assert-guard Test Quality Gate
────────────────────────────────────────────────────
✔ auth.spec.ts
✔ homepage.spec.ts
✗ checkout.spec.ts
[no-hard-waits] error line 24
→ cy.wait(3000) detected — replace with waitFor() or intercept alias
⚠ cart.spec.ts
[no-flaky-selectors] warn line 55
→ nth-child(2) positional selector — use data-testid instead
✗ login.spec.ts
[no-focused-tests] error line 8
→ it.only() skips all other tests in CI — remove before merge
✔ payment.spec.ts
────────────────────────────────────────────────────
38 passed 2 errors 1 warning 0 info
Duration 1.24s 42 files · 7 rules · 504 checks
✗ Quality gate FAILED Fix errors before merge · exit code 1Built-in Rules
| Rule | Default | What it catches |
|------|:-------:|-----------------|
| no-hard-waits | error | cy.wait(3000) · page.waitForTimeout() · Thread.sleep() |
| no-focused-tests | error | it.only() · test.only() · describe.only() · fit() |
| no-hardcoded-credentials | error | passwords · API keys · tokens committed to test files |
| no-logic-in-tests | error | if/else · for loops · try/catch inside test blocks |
| no-flaky-selectors | warn | nth-child() · absolute XPath · auto-generated CSS classes |
| single-assertion-focus | warn | test blocks exceeding the assertion limit (default: 5) |
| test-isolation-check | info | shared let state at describe scope that leaks between tests |
error → blocks merge · exits with code
1
warn → advisory · visible in report
info → informational · never fails the gate
Quick Start
Zero config. Run it right now:
npx @ashforge/assert-guard --dir ./testsInstall as a dev dependency (recommended for CI):
npm install --save-dev @ashforge/assert-guardInstall globally:
npm install -g @ashforge/assert-guardRequirements: Node.js ≥ 16
Add to your npm scripts:
{
"scripts": {
"test:quality": "assert-guard --dir ./tests",
"test:quality:strict": "assert-guard --dir ./tests --fail-on-warnings",
"test:quality:report": "assert-guard --dir ./tests --format html --output ./reports"
}
}Generate a config file:
assert-guard initCommands
assert-guard scan (default)
assert-guard [scan] [options]
Options:
-d, --dir <path> Directory to scan (default: ".")
-c, --config <path> Path to config file
-f, --format <type> cli | json | html | all (default: "cli")
-o, --output <path> Output directory for reports
--fail-on-warnings Exit 1 if warnings are found
--quiet Summary only, suppress per-file output
-v, --version Print version
-h, --help Show helpExamples:
# Scan a directory
assert-guard --dir ./tests
# Generate all report formats
assert-guard --dir ./e2e --format all --output ./reports
# Strict mode — warnings also fail the gate
assert-guard --dir ./tests --fail-on-warnings
# Custom config path
assert-guard --dir ./tests --config ./config/ag.config.jsonassert-guard init
Scaffolds an ag.config.json in the current directory.
assert-guard rules
Lists all available built-in rules with their default severity.
Configuration
ag.config.json — place in your project root (or run assert-guard init):
{
"rules": {
"no-hard-waits": "error",
"no-focused-tests": "error",
"no-hardcoded-credentials": "error",
"no-logic-in-tests": "error",
"no-flaky-selectors": "warn",
"single-assertion-focus": "warn",
"test-isolation-check": "info"
},
"include": [
"**/*.spec.{ts,js}",
"**/*.test.{ts,js}",
"**/*.cy.{ts,js}"
],
"exclude": [
"**/node_modules/**",
"**/dist/**"
],
"maxAssertionsPerTest": 5,
"reportFormat": "cli",
"outputDir": "./assert-guard-reports",
"failOnWarnings": false
}Every rule accepts: "error" · "warn" · "info" · "off"
Config is auto-discovered in this order: ag.config.json → .assert-guard.json → assert-guard.config.json
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| rules | object | see defaults | Rule name → severity level |
| include | string[] | ["**/*.spec.*", "**/*.test.*", "**/*.cy.*"] | Glob patterns to scan |
| exclude | string[] | ["**/node_modules/**", "**/dist/**"] | Glob patterns to ignore |
| maxAssertionsPerTest | number | 5 | Threshold for single-assertion-focus |
| reportFormat | string | "cli" | Output format: cli, json, html, or all |
| outputDir | string | "./assert-guard-reports" | Where JSON/HTML reports are written |
| failOnWarnings | boolean | false | Treat warnings as gate failures |
Rule Details
Flags cy.wait(N), page.waitForTimeout(), Thread.sleep() and similar hardcoded pause calls.
// ✗ Flagged as error
cy.wait(3000)
page.waitForTimeout(2000)
Thread.sleep(1000)
// ✔ Correct approach
cy.wait('@apiRequest') // alias-based — waits for the actual network event
await page.waitForSelector('.modal') // condition-based — deterministic
await expect(locator).toBeVisible() // Playwright assertion with built-in retryCatches .only() and focused test aliases that silently skip your entire suite in CI.
// ✗ Flagged as error — all other tests silently skipped in CI
it.only('my test', () => { ... })
test.only('my test', () => { ... })
describe.only('suite', () => { ... })
fit('jasmine focused', () => { ... })
// ✔ Correct
it('my test', () => { ... })A single
it.only()committed to main silently skips your entire test suite in CI. Your pipeline stays green. Everything is broken.
Detects if/else, for loops, and try/catch inside test blocks.
// ✗ Flagged as error
it('submits the form', () => {
if (isLoggedIn) { cy.get('#submit').click() }
})
// ✔ Correct
it('submits form when authenticated', () => {
loginAs('[email protected]')
cy.get('[data-testid="submit-btn"]').click()
})Detects hardcoded passwords, API keys, and tokens in test files.
// ✗ Flagged as error
const password = 'S3cr3tP@ssw0rd'
cy.login({ apiKey: 'sk-live-abc123xyz' })
// ✔ Correct
const password = process.env.TEST_PASSWORD
cy.login({ apiKey: Cypress.env('API_KEY') })Flags positional CSS, absolute XPath, and auto-generated class names.
// ✗ Flagged as warning
cy.get('li:nth-child(2)')
cy.get('//div/span[1]')
cy.get('.css-1a2b3c')
// ✔ Correct
cy.get('[data-testid="cart-item-first"]')
page.getByRole('button', { name: 'Checkout' })Warns when a test block exceeds the configured assertion limit (default: 5).
// ✗ Flagged — 6 assertions, limit 5
it('validates the whole page', () => {
expect(title).toBe('Dashboard')
expect(subtitle).toContain('Welcome')
expect(navLinks).toHaveLength(4)
expect(footer).toBeVisible()
expect(logo).toHaveAttribute('src')
expect(badge).toHaveText('New') // ← over the limit
})
// ✔ Correct — split into focused tests
it('shows correct page title', () => { ... })
it('renders navigation correctly', () => { ... })Adjust the threshold: "maxAssertionsPerTest": 8 in ag.config.json
Detects let variables at describe scope that may carry state between tests.
// ✗ Flagged
describe('user suite', () => {
let userData
it('A', () => { userData = { name: 'Alice' } })
it('B', () => { expect(userData.name).toBe('') }) // depends on A
})
// ✔ Correct
describe('user suite', () => {
let userData
beforeEach(() => { userData = { name: '' } })
})Report Formats
CLI (default)
Coloured terminal output designed to be scannable in GitHub Actions logs. Every violation includes a precise fix hint inline.
HTML
A self-contained single .html file. No CDN. No external dependencies. Opens offline in any browser.
assert-guard --dir ./tests --format html
open ./assert-guard-reports/assert-guard-report.htmlJSON
Machine-readable output for dashboards, Slack bots, and custom integrations.
assert-guard --dir ./tests --format json{
"version": "1.0.0",
"summary": { "files": 42, "errors": 2, "warnings": 4, "gateStatus": "failed" },
"violations": [
{
"rule": "no-hard-waits",
"severity": "error",
"file": "/tests/checkout.spec.ts",
"line": 24,
"hint": "Replace with waitFor() or an intercept alias"
}
]
}CI/CD Integration
- name: Test quality gate
run: npx @ashforge/assert-guard --dir ./testsassert-guard exits 1 on errors — every CI system treats non-zero exit codes as a build failure automatically.
Full workflow with report artifact:
jobs:
quality-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20.x' }
- run: npm ci
- run: npx @ashforge/assert-guard --dir ./tests --format all --output ./reports
- uses: actions/upload-artifact@v4
if: always()
with:
name: assert-guard-report
path: ./reports/Programmatic API
assert-guard ships full TypeScript declarations and works as a library:
import { loadConfig, resolveFiles, runScan, writeHtmlReport } from '@ashforge/assert-guard';
const config = loadConfig('./ag.config.json');
const files = await resolveFiles('./tests', config);
const result = runScan(files, config);
if (result.gateStatus === 'failed') {
writeHtmlReport(result, './reports');
process.exit(1);
}| Function | Description |
|----------|-------------|
| loadConfig(path?) | Loads config file or falls back to defaults |
| resolveFiles(dir, config) | Resolves test files matching include/exclude globs |
| runScan(files, config) | Runs all active rules. Synchronous. |
| writeHtmlReport(result, dir) | Writes self-contained HTML report, returns path |
| writeJsonReport(result, dir) | Writes JSON results file, returns path |
Framework Support
Framework-agnostic — no test runner installation required.
| Framework | Detected patterns |
|-----------|-------------------|
| Cypress | cy.wait(N) · positional selectors · it.only() |
| Playwright | page.waitForTimeout() · XPath locators |
| Jest | focused tests · assertion counts · shared state |
| Mocha / Jasmine | fit() · fdescribe() · logic in test blocks |
| WebdriverIO | browser.pause() · XPath selectors |
| Vitest | same patterns as Jest |
Roadmap
page-object-enforced— flag raw locator calls outside POM classesno-sleep-in-hooks— catch hard waits insidebeforeEach/afterAllrequire-test-description— enforce meaningful test namesno-console-in-tests— flagconsole.logleft in test files- SARIF output for GitHub Code Scanning
- VS Code extension for inline rule highlighting
Contributing
git clone https://github.com/qa-ashutosh/assert-guard.git
cd assert-guard && npm install && npm run build && npm testSee CONTRIBUTING.md for the rule authoring guide and PR checklist.
