diff --git a/.github/workflows/flakiness-upload-fork-prs.yml b/.github/workflows/flakiness-upload-fork-prs.yml new file mode 100644 index 0000000..c2d80bc --- /dev/null +++ b/.github/workflows/flakiness-upload-fork-prs.yml @@ -0,0 +1,30 @@ +name: Upload Flakiness.io report (fork PRs) +on: + workflow_run: + # Must match the name(s) of workflows that produce flakiness-report artifacts + workflows: ["Tests"] + types: [completed] + +jobs: + upload-flakiness-report: + runs-on: ubuntu-latest + if: >- + (github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure') + && github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name + permissions: + actions: read + contents: read + id-token: write + steps: + - name: Install Flakiness CLI + run: curl -LsSf https://cli.flakiness.io/install.sh | sh + + - name: Download flakiness-report artifacts + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: gh run download "$RUN_ID" --repo "$GITHUB_REPOSITORY" --pattern 'flakiness-report-*' --dir . + + - name: Upload to Flakiness.io + run: find . -path '*/flakiness-report-*/report.json' -exec flakiness upload {} \; diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2a37736 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,59 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + id-token: write + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Setup PNPM + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Run tests + run: pnpm exec playwright test + + test-container: + runs-on: ubuntu-latest + container: node:24 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PNPM + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Run tests + run: pnpm exec playwright test + env: + FK_ENV_CONTAINER: '1' diff --git a/README.md b/README.md index aaa7753..8832328 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![Tests](https://img.shields.io/endpoint?url=https%3A%2F%2Fflakiness.io%2Fapi%2Fbadge%3Finput%3D%257B%2522badgeToken%2522%253A%2522badge-5qUBbhdeYquEDJyBxGAv2X%2522%257D)](https://flakiness.io/flakiness/nodejs-sdk) + # Flakiness Node.js SDK The Flakiness SDK provides a comprehensive set of tools for creating and managing [Flakiness JSON Reports](https://github.com/flakiness/flakiness-report) in Node.js. diff --git a/package.json b/package.json index d291280..5b23c2b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "license": "MIT", "devDependencies": { "@flakiness/flakiness-report": "^0.27.0", + "@flakiness/playwright": "^1.3.2", + "@playwright/test": "^1.58.2", "@types/debug": "^4.1.12", "@types/node": "^25.0.3", "esbuild": "^0.27.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f836870 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + reporter: [ + ['@flakiness/playwright', { flakinessProject: 'flakiness/nodejs-sdk' }], + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 519f0c4..508511a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,12 @@ importers: '@flakiness/flakiness-report': specifier: ^0.27.0 version: 0.27.0(zod@4.3.5) + '@flakiness/playwright': + specifier: ^1.3.2 + version: 1.3.2(zod@4.3.5) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -213,6 +219,19 @@ packages: peerDependencies: zod: ^4.1.12 + '@flakiness/playwright@1.3.2': + resolution: {integrity: sha512-+sAg56fxLWV5Duc9TTbEASnP6Gkn1Y7EjaB9IoK6FZDAo+kqiMi+ezLaBJErXKqCBCqkf+fV1aZKsQuG1WXi+A==} + + '@flakiness/sdk@2.2.1': + resolution: {integrity: sha512-DRo6ygar8JW23sj2Sa1I+oww9HMGRVV9XDAgjJQ+I9YUurVZgdxG/BxmJcU+dbyVFMrQo+FrdFEHEwQ1ZtZfmA==} + peerDependencies: + '@flakiness/flakiness-report': ^0.27.0 + + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -333,6 +352,11 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -462,6 +486,16 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -685,6 +719,30 @@ snapshots: dependencies: zod: 4.3.5 + '@flakiness/playwright@1.3.2(zod@4.3.5)': + dependencies: + '@flakiness/flakiness-report': 0.27.0(zod@4.3.5) + '@flakiness/sdk': 2.2.1(@flakiness/flakiness-report@0.27.0(zod@4.3.5)) + chalk: 5.6.2 + transitivePeerDependencies: + - supports-color + - zod + + '@flakiness/sdk@2.2.1(@flakiness/flakiness-report@0.27.0(zod@4.3.5))': + dependencies: + '@flakiness/flakiness-report': 0.27.0(zod@4.3.5) + chalk: 5.6.2 + debug: 4.4.3 + open: 10.2.0 + stable-hash: 0.0.6 + zod: 4.3.5 + transitivePeerDependencies: + - supports-color + + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@sec-ant/readable-stream@0.4.1': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -813,6 +871,9 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -939,6 +1000,14 @@ snapshots: path-key@4.0.0: {} + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 diff --git a/src/gitWorktree.ts b/src/gitWorktree.ts index 689b7ad..32be03e 100644 --- a/src/gitWorktree.ts +++ b/src/gitWorktree.ts @@ -10,6 +10,18 @@ const log = debug('fk:git'); const execAsync = promisify(exec); +// Workaround for git's "dubious ownership" error (CVE-2022-24765). +// In CI containers (e.g. GitHub Actions with `container:`), the repo +// is bind-mounted from the host with a different UID. We bypass the +// safe.directory check for our read-only git calls via env vars so we +// never touch the user's global git config. +const GIT_SAFE_ENV = { + ...process.env, + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'safe.directory', + GIT_CONFIG_VALUE_0: '*', +}; + /** * Represents a git commit with its metadata. * @@ -94,6 +106,7 @@ export class GitWorktree { const root = shell(`git`, ['rev-parse', '--show-toplevel'], { cwd: somePathInsideGitRepo, encoding: 'utf-8', + env: GIT_SAFE_ENV, }); assert(root, `FAILED: git rev-parse --show-toplevel HEAD @ ${somePathInsideGitRepo}`); return new GitWorktree(root); @@ -139,6 +152,7 @@ export class GitWorktree { const sha = shell(`git`, ['rev-parse', 'HEAD'], { cwd: this._gitRoot, encoding: 'utf-8', + env: GIT_SAFE_ENV, }); assert(sha, `FAILED: git rev-parse HEAD @ ${this._gitRoot}`); return sha.trim() as FlakinessReport.CommitId; @@ -234,7 +248,7 @@ async function listCommits(gitRoot: string, head: string, count: number): Promis const command = `git log ${head} -n ${count} --pretty=format:"${prettyFormat}" -z`; try { - const { stdout } = await execAsync(command, { cwd: gitRoot }); + const { stdout } = await execAsync(command, { cwd: gitRoot, env: GIT_SAFE_ENV }); if (!stdout) { return []; diff --git a/tests/ciutils.spec.ts b/tests/ciutils.spec.ts new file mode 100644 index 0000000..3b51567 --- /dev/null +++ b/tests/ciutils.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; +import { CIUtils } from '../src/ciUtils.js'; + +const isGitHubActions = !!process.env.GITHUB_ACTIONS; + +test.skip(!isGitHubActions, 'Only runs in GitHub Actions'); + +test('runUrl() returns a valid GitHub Actions URL', () => { + const url = CIUtils.runUrl(); + expect(url).toBeTruthy(); + expect(url).toContain('/actions/runs/'); + expect(url).toContain(process.env.GITHUB_RUN_ID!); + // Verify it's a parseable URL + expect(() => new URL(url!)).not.toThrow(); +}); diff --git a/tests/cpuutilization.spec.ts b/tests/cpuutilization.spec.ts new file mode 100644 index 0000000..4502a2c --- /dev/null +++ b/tests/cpuutilization.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; +import { CPUUtilization } from '../src/cpuUtilization.js'; +import os from 'os'; + +test('should work', async () => { + const cpu = new CPUUtilization(); + cpu.sample(); + await new Promise(x => setTimeout(x, 100)); + cpu.sample(); + const report: any = {}; + cpu.enrich(report); + expect(report.cpuCount).toBe(os.cpus().length); + expect(Array.isArray(report.cpuMax)).toBe(true); + expect(Array.isArray(report.cpuAvg)).toBe(true); +}); diff --git a/tests/githuboidc.spec.ts b/tests/githuboidc.spec.ts new file mode 100644 index 0000000..6234c60 --- /dev/null +++ b/tests/githuboidc.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from '@playwright/test'; +import { GithubOIDC } from '../src/githubOIDC.js'; + +test.skip(!process.env.GITHUB_ACTIONS, 'Only runs in GitHub Actions'); +test.skip(!process.env.ACTIONS_ID_TOKEN_REQUEST_URL, 'Github OIDC not available — needs id-token: write permission'); + +test('initializeFromEnv() mints a flakiness token in GitHub Actions with id-token:write', async () => { + const oidc = GithubOIDC.initializeFromEnv(); + expect(oidc).toBeTruthy(); + const token = await oidc!.createFlakinessAccessToken('flakiness/nodejs-sdk'); + expect(token).toBeTruthy(); +}); diff --git a/tests/gitworktree.spec.ts b/tests/gitworktree.spec.ts new file mode 100644 index 0000000..fca1175 --- /dev/null +++ b/tests/gitworktree.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import path from 'path'; +import { GitWorktree } from '../src/gitWorktree.js'; + +test('GitWorktree.create() should work', () => { + const wt = GitWorktree.create('.'); + expect(wt).toBeTruthy(); + expect(wt.rootPath()).toBeTruthy(); +}); + +test('rootPath() returns an absolute path containing this repo', () => { + const wt = GitWorktree.create('.'); + const root = wt.rootPath(); + expect(path.isAbsolute(root)).toBe(true); + // The root should be an ancestor of the current working directory + expect(process.cwd().startsWith(path.normalize(root))).toBe(true); +}); + +test('GitWorktree.headCommitId() returns a 40-char SHA', () => { + const wt = GitWorktree.create('.'); + const commitId = wt.headCommitId(); + expect(commitId).toMatch(/^[0-9a-f]{40}$/); +}); + +test('GitWorktree.gitPath() converts absolute path to git-relative', () => { + const wt = GitWorktree.create('.'); + const gitPath = wt.gitPath(wt.rootPath() + '/package.json'); + expect(gitPath).toBe('package.json'); +}); + +test('GitWorktree.gitPath() works for nested paths', () => { + const wt = GitWorktree.create('.'); + const gitPath = wt.gitPath(wt.rootPath() + '/src/gitWorktree.ts'); + expect(gitPath).toBe('src/gitWorktree.ts'); +}); + +test('GitWorktree.absolutePath() converts git-relative to native absolute', () => { + const wt = GitWorktree.create('.'); + const absPath = wt.absolutePath('package.json'); + expect(absPath).toBe(path.join(wt.rootPath(), 'package.json')); +}); + +test('GitWorktree.gitPath() and absolutePath() are inverses', () => { + const wt = GitWorktree.create('.'); + const original = path.join(wt.rootPath(), 'src', 'gitWorktree.ts'); + const roundtripped = wt.absolutePath(wt.gitPath(original)); + expect(roundtripped).toBe(original); +}); + +test('GitWorktree.listCommits() returns recent commits', async () => { + const wt = GitWorktree.create('.'); + const commits = await wt.listCommits(5); + expect(commits.length).toBe(5); + expect(commits[0].commitId).toMatch(/^[0-9a-f]{40}$/); + expect(commits[0].timestamp).toBeGreaterThan(0); + expect(commits[0].message).toBeTruthy(); + expect(commits[0].commitId).toBe(wt.headCommitId()); +}); + + diff --git a/tests/ramutilization.spec.ts b/tests/ramutilization.spec.ts new file mode 100644 index 0000000..9cac033 --- /dev/null +++ b/tests/ramutilization.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '@playwright/test'; +import { RAMUtilization } from '../src/ramUtilization.js'; +import os from 'os'; + +test('should work', async () => { + const ram = new RAMUtilization(); + ram.sample(); + await new Promise(x => setTimeout(x, 100)); + ram.sample(); + const report: any = {}; + ram.enrich(report); + expect(report.ramBytes).toBe(os.totalmem()); + expect(Array.isArray(report.ram)).toBe(true); +});