diff --git a/.github/workflows/lens-core-template.yml b/.github/workflows/lens-core-template.yml new file mode 100644 index 0000000..9ed396d --- /dev/null +++ b/.github/workflows/lens-core-template.yml @@ -0,0 +1,273 @@ +name: LensCore Accessibility CI + +on: + workflow_call: + inputs: + url: + description: 'Application URL to scan' + required: false + type: string + default: 'http://localhost:3000' + port: + description: 'Application port' + required: false + type: number + default: 3000 + max_urls: + description: 'Maximum number of URLs to scan' + required: false + type: number + default: 10 + scan_depth: + description: 'Depth of crawling' + required: false + type: number + default: 2 + timeout: + description: 'Timeout for each page scan (ms)' + required: false + type: number + default: 15000 + +jobs: + accessibility: + name: Run LensCore Accessibility Scan + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + contents: read + pull-requests: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Detect package manager + id: detect_pm + run: | + if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then + echo "manager=npm" >> $GITHUB_OUTPUT + echo "lockfile=package-lock.json" >> $GITHUB_OUTPUT + elif [ -f yarn.lock ]; then + echo "manager=yarn" >> $GITHUB_OUTPUT + echo "lockfile=yarn.lock" >> $GITHUB_OUTPUT + elif [ -f pnpm-lock.yaml ]; then + echo "manager=pnpm" >> $GITHUB_OUTPUT + echo "lockfile=pnpm-lock.yaml" >> $GITHUB_OUTPUT + else + echo "manager=npm" >> $GITHUB_OUTPUT + echo "lockfile=" >> $GITHUB_OUTPUT + fi + - name: Install system packages + run: | + sudo apt-get update + sudo apt-get install -y jq curl + - name: Install docker-compose + run: | + sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + - name: Extract port and get Docker host IP + id: extract_port + run: | + URL="${{ inputs.url }}" + PORT="${{ inputs.port }}" + if [ -z "$PORT" ] || [ "$PORT" = "0" ]; then + if echo "$URL" | grep -qE ':[0-9]+'; then + PORT=$(echo "$URL" | sed -E 's/.*:([0-9]+).*/\1/') + else + PORT=3000 + fi + fi + DOCKER_HOST_IP=$(docker network inspect bridge --format '{{ (index .IPAM.Config 0).Gateway }}' 2>/dev/null || echo "") + if [ -z "$DOCKER_HOST_IP" ]; then + DOCKER_HOST_IP=$(docker network inspect bridge --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}' 2>/dev/null | head -1 || echo "") + fi + if [ -z "$DOCKER_HOST_IP" ]; then + DOCKER_HOST_IP="172.17.0.1" + echo "::warning::Could not get Docker host IP from bridge network, using default: $DOCKER_HOST_IP" + fi + echo "port=$PORT" >> $GITHUB_OUTPUT + echo "docker_host_ip=$DOCKER_HOST_IP" >> $GITHUB_OUTPUT + echo "app_url=http://app:$PORT" >> $GITHUB_OUTPUT + echo "localhost_url=http://localhost:$PORT" >> $GITHUB_OUTPUT + echo "host_docker_internal_url=http://host.docker.internal:$PORT" >> $GITHUB_OUTPUT + echo "docker_host_url=http://${DOCKER_HOST_IP}:${PORT}" >> $GITHUB_OUTPUT + echo "Docker host IP: $DOCKER_HOST_IP" + echo "Scan URL will be: http://${DOCKER_HOST_IP}:${PORT}" + - name: Setup LensCore + run: | + npm install -g @accesstime/lenscore@latest + mkdir -p ~/.lenscore + cat > ~/.lenscore/config.json << 'CONFIG_EOF' + { + "mode": "local", + "docker": { + "image": "lenscore:latest", + "port": 3001 + }, + "remote": { + "baseUrl": "http://localhost:3001" + }, + "openai": { + "apiKey": "", + "model": "gpt-3.5-turbo", + "enabled": false + } + } + CONFIG_EOF + lens-core build + - name: Prepare Dockerfile + run: | + PM="${{ steps.detect_pm.outputs.manager }}" + PORT="${{ steps.extract_port.outputs.port }}" + if [ ! -f Dockerfile ]; then + echo "Dockerfile not found. Creating a basic Dockerfile for $PM..." + + if [ "$PM" = "npm" ]; then + { + echo "FROM node:20-alpine" + echo "WORKDIR /app" + echo "COPY package*.json ./" + echo "RUN npm ci" + echo "COPY . ." + echo "RUN npm run build || true" + echo "EXPOSE ${PORT}" + echo "CMD [\"npm\", \"start\"]" + } > Dockerfile + elif [ "$PM" = "yarn" ]; then + { + echo "FROM node:20-alpine" + echo "WORKDIR /app" + echo "RUN corepack enable && corepack prepare yarn@stable --activate" + echo "COPY package.json yarn.lock* .yarnrc* ./" + echo "COPY . ." + echo "RUN yarn install" + echo "RUN yarn build || true" + echo "EXPOSE ${PORT}" + echo "CMD [\"yarn\", \"start\"]" + } > Dockerfile + elif [ "$PM" = "pnpm" ]; then + { + echo "FROM node:20-alpine" + echo "WORKDIR /app" + echo "RUN corepack enable && corepack prepare pnpm@latest --activate" + echo "COPY package.json pnpm-lock.yaml ./" + echo "RUN pnpm install --frozen-lockfile" + echo "COPY . ." + echo "RUN pnpm build || true" + echo "EXPOSE ${PORT}" + echo "CMD [\"pnpm\", \"start\"]" + } > Dockerfile + else + echo "::warning::Unknown package manager: $PM. Creating generic Dockerfile..." + { + echo "FROM node:20-alpine" + echo "WORKDIR /app" + echo "COPY package*.json ./" + echo "RUN npm install --production || true" + echo "COPY . ." + echo "EXPOSE ${PORT}" + echo "CMD [\"node\", \"index.js\"]" + } > Dockerfile + fi + echo "✓ Created basic Dockerfile for $PM" + else + echo "✓ Using existing Dockerfile" + fi + - name: Start application + run: | + PORT="${{ steps.extract_port.outputs.port }}" + if [ -f docker-compose.yml ]; then + echo "✓ Found docker-compose.yml, using it to start application" + docker-compose up -d --build + else + echo "No docker-compose.yml found. Building and running application directly..." + docker build -t app-image . + docker run -d --rm \ + --name app \ + -p "${PORT}:${PORT}" \ + -e NODE_ENV=production \ + -e PORT=${PORT} \ + app-image + echo "✓ Application container started" + fi + - name: Wait for LensCore + run: | + timeout=120 + elapsed=0 + while [ $elapsed -lt $timeout ]; do + if curl -sf "http://localhost:3001/api/health" > /dev/null 2>&1; then + echo "✓ LensCore is ready" + exit 0 + fi + sleep 5 + elapsed=$((elapsed + 5)) + done + echo "::error::LensCore did not become available within timeout" + docker ps -a | grep lenscore || true + exit 1 + - name: Wait for application + id: wait_app + run: | + DOCKER_HOST_IP="${{ steps.extract_port.outputs.docker_host_ip }}" + PORT="${{ steps.extract_port.outputs.port }}" + SCAN_BASE_URL="http://${DOCKER_HOST_IP}:${PORT}" + echo "Docker host IP: ${DOCKER_HOST_IP}" + echo "Waiting for app on ${SCAN_BASE_URL}..." + curl --retry 30 --retry-delay 3 --retry-connrefused \ + "${SCAN_BASE_URL}" > /dev/null + echo "✓ Application is accessible at ${SCAN_BASE_URL}" + echo "localhost_url=${SCAN_BASE_URL}" >> $GITHUB_OUTPUT + echo "Previewing application response..." + RESPONSE=$(curl -sS "$SCAN_BASE_URL" 2>/dev/null || echo "") + if [ -n "$RESPONSE" ]; then + echo "Response preview (first 5 lines):" + echo "$RESPONSE" | head -n 5 + fi + echo "scan_url=${SCAN_BASE_URL}" >> $GITHUB_ENV + - name: Run scan + run: | + SCAN_URL="${{ steps.wait_app.outputs.localhost_url }}" + if [ -z "$SCAN_URL" ]; then + DOCKER_HOST_IP="${{ steps.extract_port.outputs.docker_host_ip }}" + PORT="${{ steps.extract_port.outputs.port }}" + SCAN_URL="http://${DOCKER_HOST_IP}:${PORT}" + fi + lens-core scan "$SCAN_URL" \ + -u ${{ inputs.max_urls }} \ + -d ${{ inputs.scan_depth }} \ + -t ${{ inputs.timeout }} \ + --skip-cache \ + --ci \ + -o report.json + - name: Generate HTML report + run: | + SCAN_URL="${{ steps.wait_app.outputs.localhost_url }}" + if [ -z "$SCAN_URL" ]; then + DOCKER_HOST_IP="${{ steps.extract_port.outputs.docker_host_ip }}" + PORT="${{ steps.extract_port.outputs.port }}" + SCAN_URL="http://${DOCKER_HOST_IP}:${PORT}" + fi + lens-core scan "$SCAN_URL" -u ${{ inputs.max_urls }} -d ${{ inputs.scan_depth }} -t ${{ inputs.timeout }} --skip-cache --web || true + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: lenscore-accessibility-report + path: | + report.json + retention-days: 7 + if-no-files-found: warn + + - name: Stop services + if: always() + run: | + if [ -f docker-compose.yml ]; then + docker-compose down -v || true + else + docker stop app || true + docker rm app || true + fi + lens-core down || true diff --git a/README.md b/README.md index bc0485a..a7afe1c 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ 1. **Clone the repository:** ```bash - git clone + git clone https://github.com/Access-Time/LensCore.git cd LensCore ``` @@ -930,6 +930,192 @@ This project adheres to the [Contributor Covenant Code of Conduct](CODE_OF_CONDU --- +## 🧪 GitHub Actions CI Template + +Use these reusable workflows to run automated accessibility testing with LensCore on your web projects. + +### Quick Start + +Choose the appropriate workflow template based on your deployment method: + +#### For Next.js Projects + +Create a `.github/workflows/accessibility.yml` file: + +```yaml +name: Accessibility Check + +on: + pull_request: + branches: [main] + +jobs: + accessibility: + uses: Access-Time/LensCore/.github/workflows/nextjs-lens-core-template.yml@main + with: + url: '' + port: 3000 +``` + +#### For Vercel Deployments + +```yaml +name: Accessibility Check + +on: + pull_request: + branches: [main] + +jobs: + accessibility: + uses: Access-Time/LensCore/.github/workflows/vercel-lens-core-template.yml@main + with: + vercel_org_id: 'your-org-id' + vercel_project_id: 'your-project-id' + secrets: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} +``` + +#### For Custom URLs + +```yaml +name: Accessibility Check + +on: + pull_request: + branches: [main] + +jobs: + accessibility: + uses: Access-Time/LensCore/.github/workflows/lens-core-template.yml@main + with: + url: 'https://your-app.com' +``` + +### How Reusable Workflows Work + +When you use `uses: Access-Time/LensCore/.github/workflows/[template-name].yml@main`, GitHub Actions will: + +1. **Fetch the workflow** from the `Access-Time/LensCore` repository on GitHub +2. **Use the specified reference** (`@main` branch, or you can use a tag/commit SHA) +3. **Run the workflow** in your repository's context + +**Requirements:** + +- The `Access-Time/LensCore` repository must be **public** (or your repository must have access to it) +- Your repository must have **workflow permissions** enabled in Settings → Actions → General → Workflow permissions + +**Using Different Versions:** +You can pin to a specific version for stability: + +```yaml +uses: Access-Time/LensCore/.github/workflows/nextjs-lens-core-template.yml@v1.0.0 +uses: Access-Time/LensCore/.github/workflows/nextjs-lens-core-template.yml@abc123def +``` + +Or use a specific branch: + +```yaml +uses: Access-Time/LensCore/.github/workflows/nextjs-lens-core-template.yml@develop +``` + +### Inputs + +- `mode` (required): `vercel` | `nextjs` | `custom` +- `url` (optional): Target URL to scan (auto-detect if empty) +- `port` (optional): Port where application will run (for nextjs/custom mode, default: 3000) +- `max_urls` (optional): Maximum number of URLs to scan (default: 10) +- `scan_depth` (optional): Depth of crawling (default: 2) +- `timeout` (optional): Timeout for each page scan in milliseconds (default: 15000) +- `vercel_org_id` (optional): Vercel Organization ID (required for vercel mode) +- `vercel_project_id` (optional): Vercel Project ID (required for vercel mode) + +### Secrets + +Add this secret in repository Settings → Secrets and variables → Actions (only required for vercel mode): + +- `VERCEL_TOKEN`: Token from Vercel dashboard + +**Note:** + +- `VERCEL_TOKEN` is only required when using `mode: vercel`. For other modes (nextjs, custom), you don't need to provide it. +- `vercel_org_id` and `vercel_project_id` are regular inputs, not secrets. You can pass them directly in the workflow or store them as repository variables if you prefer. + +### Usage Examples + +#### Mode: Next.js + +```yaml +jobs: + accessibility: + uses: Access-Time/LensCore/.github/workflows/nextjs-lens-core-template.yml@main + with: + mode: nextjs + url: '' + port: 3000 +``` + +The workflow will: + +- Run `npm run build` +- Run `npm start` (port 3000) +- Scan `http://localhost:3000` + +#### Mode: Vercel + +```yaml +jobs: + accessibility: + uses: Access-Time/LensCore/.github/workflows/nextjs-lens-core-template.yml@main + with: + mode: vercel + url: '' + vercel_org_id: 'your-org-id' + vercel_project_id: 'your-project-id' + secrets: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} +``` + +**Note:** `vercel_org_id` and `vercel_project_id` are regular inputs (not secrets). Only `VERCEL_TOKEN` needs to be stored as a secret. + +The workflow will: + +- Deploy to Vercel preview +- Automatically get preview URL +- Scan the preview URL + +#### Mode: Custom + +```yaml +jobs: + accessibility: + uses: Access-Time/LensCore/.github/workflows/nextjs-lens-core-template.yml@main + with: + mode: custom + url: 'http://localhost:5173' + port: 5173 +``` + +For other npm projects (Vite, Remix, etc.). The workflow will: + +- Run `npm run build` +- Run `npm start` (on specified port) +- Scan the specified URL + +### Output + +After the workflow runs: + +- **JSON Report**: `lenscore-report.json` file uploaded as artifact +- **HTML Report**: HTML files uploaded as `lenscore-accessibility-report` artifact +- **CI Status**: + - ✅ **PASS** if no violations found + - ❌ **FAIL** if violations detected + +Reports can be downloaded from GitHub Actions → Artifacts. + +--- + ## 📄 License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/package.json b/package.json index b743306..9a3159f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@accesstime/lenscore", - "version": "0.1.68", + "version": "0.1.69", "description": "Open-source accessibility testing and web crawling platform", "main": "dist/index.js", "bin": { diff --git a/src/cli.ts b/src/cli.ts index 91d2d6d..d756e43 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -115,6 +115,7 @@ program 'Project context (e.g., react,tailwind,typescript)' ) .option('-w, --web', 'Open results in browser (default: JSON output)') + .option('-o, --output ', 'Save JSON output to file') .option('-u, --max-urls ', 'Maximum URLs to crawl', '10') .option('-d, --max-depth ', 'Maximum crawl depth', '2') .option('-t, --timeout ', 'Request timeout in milliseconds', '15000') diff --git a/src/cli/commands/scan.ts b/src/cli/commands/scan.ts index 75111bb..4825ae4 100644 --- a/src/cli/commands/scan.ts +++ b/src/cli/commands/scan.ts @@ -67,7 +67,12 @@ export async function scanCommand(url: string, options: any) { } const webMode = options.web || false; - const reportFilename = CommandUtils.displayScanResults(result, webMode); + const outputFile = options.output || options.o || ''; + const reportFilename = await CommandUtils.displayScanResults( + result, + webMode, + outputFile + ); CommandUtils.displayAIStatus(options, result); await CommandUtils.displayFooter(options, reportFilename || undefined); } catch (error: any) { diff --git a/src/cli/utils/command-utils.ts b/src/cli/utils/command-utils.ts index e36c5ee..07bcb3b 100644 --- a/src/cli/utils/command-utils.ts +++ b/src/cli/utils/command-utils.ts @@ -79,17 +79,23 @@ export class CommandUtils { /** * Display scan results */ - static displayScanResults( + static async displayScanResults( result: any, - webMode: boolean = false - ): string | null { + webMode: boolean = false, + outputFile?: string + ): Promise { if (webMode) { const webReport = new WebReportService(); return webReport.generateScanReport(result); } else { - // JSON output mode - console.log(JSON.stringify(result, null, 2)); - return null; + const jsonOutput = JSON.stringify(result, null, 2); + if (outputFile) { + await fs.writeFile(outputFile, jsonOutput, 'utf8'); + return outputFile; + } else { + console.log(jsonOutput); + return null; + } } }