From 947f4828e5621df410dcc81fcad92b9425090edb Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Mon, 16 Mar 2026 10:35:13 -0600 Subject: [PATCH 1/8] devops: add a workflow to verify git in container. --- .github/workflows/test-container-git.yml | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/test-container-git.yml diff --git a/.github/workflows/test-container-git.yml b/.github/workflows/test-container-git.yml new file mode 100644 index 0000000..41f9c99 --- /dev/null +++ b/.github/workflows/test-container-git.yml @@ -0,0 +1,44 @@ +name: Test git in container + +on: + push: + branches: [main] + pull_request: + +jobs: + test-git-in-container: + runs-on: ubuntu-latest + container: node:24 + steps: + - uses: actions/checkout@v4 + + - name: Verify git fails without safe.directory + run: | + echo "--- Testing bare git rev-parse (should fail) ---" + if git rev-parse --show-toplevel 2>&1; then + echo "UNEXPECTED: git rev-parse succeeded without safe.directory fix" + exit 1 + else + echo "EXPECTED: git rev-parse failed (dubious ownership)" + fi + + - name: Setup PNPM + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Build SDK + run: | + pnpm i --frozen-lockfile + pnpm exec kubik ./build.mts + + - name: Test GitWorktree.create succeeds + run: | + node -e " + import { GitWorktree } from './lib/gitWorktree.js'; + const wt = GitWorktree.create('.'); + const commitId = wt.headCommitId(); + console.log('GitWorktree.create succeeded, HEAD:', commitId); + if (!commitId || commitId.length !== 40) + throw new Error('Invalid commit id: ' + commitId); + " From b0a95aad2bc4789f3b52bab02f1c99439b65a547 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Mon, 16 Mar 2026 10:37:11 -0600 Subject: [PATCH 2/8] fix --- .github/workflows/test-container-git.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-container-git.yml b/.github/workflows/test-container-git.yml index 41f9c99..f122289 100644 --- a/.github/workflows/test-container-git.yml +++ b/.github/workflows/test-container-git.yml @@ -35,7 +35,7 @@ jobs: - name: Test GitWorktree.create succeeds run: | node -e " - import { GitWorktree } from './lib/gitWorktree.js'; + import { GitWorktree } from '@flakiness/sdk'; const wt = GitWorktree.create('.'); const commitId = wt.headCommitId(); console.log('GitWorktree.create succeeded, HEAD:', commitId); From 343560f790db3eb38ea4c01f16865f22e18320cd Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Mon, 16 Mar 2026 10:42:40 -0600 Subject: [PATCH 3/8] ifx --- .github/workflows/test-container-git.yml | 26 ++++++++++++++++++++++-- src/gitWorktree.ts | 16 ++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-container-git.yml b/.github/workflows/test-container-git.yml index f122289..3e32669 100644 --- a/.github/workflows/test-container-git.yml +++ b/.github/workflows/test-container-git.yml @@ -32,13 +32,35 @@ jobs: pnpm i --frozen-lockfile pnpm exec kubik ./build.mts - - name: Test GitWorktree.create succeeds + - name: Test GitWorktree methods all succeed run: | node -e " import { GitWorktree } from '@flakiness/sdk'; + const wt = GitWorktree.create('.'); + console.log('rootPath:', wt.rootPath()); + const commitId = wt.headCommitId(); - console.log('GitWorktree.create succeeded, HEAD:', commitId); + console.log('headCommitId:', commitId); if (!commitId || commitId.length !== 40) throw new Error('Invalid commit id: ' + commitId); + + const gitPath = wt.gitPath(wt.rootPath() + '/package.json'); + console.log('gitPath:', gitPath); + if (gitPath !== 'package.json') + throw new Error('Unexpected gitPath: ' + gitPath); + + const absPath = wt.absolutePath('package.json'); + console.log('absolutePath:', absPath); + if (!absPath.endsWith('/package.json')) + throw new Error('Unexpected absolutePath: ' + absPath); + + const commits = await wt.listCommits(5); + console.log('listCommits returned', commits.length, 'commits'); + if (commits.length === 0) + throw new Error('Expected at least 1 commit'); + if (commits[0].commitId !== commitId) + throw new Error('First commit id mismatch'); + + console.log('All GitWorktree methods passed!'); " 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 []; From c480a593c9fdaf1129fccf811e902928eb992494 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Mon, 16 Mar 2026 10:56:50 -0600 Subject: [PATCH 4/8] add proper tests --- .../workflows/flakiness-upload-fork-prs.yml | 30 +++++++ .github/workflows/test-container-git.yml | 78 +++++++++---------- README.md | 2 + package.json | 2 + playwright.config.ts | 8 ++ pnpm-lock.yaml | 69 ++++++++++++++++ tests/ciutils.spec.ts | 27 +++++++ tests/cpuutilization.spec.ts | 20 +++++ tests/githuboidc.spec.ts | 16 ++++ tests/gitworktree.spec.ts | 64 +++++++++++++++ tests/ramutilization.spec.ts | 19 +++++ 11 files changed, 295 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/flakiness-upload-fork-prs.yml create mode 100644 playwright.config.ts create mode 100644 tests/ciutils.spec.ts create mode 100644 tests/cpuutilization.spec.ts create mode 100644 tests/githuboidc.spec.ts create mode 100644 tests/gitworktree.spec.ts create mode 100644 tests/ramutilization.spec.ts 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/test-container-git.yml b/.github/workflows/test-container-git.yml index 3e32669..1b57e87 100644 --- a/.github/workflows/test-container-git.yml +++ b/.github/workflows/test-container-git.yml @@ -1,20 +1,48 @@ -name: Test git in container +name: Tests on: push: branches: [main] pull_request: +permissions: + contents: read + id-token: write + jobs: - test-git-in-container: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - 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 - - name: Verify git fails without safe.directory + - name: Verify bare git fails without safe.directory run: | - echo "--- Testing bare git rev-parse (should fail) ---" if git rev-parse --show-toplevel 2>&1; then echo "UNEXPECTED: git rev-parse succeeded without safe.directory fix" exit 1 @@ -27,40 +55,10 @@ jobs: with: version: 10 - - name: Build SDK - run: | - pnpm i --frozen-lockfile - pnpm exec kubik ./build.mts - - - name: Test GitWorktree methods all succeed - run: | - node -e " - import { GitWorktree } from '@flakiness/sdk'; - - const wt = GitWorktree.create('.'); - console.log('rootPath:', wt.rootPath()); - - const commitId = wt.headCommitId(); - console.log('headCommitId:', commitId); - if (!commitId || commitId.length !== 40) - throw new Error('Invalid commit id: ' + commitId); - - const gitPath = wt.gitPath(wt.rootPath() + '/package.json'); - console.log('gitPath:', gitPath); - if (gitPath !== 'package.json') - throw new Error('Unexpected gitPath: ' + gitPath); - - const absPath = wt.absolutePath('package.json'); - console.log('absolutePath:', absPath); - if (!absPath.endsWith('/package.json')) - throw new Error('Unexpected absolutePath: ' + absPath); - - const commits = await wt.listCommits(5); - console.log('listCommits returned', commits.length, 'commits'); - if (commits.length === 0) - throw new Error('Expected at least 1 commit'); - if (commits[0].commitId !== commitId) - throw new Error('First commit id mismatch'); + - name: Install dependencies + run: pnpm i --frozen-lockfile - console.log('All GitWorktree methods passed!'); - " + - 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/tests/ciutils.spec.ts b/tests/ciutils.spec.ts new file mode 100644 index 0000000..168664b --- /dev/null +++ b/tests/ciutils.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; +import { CIUtils } from '../src/ciUtils.js'; + +const isContainer = process.env.FK_ENV_CONTAINER === '1'; +const isGitHubActions = !!process.env.GITHUB_ACTIONS; + +test('runUrl() returns undefined outside CI', () => { + test.skip(isGitHubActions, 'Only runs outside GitHub Actions'); + expect(CIUtils.runUrl()).toBeUndefined(); +}); + +test('runUrl() returns a valid GitHub Actions URL', () => { + test.skip(!isGitHubActions, 'Only runs in GitHub Actions'); + 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(); +}); + +test('runUrl() includes run attempt when available', () => { + test.skip(!isGitHubActions, 'Only runs in GitHub Actions'); + test.skip(!process.env.GITHUB_RUN_ATTEMPT, 'No GITHUB_RUN_ATTEMPT set'); + const url = CIUtils.runUrl()!; + expect(url).toContain('attempt=' + process.env.GITHUB_RUN_ATTEMPT); +}); diff --git a/tests/cpuutilization.spec.ts b/tests/cpuutilization.spec.ts new file mode 100644 index 0000000..bea70b6 --- /dev/null +++ b/tests/cpuutilization.spec.ts @@ -0,0 +1,20 @@ +import { test, expect } from '@playwright/test'; +import { CPUUtilization } from '../src/cpuUtilization.js'; +import os from 'os'; + +test('sample() does not throw', () => { + const cpu = new CPUUtilization(); + expect(() => cpu.sample()).not.toThrow(); +}); + +test('enrich() adds cpu telemetry to report', () => { + const cpu = new CPUUtilization(); + cpu.sample(); + cpu.sample(); + const report: any = {}; + cpu.enrich(report); + expect(report.cpuCount).toBe(os.cpus().length); + expect(report.cpuCount).toBeGreaterThan(0); + 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..61f2d63 --- /dev/null +++ b/tests/githuboidc.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from '@playwright/test'; +import { GithubOIDC } from '../src/githubOIDC.js'; + +const isGitHubActions = !!process.env.GITHUB_ACTIONS; + +test('initializeFromEnv() returns undefined outside GitHub Actions', () => { + test.skip(isGitHubActions, 'Only runs outside GitHub Actions'); + expect(GithubOIDC.initializeFromEnv()).toBeUndefined(); +}); + +test('initializeFromEnv() returns an instance in GitHub Actions with id-token:write', () => { + test.skip(!isGitHubActions, 'Only runs in GitHub Actions'); + test.skip(!process.env.ACTIONS_ID_TOKEN_REQUEST_URL, 'OIDC not available — needs id-token: write permission'); + const oidc = GithubOIDC.initializeFromEnv(); + expect(oidc).toBeTruthy(); +}); diff --git a/tests/gitworktree.spec.ts b/tests/gitworktree.spec.ts new file mode 100644 index 0000000..f9a7927 --- /dev/null +++ b/tests/gitworktree.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test'; +import { execSync } from 'child_process'; +import { GitWorktree } from '../src/gitWorktree.js'; + +test('create() returns a GitWorktree for the current repo', () => { + 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(root).toMatch(/^\//); + // The root should be an ancestor of the current working directory + expect(process.cwd().startsWith(root)).toBe(true); +}); + +test('headCommitId() returns a 40-char SHA', () => { + const wt = GitWorktree.create('.'); + const commitId = wt.headCommitId(); + expect(commitId).toMatch(/^[0-9a-f]{40}$/); +}); + +test('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('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('absolutePath() converts git-relative to native absolute', () => { + const wt = GitWorktree.create('.'); + const absPath = wt.absolutePath('package.json'); + expect(absPath).toBe(wt.rootPath() + '/package.json'); +}); + +test('gitPath() and absolutePath() are inverses', () => { + const wt = GitWorktree.create('.'); + const original = wt.rootPath() + '/src/gitWorktree.ts'; + const roundtripped = wt.absolutePath(wt.gitPath(original)); + expect(roundtripped).toBe(original); +}); + +test('listCommits() returns recent commits', async () => { + const wt = GitWorktree.create('.'); + const commits = await wt.listCommits(5); + expect(commits.length).toBeGreaterThan(0); + expect(commits[0].commitId).toMatch(/^[0-9a-f]{40}$/); + expect(commits[0].timestamp).toBeGreaterThan(0); + expect(commits[0].message).toBeTruthy(); +}); + +test('listCommits() HEAD matches headCommitId()', async () => { + const wt = GitWorktree.create('.'); + const commits = await wt.listCommits(1); + 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..2d82386 --- /dev/null +++ b/tests/ramutilization.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; +import { RAMUtilization } from '../src/ramUtilization.js'; +import os from 'os'; + +test('sample() does not throw', () => { + const ram = new RAMUtilization(); + expect(() => ram.sample()).not.toThrow(); +}); + +test('enrich() adds ram telemetry to report', () => { + const ram = new RAMUtilization(); + ram.sample(); + ram.sample(); + const report: any = {}; + ram.enrich(report); + expect(report.ramBytes).toBe(os.totalmem()); + expect(report.ramBytes).toBeGreaterThan(0); + expect(Array.isArray(report.ram)).toBe(true); +}); From b1e5a6fc8791fcc271c3836382a9f46308518799 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Mon, 16 Mar 2026 11:06:06 -0600 Subject: [PATCH 5/8] fix tests --- .github/workflows/test-container-git.yml | 9 --------- tests/gitworktree.spec.ts | 7 ++++--- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test-container-git.yml b/.github/workflows/test-container-git.yml index 1b57e87..4e217c3 100644 --- a/.github/workflows/test-container-git.yml +++ b/.github/workflows/test-container-git.yml @@ -41,15 +41,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Verify bare git fails without safe.directory - run: | - if git rev-parse --show-toplevel 2>&1; then - echo "UNEXPECTED: git rev-parse succeeded without safe.directory fix" - exit 1 - else - echo "EXPECTED: git rev-parse failed (dubious ownership)" - fi - - name: Setup PNPM uses: pnpm/action-setup@v4 with: diff --git a/tests/gitworktree.spec.ts b/tests/gitworktree.spec.ts index f9a7927..0c9fe64 100644 --- a/tests/gitworktree.spec.ts +++ b/tests/gitworktree.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '@playwright/test'; import { execSync } from 'child_process'; +import path from 'path'; import { GitWorktree } from '../src/gitWorktree.js'; test('create() returns a GitWorktree for the current repo', () => { @@ -11,7 +12,7 @@ test('create() returns a GitWorktree for the current repo', () => { test('rootPath() returns an absolute path containing this repo', () => { const wt = GitWorktree.create('.'); const root = wt.rootPath(); - expect(root).toMatch(/^\//); + expect(path.isAbsolute(root)).toBe(true); // The root should be an ancestor of the current working directory expect(process.cwd().startsWith(root)).toBe(true); }); @@ -37,12 +38,12 @@ test('gitPath() works for nested paths', () => { test('absolutePath() converts git-relative to native absolute', () => { const wt = GitWorktree.create('.'); const absPath = wt.absolutePath('package.json'); - expect(absPath).toBe(wt.rootPath() + '/package.json'); + expect(absPath).toBe(path.join(wt.rootPath(), 'package.json')); }); test('gitPath() and absolutePath() are inverses', () => { const wt = GitWorktree.create('.'); - const original = wt.rootPath() + '/src/gitWorktree.ts'; + const original = path.join(wt.rootPath(), 'src', 'gitWorktree.ts'); const roundtripped = wt.absolutePath(wt.gitPath(original)); expect(roundtripped).toBe(original); }); From 21cfc8c99acd1f858aeac339de6d26a4c381c850 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Mon, 16 Mar 2026 11:15:40 -0600 Subject: [PATCH 6/8] fix --- tests/gitworktree.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/gitworktree.spec.ts b/tests/gitworktree.spec.ts index 0c9fe64..86dbbc2 100644 --- a/tests/gitworktree.spec.ts +++ b/tests/gitworktree.spec.ts @@ -14,7 +14,7 @@ test('rootPath() returns an absolute path containing this repo', () => { 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(root)).toBe(true); + expect(process.cwd().startsWith(path.normalize(root))).toBe(true); }); test('headCommitId() returns a 40-char SHA', () => { From 4c1a4cb03463aac17602a4f44823a516e05e583a Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Mon, 16 Mar 2026 11:26:52 -0600 Subject: [PATCH 7/8] chore: refactor tests --- .../{test-container-git.yml => tests.yml} | 0 tests/ciutils.spec.ts | 14 +----------- tests/cpuutilization.spec.ts | 9 ++------ tests/githuboidc.spec.ts | 14 +++++------- tests/gitworktree.spec.ts | 22 ++++++++----------- tests/ramutilization.spec.ts | 9 ++------ 6 files changed, 19 insertions(+), 49 deletions(-) rename .github/workflows/{test-container-git.yml => tests.yml} (100%) diff --git a/.github/workflows/test-container-git.yml b/.github/workflows/tests.yml similarity index 100% rename from .github/workflows/test-container-git.yml rename to .github/workflows/tests.yml diff --git a/tests/ciutils.spec.ts b/tests/ciutils.spec.ts index 168664b..3b51567 100644 --- a/tests/ciutils.spec.ts +++ b/tests/ciutils.spec.ts @@ -1,16 +1,11 @@ import { test, expect } from '@playwright/test'; import { CIUtils } from '../src/ciUtils.js'; -const isContainer = process.env.FK_ENV_CONTAINER === '1'; const isGitHubActions = !!process.env.GITHUB_ACTIONS; -test('runUrl() returns undefined outside CI', () => { - test.skip(isGitHubActions, 'Only runs outside GitHub Actions'); - expect(CIUtils.runUrl()).toBeUndefined(); -}); +test.skip(!isGitHubActions, 'Only runs in GitHub Actions'); test('runUrl() returns a valid GitHub Actions URL', () => { - test.skip(!isGitHubActions, 'Only runs in GitHub Actions'); const url = CIUtils.runUrl(); expect(url).toBeTruthy(); expect(url).toContain('/actions/runs/'); @@ -18,10 +13,3 @@ test('runUrl() returns a valid GitHub Actions URL', () => { // Verify it's a parseable URL expect(() => new URL(url!)).not.toThrow(); }); - -test('runUrl() includes run attempt when available', () => { - test.skip(!isGitHubActions, 'Only runs in GitHub Actions'); - test.skip(!process.env.GITHUB_RUN_ATTEMPT, 'No GITHUB_RUN_ATTEMPT set'); - const url = CIUtils.runUrl()!; - expect(url).toContain('attempt=' + process.env.GITHUB_RUN_ATTEMPT); -}); diff --git a/tests/cpuutilization.spec.ts b/tests/cpuutilization.spec.ts index bea70b6..4502a2c 100644 --- a/tests/cpuutilization.spec.ts +++ b/tests/cpuutilization.spec.ts @@ -2,19 +2,14 @@ import { test, expect } from '@playwright/test'; import { CPUUtilization } from '../src/cpuUtilization.js'; import os from 'os'; -test('sample() does not throw', () => { - const cpu = new CPUUtilization(); - expect(() => cpu.sample()).not.toThrow(); -}); - -test('enrich() adds cpu telemetry to report', () => { +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(report.cpuCount).toBeGreaterThan(0); 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 index 61f2d63..6234c60 100644 --- a/tests/githuboidc.spec.ts +++ b/tests/githuboidc.spec.ts @@ -1,16 +1,12 @@ import { test, expect } from '@playwright/test'; import { GithubOIDC } from '../src/githubOIDC.js'; -const isGitHubActions = !!process.env.GITHUB_ACTIONS; +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() returns undefined outside GitHub Actions', () => { - test.skip(isGitHubActions, 'Only runs outside GitHub Actions'); - expect(GithubOIDC.initializeFromEnv()).toBeUndefined(); -}); - -test('initializeFromEnv() returns an instance in GitHub Actions with id-token:write', () => { - test.skip(!isGitHubActions, 'Only runs in GitHub Actions'); - test.skip(!process.env.ACTIONS_ID_TOKEN_REQUEST_URL, '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 index 86dbbc2..fca1175 100644 --- a/tests/gitworktree.spec.ts +++ b/tests/gitworktree.spec.ts @@ -3,7 +3,7 @@ import { execSync } from 'child_process'; import path from 'path'; import { GitWorktree } from '../src/gitWorktree.js'; -test('create() returns a GitWorktree for the current repo', () => { +test('GitWorktree.create() should work', () => { const wt = GitWorktree.create('.'); expect(wt).toBeTruthy(); expect(wt.rootPath()).toBeTruthy(); @@ -17,49 +17,45 @@ test('rootPath() returns an absolute path containing this repo', () => { expect(process.cwd().startsWith(path.normalize(root))).toBe(true); }); -test('headCommitId() returns a 40-char SHA', () => { +test('GitWorktree.headCommitId() returns a 40-char SHA', () => { const wt = GitWorktree.create('.'); const commitId = wt.headCommitId(); expect(commitId).toMatch(/^[0-9a-f]{40}$/); }); -test('gitPath() converts absolute path to git-relative', () => { +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('gitPath() works for nested paths', () => { +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('absolutePath() converts git-relative to native absolute', () => { +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('gitPath() and absolutePath() are inverses', () => { +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('listCommits() returns recent commits', async () => { +test('GitWorktree.listCommits() returns recent commits', async () => { const wt = GitWorktree.create('.'); const commits = await wt.listCommits(5); - expect(commits.length).toBeGreaterThan(0); + 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(); -}); - -test('listCommits() HEAD matches headCommitId()', async () => { - const wt = GitWorktree.create('.'); - const commits = await wt.listCommits(1); expect(commits[0].commitId).toBe(wt.headCommitId()); }); + diff --git a/tests/ramutilization.spec.ts b/tests/ramutilization.spec.ts index 2d82386..9cac033 100644 --- a/tests/ramutilization.spec.ts +++ b/tests/ramutilization.spec.ts @@ -2,18 +2,13 @@ import { test, expect } from '@playwright/test'; import { RAMUtilization } from '../src/ramUtilization.js'; import os from 'os'; -test('sample() does not throw', () => { - const ram = new RAMUtilization(); - expect(() => ram.sample()).not.toThrow(); -}); - -test('enrich() adds ram telemetry to report', () => { +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(report.ramBytes).toBeGreaterThan(0); expect(Array.isArray(report.ram)).toBe(true); }); From e8e5a7031fad894a5125c28f67ea25ddfe2f13a0 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Mon, 16 Mar 2026 13:19:59 -0600 Subject: [PATCH 8/8] fix tests --- .github/workflows/tests.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4e217c3..2a37736 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,6 +18,8 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 @@ -40,6 +42,8 @@ jobs: container: node:24 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup PNPM uses: pnpm/action-setup@v4