diff --git a/eslint.config.mjs b/eslint.config.mjs index 8f9a401c3..fde1a3252 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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: { diff --git a/package.json b/package.json index 95d7e29ff..ca86d4da2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/integration/data-lifecycle.test.ts b/tests/integration/data-lifecycle.test.ts index 96d628d2c..10e9c4137 100644 --- a/tests/integration/data-lifecycle.test.ts +++ b/tests/integration/data-lifecycle.test.ts @@ -18,6 +18,7 @@ import { GITLAB_TOKEN, GITLAB_API_URL, updateTestData, getTestData } from '../se import { GraphQLClient } from '../../src/graphql/client'; import { ConnectionManager } from '../../src/services/ConnectionManager'; import { getWorkItemTypes } from '../../src/utils/workItemTypes'; +import { itIfTier, describeIfTier } from '../setup/tierGate'; import { gql } from 'graphql-tag'; import { IntegrationTestHelper } from './helpers/registry-helper'; @@ -583,177 +584,183 @@ describe('🔄 Data Lifecycle - Complete Infrastructure Setup', () => { ); }); - it('should create GROUP-level work items (Epics - depends on group)', async () => { - const testData = getTestData(); - expect(testData.group?.id).toBeDefined(); - console.log( - '🔧 Creating GROUP-level work items (Epics) using GraphQL with dynamic type discovery...', - ); - - // Get work item types directly using utility function (not exposed as tool) - console.log('🔍 Getting work item types for group namespace using internal utility...'); - const groupWorkItemTypes = await getWorkItemTypes(testData.group!.path); - console.log( - '📋 Available group work item types:', - groupWorkItemTypes.map((t) => `${t.name}(${t.id})`).join(', '), - ); - - const epicType = groupWorkItemTypes.find((t) => t.name === 'Epic'); - expect(epicType).toBeDefined(); - - const createdGroupWorkItems: any[] = []; - - // 🚨 CRITICAL: Create diverse Epics to test with and without widgets - // Get created labels and milestones for widget testing - const labels = testData.labels || []; - const milestones = testData.milestones || []; - const user = testData.user; + itIfTier( + 'premium', + 'should create GROUP-level work items (Epics - depends on group)', + async () => { + const testData = getTestData(); + expect(testData.group?.id).toBeDefined(); + console.log( + '🔧 Creating GROUP-level work items (Epics) using GraphQL with dynamic type discovery...', + ); - const groupWorkItemsData: Array<{ - title: string; - description: string; - workItemType: string; - assigneeIds?: string[]; - labelIds?: string[]; - milestoneId?: string; - }> = [ - { - title: `Test Epic (No Widgets) ${timestamp}`, - description: 'Test epic with no widgets - GROUP LEVEL ONLY (using handler)', - workItemType: 'EPIC', - // No assignees, labels, or milestones - tests conditional widget inclusion - }, - { - title: `Test Epic (With Widgets) ${timestamp}`, - description: 'Test epic with widgets - GROUP LEVEL ONLY (using handler)', - workItemType: 'EPIC', - assigneeIds: user ? [`gid://gitlab/User/${user.id}`] : undefined, - labelIds: labels.length > 0 ? [labels[0].id.toString()] : undefined, - milestoneId: milestones.length > 0 ? milestones[0].id.toString() : undefined, - }, - ]; + // Get work item types directly using utility function (not exposed as tool) + console.log('🔍 Getting work item types for group namespace using internal utility...'); + const groupWorkItemTypes = await getWorkItemTypes(testData.group!.path); + console.log( + '📋 Available group work item types:', + groupWorkItemTypes.map((t) => `${t.name}(${t.id})`).join(', '), + ); - for (const workItemData of groupWorkItemsData) { - try { - console.log(` 🔧 Creating ${workItemData.workItemType} via create_work_item handler...`); + const epicType = groupWorkItemTypes.find((t) => t.name === 'Epic'); + expect(epicType).toBeDefined(); + + const createdGroupWorkItems: any[] = []; + + // 🚨 CRITICAL: Create diverse Epics to test with and without widgets + // Get created labels and milestones for widget testing + const labels = testData.labels || []; + const milestones = testData.milestones || []; + const user = testData.user; + + const groupWorkItemsData: Array<{ + title: string; + description: string; + workItemType: string; + assigneeIds?: string[]; + labelIds?: string[]; + milestoneId?: string; + }> = [ + { + title: `Test Epic (No Widgets) ${timestamp}`, + description: 'Test epic with no widgets - GROUP LEVEL ONLY (using handler)', + workItemType: 'EPIC', + // No assignees, labels, or milestones - tests conditional widget inclusion + }, + { + title: `Test Epic (With Widgets) ${timestamp}`, + description: 'Test epic with widgets - GROUP LEVEL ONLY (using handler)', + workItemType: 'EPIC', + assigneeIds: user ? [`gid://gitlab/User/${user.id}`] : undefined, + labelIds: labels.length > 0 ? [labels[0].id.toString()] : undefined, + milestoneId: milestones.length > 0 ? milestones[0].id.toString() : undefined, + }, + ]; - // Step 1: Create work item with basic parameters (CREATE doesn't support widgets) - const workItem = (await helper.createWorkItem({ - namespace: testData.group!.path, - title: workItemData.title, - workItemType: workItemData.workItemType, - description: workItemData.description, - })) as any; + for (const workItemData of groupWorkItemsData) { + try { + console.log( + ` 🔧 Creating ${workItemData.workItemType} via create_work_item handler...`, + ); - // Step 2: Add widgets iteratively if data exists for each widget type - console.log( - ` 🔍 Widget check: workItem=${!!workItem}, title="${workItemData.title}", includesWithWidgets=${workItemData.title?.includes('With Widgets')}`, - ); - if (workItem && workItemData.title?.includes('With Widgets')) { - console.log(` 🔧 Adding widgets to ${workItemData.workItemType}...`); + // Step 1: Create work item with basic parameters (CREATE doesn't support widgets) + const workItem = (await helper.createWorkItem({ + namespace: testData.group!.path, + title: workItemData.title, + workItemType: workItemData.workItemType, + description: workItemData.description, + })) as any; - // Try to add assignees widget if assignee data exists - if (workItemData.assigneeIds && workItemData.assigneeIds.length > 0) { - try { - console.log(` 🔧 Adding assignees widget...`); - const assigneeUpdate = (await helper.updateWorkItem({ - id: workItem.id, - assigneeIds: workItemData.assigneeIds, - })) as any; - if (assigneeUpdate) { - console.log(` ✅ Added assignees: ${workItemData.assigneeIds.length}`); + // Step 2: Add widgets iteratively if data exists for each widget type + console.log( + ` 🔍 Widget check: workItem=${!!workItem}, title="${workItemData.title}", includesWithWidgets=${workItemData.title?.includes('With Widgets')}`, + ); + if (workItem && workItemData.title?.includes('With Widgets')) { + console.log(` 🔧 Adding widgets to ${workItemData.workItemType}...`); + + // Try to add assignees widget if assignee data exists + if (workItemData.assigneeIds && workItemData.assigneeIds.length > 0) { + try { + console.log(` 🔧 Adding assignees widget...`); + const assigneeUpdate = (await helper.updateWorkItem({ + id: workItem.id, + assigneeIds: workItemData.assigneeIds, + })) as any; + if (assigneeUpdate) { + console.log(` ✅ Added assignees: ${workItemData.assigneeIds.length}`); + console.log( + ` 🔍 Assignee update response widgets:`, + assigneeUpdate.widgets?.find((w: any) => w.type === 'ASSIGNEES')?.assignees + ?.nodes?.length || 0, + ); + Object.assign(workItem, assigneeUpdate); + } + } catch (assigneeError) { console.log( - ` 🔍 Assignee update response widgets:`, - assigneeUpdate.widgets?.find((w: any) => w.type === 'ASSIGNEES')?.assignees - ?.nodes?.length || 0, + ` ⚠️ Could not add assignees to ${workItemData.workItemType}:`, + assigneeError, ); - Object.assign(workItem, assigneeUpdate); } - } catch (assigneeError) { - console.log( - ` ⚠️ Could not add assignees to ${workItemData.workItemType}:`, - assigneeError, - ); } - } - // Try to add labels widget if label data exists - if (workItemData.labelIds && workItemData.labelIds.length > 0) { - try { - console.log(` 🔧 Adding labels widget...`); - const labelUpdate = (await helper.updateWorkItem({ - id: workItem.id, - labelIds: workItemData.labelIds, - })) as any; - if (labelUpdate) { - console.log(` ✅ Added labels: ${workItemData.labelIds.length}`); + // Try to add labels widget if label data exists + if (workItemData.labelIds && workItemData.labelIds.length > 0) { + try { + console.log(` 🔧 Adding labels widget...`); + const labelUpdate = (await helper.updateWorkItem({ + id: workItem.id, + labelIds: workItemData.labelIds, + })) as any; + if (labelUpdate) { + console.log(` ✅ Added labels: ${workItemData.labelIds.length}`); + console.log( + ` 🔍 Label update response widgets:`, + labelUpdate.widgets?.find((w: any) => w.type === 'LABELS')?.labels?.nodes + ?.length || 0, + ); + Object.assign(workItem, labelUpdate); + } + } catch (labelError) { console.log( - ` 🔍 Label update response widgets:`, - labelUpdate.widgets?.find((w: any) => w.type === 'LABELS')?.labels?.nodes - ?.length || 0, + ` ⚠️ Could not add labels to ${workItemData.workItemType}:`, + labelError, ); - Object.assign(workItem, labelUpdate); } - } catch (labelError) { - console.log( - ` ⚠️ Could not add labels to ${workItemData.workItemType}:`, - labelError, - ); } - } - // Try to add milestone widget if milestone data exists - if (workItemData.milestoneId) { - try { - console.log(` 🔧 Adding milestone widget...`); - const milestoneUpdate = (await helper.updateWorkItem({ - id: workItem.id, - milestoneId: workItemData.milestoneId, - })) as any; - if (milestoneUpdate) { - console.log(` ✅ Added milestone: 1`); - Object.assign(workItem, milestoneUpdate); + // Try to add milestone widget if milestone data exists + if (workItemData.milestoneId) { + try { + console.log(` 🔧 Adding milestone widget...`); + const milestoneUpdate = (await helper.updateWorkItem({ + id: workItem.id, + milestoneId: workItemData.milestoneId, + })) as any; + if (milestoneUpdate) { + console.log(` ✅ Added milestone: 1`); + Object.assign(workItem, milestoneUpdate); + } + } catch (milestoneError) { + console.log( + ` ⚠️ Could not add milestone to ${workItemData.workItemType}:`, + milestoneError, + ); } - } catch (milestoneError) { - console.log( - ` ⚠️ Could not add milestone to ${workItemData.workItemType}:`, - milestoneError, - ); } } - } - console.log( - ` 🔍 Widget testing - ${workItemData.workItemType}:`, - workItemData.assigneeIds - ? `assignees: ${workItemData.assigneeIds.length}` - : 'no assignees', - workItemData.labelIds ? `labels: ${workItemData.labelIds.length}` : 'no labels', - workItemData.milestoneId ? 'milestone: 1' : 'no milestone', - ); + console.log( + ` 🔍 Widget testing - ${workItemData.workItemType}:`, + workItemData.assigneeIds + ? `assignees: ${workItemData.assigneeIds.length}` + : 'no assignees', + workItemData.labelIds ? `labels: ${workItemData.labelIds.length}` : 'no labels', + workItemData.milestoneId ? 'milestone: 1' : 'no milestone', + ); - if (workItem) { - createdGroupWorkItems.push(workItem); + if (workItem) { + createdGroupWorkItems.push(workItem); + console.log( + ` ✅ Created GROUP-level ${workItemData.workItemType}: ${workItem.iid} (Type: ${workItem.workItemType.name})`, + ); + } else { + console.log(` ⚠️ Handler returned null for ${workItemData.workItemType}`); + } + } catch (error) { console.log( - ` ✅ Created GROUP-level ${workItemData.workItemType}: ${workItem.iid} (Type: ${workItem.workItemType.name})`, + ` ⚠️ Could not create GROUP work item via handler: ${workItemData.title}`, + error, ); - } else { - console.log(` ⚠️ Handler returned null for ${workItemData.workItemType}`); } - } catch (error) { - console.log( - ` ⚠️ Could not create GROUP work item via handler: ${workItemData.title}`, - error, - ); } - } - // Store group work items separately - updateTestData({ groupWorkItems: createdGroupWorkItems }); + // Store group work items separately + updateTestData({ groupWorkItems: createdGroupWorkItems }); - expect(createdGroupWorkItems.length).toBeGreaterThan(0); - console.log(`✅ Created ${createdGroupWorkItems.length} GROUP-level work items (Epics)`); - }); + expect(createdGroupWorkItems.length).toBeGreaterThan(0); + console.log(`✅ Created ${createdGroupWorkItems.length} GROUP-level work items (Epics)`); + }, + ); it('should verify work items actually have widgets (assignees, labels, milestones)', async () => { const testData = getTestData(); @@ -879,7 +886,7 @@ describe('🔄 Data Lifecycle - Complete Infrastructure Setup', () => { }, 30000); }); - describe('🏗️ Step 5.5: Subgroup and Parent Epic Infrastructure', () => { + describeIfTier('premium', '🏗️ Step 5.5: Subgroup and Parent Epic Infrastructure', () => { it('should create subgroup (depends on main group)', async () => { const testData = getTestData(); expect(testData.group?.id).toBeDefined(); @@ -1668,7 +1675,7 @@ describe('🔄 Data Lifecycle - Complete Infrastructure Setup', () => { }); describe('🔍 Step 7: Work Items Tools Validation', () => { - it('should test list_work_items with group namespace (Epics)', async () => { + itIfTier('premium', 'should test list_work_items with group namespace (Epics)', async () => { const testData = getTestData(); expect(testData.group?.id).toBeDefined(); expect(testData.groupWorkItems?.length).toBeGreaterThan(0); @@ -1730,7 +1737,7 @@ describe('🔄 Data Lifecycle - Complete Infrastructure Setup', () => { ); }); - it('should test list_work_items with type filtering', async () => { + itIfTier('premium', 'should test list_work_items with type filtering', async () => { const testData = getTestData(); console.log('🔍 Testing list_work_items with type filtering...'); diff --git a/tests/integration/debug-widget-assignment.test.ts b/tests/integration/debug-widget-assignment.test.ts index 170c7e54b..1e10a4cda 100644 --- a/tests/integration/debug-widget-assignment.test.ts +++ b/tests/integration/debug-widget-assignment.test.ts @@ -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 () => { diff --git a/tests/integration/requirements.test.ts b/tests/integration/requirements.test.ts index 0e5437bed..3be261a5b 100644 --- a/tests/integration/requirements.test.ts +++ b/tests/integration/requirements.test.ts @@ -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; diff --git a/tests/setup/globalSetup.js b/tests/setup/globalSetup.js index 4a95e1839..91b9d2e6e 100644 --- a/tests/setup/globalSetup.js +++ b/tests/setup/globalSetup.js @@ -8,8 +8,19 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); +const crypto = require('crypto'); 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); + module.exports = async () => { // Load .env.test file first (same as setupTests.ts) const envTestPath = path.resolve(__dirname, '../../.env.test'); @@ -40,5 +51,88 @@ module.exports = async () => { 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)); + } + } + } + + // Match the codebase auth convention from src/utils/fetch.ts: + // OAuth mode → Authorization: Bearer + // PAT mode → PRIVATE-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(); + 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', + }, + body: JSON.stringify({ query: '{ currentLicense { plan } }' }), + 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})` : ''}`); + } 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); + } + console.log('✅ Environment validated - starting test data lifecycle chain'); }; diff --git a/tests/setup/tierGate.ts b/tests/setup/tierGate.ts new file mode 100644 index 000000000..be98845f1 --- /dev/null +++ b/tests/setup/tierGate.ts @@ -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'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +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 = { 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); + } +}