Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/flakiness-upload-fork-prs.yml
Original file line number Diff line number Diff line change
@@ -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 {} \;
59 changes: 59 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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'
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from '@playwright/test';

export default defineConfig({
testDir: './tests',
reporter: [
['@flakiness/playwright', { flakinessProject: 'flakiness/nodejs-sdk' }],
],
});
69 changes: 69 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 15 additions & 1 deletion src/gitWorktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 [];
Expand Down
15 changes: 15 additions & 0 deletions tests/ciutils.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
15 changes: 15 additions & 0 deletions tests/cpuutilization.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
12 changes: 12 additions & 0 deletions tests/githuboidc.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
61 changes: 61 additions & 0 deletions tests/gitworktree.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
aslushnikov marked this conversation as resolved.
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());
});


14 changes: 14 additions & 0 deletions tests/ramutilization.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading