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
7 changes: 7 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ export default [
process: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
fetch: 'readonly',
AbortController: 'readonly',
Promise: 'readonly',
URL: 'readonly',
Buffer: 'readonly',
},
},
rules: {
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -449,8 +449,8 @@
"cleanup:test-groups": "node scripts/cleanup-test-groups.js",
"cleanup:test-groups:dry-run": "node scripts/cleanup-test-groups.js --dry-run",
"cleanup:test-groups:force": "node scripts/cleanup-test-groups.js --force",
"lint": "tsc --noEmit && eslint \"{src,tests}/**/*.ts\" ",
"lint:fix": "eslint \"{src,tests}/**/*.ts\" --fix",
"lint": "tsc --noEmit && eslint \"{src,tests}/**/*.{ts,js}\"",
"lint:fix": "eslint \"{src,tests}/**/*.{ts,js}\" --fix",
"format": "prettier --write \"**/*.{js,ts,json,md}\"",
"format:check": "prettier --check \"**/*.{js,ts,json,md}\"",
"list-tools": "node dist/src/cli/list-tools.js",
Expand Down
303 changes: 155 additions & 148 deletions tests/integration/data-lifecycle.test.ts

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion tests/integration/debug-widget-assignment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
*/

import { IntegrationTestHelper } from './helpers/registry-helper';
import { describeIfTier } from '../setup/tierGate';

describe('Debug Widget Assignment', () => {
// Epics + colour widget are Premium/Ultimate features — skip on Free instances.
describeIfTier('premium', 'Debug Widget Assignment', () => {
let helper: IntegrationTestHelper;

beforeAll(async () => {
Expand Down
3 changes: 2 additions & 1 deletion tests/integration/requirements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@

import { IntegrationTestHelper } from './helpers/registry-helper';
import { requireTestData } from '../setup/testConfig';
import { describeIfTier } from '../setup/tierGate';

describe('Requirements Verification - Integration Tests', () => {
describeIfTier('ultimate', 'Requirements Verification - Integration Tests', () => {
let helper: IntegrationTestHelper;
let createdRequirementId: string | undefined;
let testProjectPath: string;
Expand Down
94 changes: 94 additions & 0 deletions tests/setup/globalSetup.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,20 @@
const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');

Check warning on line 11 in tests/setup/globalSetup.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:crypto` over `crypto`.

See more on https://sonarcloud.io/project/issues?id=structured-world_gitlab-mcp&issues=AZ5QCdTtL0ydMQyMYeYn&open=AZ5QCdTtL0ydMQyMYeYn&pullRequest=429
const { config } = require('dotenv');

// Namespace tmp artefacts by checkout root so concurrent runs across multiple
// worktrees (or different machines sharing a tmpdir over NFS) cannot clobber
// each other's tier-detection cache. sha256 here is a cache-key digest, not a
// security primitive — it just needs to be collision-resistant across paths.
const REPO_HASH = crypto
.createHash('sha256')
.update(path.resolve(__dirname, '../..'))
.digest('hex')
.slice(0, 12);
Comment thread
polaz marked this conversation as resolved.

module.exports = async () => {

Check failure on line 24 in tests/setup/globalSetup.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 25 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=structured-world_gitlab-mcp&issues=AZ5Qs30iqJQ-O8gWZfO_&open=AZ5Qs30iqJQ-O8gWZfO_&pullRequest=429
// Load .env.test file first (same as setupTests.ts)
const envTestPath = path.resolve(__dirname, '../../.env.test');
if (fs.existsSync(envTestPath)) {
Expand Down Expand Up @@ -40,5 +51,88 @@
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}

// Detect GitLab tier once. Premium/Ultimate-only test suites use this to
// skip rather than fail on Free instances. Result lives in a tmp file so
// each Jest worker can read it synchronously at setup load time, before
// describeIfTier blocks parse.
const tierFile = path.join(os.tmpdir(), `gitlab-mcp-detected-tier-${REPO_HASH}.json`);
// Best-effort cleanup: force:true suppresses ENOENT but EPERM/EACCES (Windows
// file locks, perms) would still throw and crash the integration run before
// any tests start. writeFileSync below overwrites anyway, so a failed unlink
// is not load-bearing — just log and proceed.
// fs.rmSync's maxRetries/retryDelay are honoured only when recursive:true,
// so emulate the retry window explicitly with a small backoff loop.
let removed = false;
for (let attempt = 0; attempt < 3 && !removed; attempt += 1) {
try {
fs.rmSync(tierFile, { force: true });
removed = true;
} catch (err) {
if (attempt === 2) {
const reason = err instanceof Error ? err.message : String(err);
console.warn(`⚠️ Could not remove stale tier cache (${reason}) — will overwrite`);
} else {
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Comment thread
polaz marked this conversation as resolved.
// Match the codebase auth convention from src/utils/fetch.ts:
// OAuth mode → Authorization: Bearer <token>
// PAT mode → PRIVATE-TOKEN: <token> (GitLab's canonical PAT header)
// PATs also accept Bearer, but PRIVATE-TOKEN keeps us consistent with the
// rest of the codebase and avoids confusion when debugging auth issues.
// AbortController guards against a hung connection blocking suite startup
// (Jest's per-test timeout doesn't apply in globalSetup).
const oauthMode = String(process.env.OAUTH_ENABLED ?? '').toLowerCase() === 'true';
const authHeaders = oauthMode
? { Authorization: `Bearer ${process.env.GITLAB_TOKEN}` }
: { 'PRIVATE-TOKEN': process.env.GITLAB_TOKEN };
const controller = new AbortController();
Comment thread
polaz marked this conversation as resolved.
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const res = await fetch(`${process.env.GITLAB_API_URL}/api/graphql`, {
method: 'POST',
headers: {
...authHeaders,
'Content-Type': 'application/json',
Comment thread
polaz marked this conversation as resolved.
},
body: JSON.stringify({ query: '{ currentLicense { plan } }' }),
Comment thread
polaz marked this conversation as resolved.
signal: controller.signal,
});
// Treat non-2xx and GraphQL errors as DETECTION FAILURE (caught + warned)
// rather than as a Free-tier response — otherwise auth/network breakage
// would silently skip every Premium/Ultimate suite.
if (!res.ok) {
throw new Error(`Tier detection HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();
if (Array.isArray(data?.errors) && data.errors.length > 0) {
throw new Error(`Tier detection GraphQL error: ${data.errors[0]?.message ?? 'unknown'}`);
}
const plan = (data?.data?.currentLicense?.plan ?? '').toLowerCase();
let tier = 'free';
if (plan.includes('ultimate') || plan.includes('gold')) tier = 'ultimate';
else if (plan.includes('premium') || plan.includes('silver')) tier = 'premium';
fs.writeFileSync(tierFile, JSON.stringify({ tier, plan }));
console.log(`🎫 Detected GitLab tier: ${tier}${plan ? ` (plan: ${plan})` : ''}`);

Check warning on line 118 in tests/setup/globalSetup.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=structured-world_gitlab-mcp&issues=AZ5PvHbXJ8rGNG5RUn4T&open=AZ5PvHbXJ8rGNG5RUn4T&pullRequest=429
} catch (err) {
// Use 'unknown' sentinel on detection failure — NOT 'free'. Silently
// defaulting to 'free' would skip every Premium/Ultimate suite and produce
// a green run that hides real regressions. tierGate.ts treats 'unknown'
// as "do not gate" so the suites still run; if the underlying feature is
// genuinely unavailable, the test fails loudly with the real API error.
const reason = err instanceof Error ? err.message : String(err);
fs.writeFileSync(
tierFile,
JSON.stringify({ tier: 'unknown', plan: '', detectionFailed: true, reason }),
);
console.warn(
`⚠️ Tier detection failed (${reason}) — marking tier as 'unknown' (suites will run, not skip)`,
);
} finally {
clearTimeout(timeoutId);
}
Comment thread
polaz marked this conversation as resolved.

console.log('✅ Environment validated - starting test data lifecycle chain');
};
128 changes: 128 additions & 0 deletions tests/setup/tierGate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Tier-gating helper for integration tests.
*
* Some integration tests exercise features that require a paid GitLab tier
* (Premium/Ultimate). When the target instance is GitLab Free (or EE binary
* without a license), those tests must be SKIPPED rather than failed — the
* underlying behavior is "feature unavailable", not "code broken".
*
* Tier is detected once in globalSetup.js (one HTTP call against currentLicense)
* and written to a tmp file. This module reads it synchronously at module load
* so describeIfTier can be used at module top-level inside test files.
*
* Usage:
*
* import { describeIfTier, itIfTier } from '../setup/tierGate';
*
* describeIfTier('ultimate', 'Requirements verification', () => {
* it('verifies a requirement', async () => { ... });
* });
*
* describe('mixed-tier suite', () => {
* it('runs on all tiers', () => { ... });
* itIfTier('premium', 'runs on premium and ultimate', () => { ... });
* });
*/

import * as crypto from 'crypto';

Check warning on line 27 in tests/setup/tierGate.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:crypto` over `crypto`.

See more on https://sonarcloud.io/project/issues?id=structured-world_gitlab-mcp&issues=AZ5QCdPrL0ydMQyMYeYl&open=AZ5QCdPrL0ydMQyMYeYl&pullRequest=429
import * as fs from 'fs';

Check warning on line 28 in tests/setup/tierGate.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=structured-world_gitlab-mcp&issues=AZ5PvHbGJ8rGNG5RUn4Q&open=AZ5PvHbGJ8rGNG5RUn4Q&pullRequest=429
import * as os from 'os';

Check warning on line 29 in tests/setup/tierGate.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:os` over `os`.

See more on https://sonarcloud.io/project/issues?id=structured-world_gitlab-mcp&issues=AZ5PvHbGJ8rGNG5RUn4R&open=AZ5PvHbGJ8rGNG5RUn4R&pullRequest=429
import * as path from 'path';

Check warning on line 30 in tests/setup/tierGate.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=structured-world_gitlab-mcp&issues=AZ5PvHbGJ8rGNG5RUn4S&open=AZ5PvHbGJ8rGNG5RUn4S&pullRequest=429
import { z } from 'zod';

export type GitLabTier = 'free' | 'premium' | 'ultimate';

/**
* Internal detection result. 'unknown' is a sentinel meaning globalSetup
* could not determine the tier (network failure / 4xx / GraphQL error).
* We never gate on 'unknown' — it bypasses skip logic so the suite runs
* and fails loudly with the real underlying error instead of producing a
* misleading green run with silent skips.
*/
type DetectedTier = GitLabTier | 'unknown';

const TIER_RANK: Record<GitLabTier, number> = { free: 0, premium: 1, ultimate: 2 };

// Cache file is written by globalSetup.js across a process boundary, so treat
// it as untrusted input and validate with Zod (project convention for any
// external/runtime-loaded payload).
const TierCacheSchema = z.object({
tier: z.enum(['free', 'premium', 'ultimate', 'unknown']).optional(),
detectionFailed: z.boolean().optional(),
reason: z.string().optional(),
});

// Mirror globalSetup.js: namespace by checkout root hash so concurrent runs
// across worktrees (or NFS-shared tmpdirs) don't collide on the same cache file.
// sha256 here is a cache-key digest, not a security primitive.
const REPO_HASH = crypto
.createHash('sha256')
.update(path.resolve(__dirname, '../..'))
.digest('hex')
.slice(0, 12);

const TIER_FILE = path.join(os.tmpdir(), `gitlab-mcp-detected-tier-${REPO_HASH}.json`);

function readDetectedTier(): DetectedTier {
try {
if (!fs.existsSync(TIER_FILE)) return 'unknown';
const parsed = TierCacheSchema.safeParse(JSON.parse(fs.readFileSync(TIER_FILE, 'utf8')));
if (!parsed.success || !parsed.data.tier) return 'unknown';
return parsed.data.tier;
} catch {
return 'unknown';
}
}

const DETECTED_TIER: DetectedTier = readDetectedTier();

/**
* Return the tier detected during globalSetup. Returns 'unknown' when
* detection failed (network/auth/cache-miss) — caller can distinguish a
* confirmed Free instance from a failed detection.
*/
export function getDetectedTier(): DetectedTier {
return DETECTED_TIER;
}

/**
* True if the detected tier satisfies (>=) the required tier.
* When detection failed ('unknown'), returns true — we'd rather run the
* gated suite and fail loudly with the real error than silently skip and
* hide regressions behind a misleading green-with-pending run.
*/
export function tierSatisfies(required: GitLabTier): boolean {
if (DETECTED_TIER === 'unknown') return true;
return TIER_RANK[DETECTED_TIER] >= TIER_RANK[required];
}

/**
* Describe block that runs only when the detected GitLab tier meets the
* required minimum. Otherwise emits describe.skip so the suite reports as
* pending instead of failing.
*/
export function describeIfTier(required: GitLabTier, name: string, fn: () => void): void {
if (tierSatisfies(required)) {
describe(name, fn);
} else {
describe.skip(`${name} [skipped: requires ${required}, detected ${DETECTED_TIER}]`, fn);
}
}

/**
* It block that runs only when the detected GitLab tier meets the required
* minimum. Useful for a single tier-gated assertion inside an otherwise
* tier-agnostic describe block.
*/
export function itIfTier(
required: GitLabTier,
name: string,
fn: jest.ProvidesCallback,
timeout?: number,
): void {
if (tierSatisfies(required)) {
it(name, fn, timeout);
} else {
it.skip(`${name} [skipped: requires ${required}, detected ${DETECTED_TIER}]`, fn, timeout);
}
}
Loading