@get-skipper/core 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +67 -14
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +67 -14
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -133,10 +133,17 @@ declare class SkipperResolver {
|
|
|
133
133
|
/** normalized testId → disabledUntil ISO string (null = no date = enabled) */
|
|
134
134
|
private cache;
|
|
135
135
|
private initialized;
|
|
136
|
+
/** When true, all tests are enabled (fail-open fallback with no valid cache). */
|
|
137
|
+
private allEnabled;
|
|
136
138
|
constructor(config: SkipperConfig);
|
|
137
139
|
/**
|
|
138
140
|
* Fetches the spreadsheet and populates the in-memory cache.
|
|
139
141
|
* Must be called once before `isTestEnabled()`.
|
|
142
|
+
*
|
|
143
|
+
* On API failure:
|
|
144
|
+
* - If a valid `.skipper-cache.json` exists within SKIPPER_CACHE_TTL seconds, it is used.
|
|
145
|
+
* - Otherwise, if SKIPPER_FAIL_OPEN is not "false", all tests are enabled (fail-open).
|
|
146
|
+
* - Otherwise (SKIPPER_FAIL_OPEN=false), the original error is re-thrown.
|
|
140
147
|
*/
|
|
141
148
|
initialize(): Promise<void>;
|
|
142
149
|
/**
|
|
@@ -146,6 +153,7 @@ declare class SkipperResolver {
|
|
|
146
153
|
* - Not in spreadsheet → true (opt-out model: unknown tests run by default)
|
|
147
154
|
* - disabledUntil is null or in the past → true
|
|
148
155
|
* - disabledUntil is in the future → false
|
|
156
|
+
* - allEnabled (fail-open with no cache) → always true
|
|
149
157
|
*/
|
|
150
158
|
isTestEnabled(testId: string): boolean;
|
|
151
159
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -133,10 +133,17 @@ declare class SkipperResolver {
|
|
|
133
133
|
/** normalized testId → disabledUntil ISO string (null = no date = enabled) */
|
|
134
134
|
private cache;
|
|
135
135
|
private initialized;
|
|
136
|
+
/** When true, all tests are enabled (fail-open fallback with no valid cache). */
|
|
137
|
+
private allEnabled;
|
|
136
138
|
constructor(config: SkipperConfig);
|
|
137
139
|
/**
|
|
138
140
|
* Fetches the spreadsheet and populates the in-memory cache.
|
|
139
141
|
* Must be called once before `isTestEnabled()`.
|
|
142
|
+
*
|
|
143
|
+
* On API failure:
|
|
144
|
+
* - If a valid `.skipper-cache.json` exists within SKIPPER_CACHE_TTL seconds, it is used.
|
|
145
|
+
* - Otherwise, if SKIPPER_FAIL_OPEN is not "false", all tests are enabled (fail-open).
|
|
146
|
+
* - Otherwise (SKIPPER_FAIL_OPEN=false), the original error is re-thrown.
|
|
140
147
|
*/
|
|
141
148
|
initialize(): Promise<void>;
|
|
142
149
|
/**
|
|
@@ -146,6 +153,7 @@ declare class SkipperResolver {
|
|
|
146
153
|
* - Not in spreadsheet → true (opt-out model: unknown tests run by default)
|
|
147
154
|
* - disabledUntil is null or in the past → true
|
|
148
155
|
* - disabledUntil is in the future → false
|
|
156
|
+
* - allEnabled (fail-open with no cache) → always true
|
|
149
157
|
*/
|
|
150
158
|
isTestEnabled(testId: string): boolean;
|
|
151
159
|
/**
|
package/dist/index.js
CHANGED
|
@@ -113,14 +113,13 @@ var SheetsClient = class {
|
|
|
113
113
|
if (disabledUntilIdx !== -1 && row[disabledUntilIdx]) {
|
|
114
114
|
const raw = String(row[disabledUntilIdx]).trim();
|
|
115
115
|
if (raw) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
} else {
|
|
120
|
-
warn(
|
|
121
|
-
`[skipper] Row ${i + 1} in "${sheetName}": invalid date "${raw}" in "${disabledUntilCol}" \u2014 treating as enabled`
|
|
116
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`[skipper] Row ${i + 1} in "${sheetName}": date "${raw}" in "${disabledUntilCol}" must be in YYYY-MM-DD format (e.g. "2026-04-01")`
|
|
122
119
|
);
|
|
123
120
|
}
|
|
121
|
+
const [year, month, day] = raw.split("-").map(Number);
|
|
122
|
+
disabledUntil = new Date(Date.UTC(year, month - 1, day + 1));
|
|
124
123
|
}
|
|
125
124
|
}
|
|
126
125
|
const notes = notesIdx !== -1 && row[notesIdx] ? String(row[notesIdx]) : void 0;
|
|
@@ -218,9 +217,16 @@ var SheetsWriter = class {
|
|
|
218
217
|
existingEntries.map((e) => [normalizeTestId(e.testId), e])
|
|
219
218
|
);
|
|
220
219
|
const toAdd = discoveredIds.filter((id) => !normalizedExisting.has(normalizeTestId(id)));
|
|
221
|
-
const
|
|
220
|
+
const orphanedNormalized = new Set(
|
|
222
221
|
[...normalizedExisting.keys()].filter((nid) => !normalizedDiscovered.has(nid))
|
|
223
222
|
);
|
|
223
|
+
const allowDelete = process.env.SKIPPER_SYNC_ALLOW_DELETE === "true";
|
|
224
|
+
const toRemoveNormalized = allowDelete ? orphanedNormalized : /* @__PURE__ */ new Set();
|
|
225
|
+
if (orphanedNormalized.size > 0 && !allowDelete) {
|
|
226
|
+
warn(
|
|
227
|
+
`[skipper] ${orphanedNormalized.size} orphaned test(s) found in spreadsheet but not in suite \u2014 set SKIPPER_SYNC_ALLOW_DELETE=true to remove them.`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
224
230
|
if (toAdd.length === 0 && toRemoveNormalized.size === 0) {
|
|
225
231
|
log("[skipper] Spreadsheet is already up to date.");
|
|
226
232
|
return;
|
|
@@ -266,26 +272,71 @@ var SheetsWriter = class {
|
|
|
266
272
|
};
|
|
267
273
|
|
|
268
274
|
// src/resolver.ts
|
|
275
|
+
var fs2 = __toESM(require("fs"));
|
|
276
|
+
var path2 = __toESM(require("path"));
|
|
277
|
+
var DISK_CACHE_FILE = path2.join(process.cwd(), ".skipper-cache.json");
|
|
278
|
+
function readDiskCache(ttlSeconds) {
|
|
279
|
+
try {
|
|
280
|
+
const raw = fs2.readFileSync(DISK_CACHE_FILE, "utf8");
|
|
281
|
+
const data = JSON.parse(raw);
|
|
282
|
+
if ((Date.now() - data.timestamp) / 1e3 <= ttlSeconds) return data.entries;
|
|
283
|
+
} catch {
|
|
284
|
+
}
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
function writeDiskCache(entries) {
|
|
288
|
+
try {
|
|
289
|
+
fs2.writeFileSync(DISK_CACHE_FILE, JSON.stringify({ timestamp: Date.now(), entries }));
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
}
|
|
269
293
|
var SkipperResolver = class _SkipperResolver {
|
|
270
294
|
constructor(config) {
|
|
271
295
|
/** normalized testId → disabledUntil ISO string (null = no date = enabled) */
|
|
272
296
|
this.cache = /* @__PURE__ */ new Map();
|
|
273
297
|
this.initialized = false;
|
|
298
|
+
/** When true, all tests are enabled (fail-open fallback with no valid cache). */
|
|
299
|
+
this.allEnabled = false;
|
|
274
300
|
this.config = config;
|
|
275
301
|
this.client = new SheetsClient(config);
|
|
276
302
|
}
|
|
277
303
|
/**
|
|
278
304
|
* Fetches the spreadsheet and populates the in-memory cache.
|
|
279
305
|
* Must be called once before `isTestEnabled()`.
|
|
306
|
+
*
|
|
307
|
+
* On API failure:
|
|
308
|
+
* - If a valid `.skipper-cache.json` exists within SKIPPER_CACHE_TTL seconds, it is used.
|
|
309
|
+
* - Otherwise, if SKIPPER_FAIL_OPEN is not "false", all tests are enabled (fail-open).
|
|
310
|
+
* - Otherwise (SKIPPER_FAIL_OPEN=false), the original error is re-thrown.
|
|
280
311
|
*/
|
|
281
312
|
async initialize() {
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
313
|
+
const ttl = parseInt(process.env.SKIPPER_CACHE_TTL ?? "300", 10);
|
|
314
|
+
const failOpen = process.env.SKIPPER_FAIL_OPEN !== "false";
|
|
315
|
+
let entries;
|
|
316
|
+
try {
|
|
317
|
+
const result = await this.client.fetchAll();
|
|
318
|
+
entries = Object.fromEntries(
|
|
319
|
+
result.entries.map((e) => [
|
|
320
|
+
normalizeTestId(e.testId),
|
|
321
|
+
e.disabledUntil ? e.disabledUntil.toISOString() : null
|
|
322
|
+
])
|
|
323
|
+
);
|
|
324
|
+
writeDiskCache(entries);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
const cached = readDiskCache(ttl);
|
|
327
|
+
if (cached !== null) {
|
|
328
|
+
warn("[skipper] API unreachable \u2014 using cached skip list (SKIPPER_CACHE_TTL).");
|
|
329
|
+
entries = cached;
|
|
330
|
+
} else if (failOpen) {
|
|
331
|
+
warn("[skipper] API unreachable and no valid cache \u2014 running all tests (SKIPPER_FAIL_OPEN=true).");
|
|
332
|
+
this.allEnabled = true;
|
|
333
|
+
this.initialized = true;
|
|
334
|
+
return;
|
|
335
|
+
} else {
|
|
336
|
+
throw err;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
this.cache = new Map(Object.entries(entries));
|
|
289
340
|
this.initialized = true;
|
|
290
341
|
}
|
|
291
342
|
/**
|
|
@@ -295,6 +346,7 @@ var SkipperResolver = class _SkipperResolver {
|
|
|
295
346
|
* - Not in spreadsheet → true (opt-out model: unknown tests run by default)
|
|
296
347
|
* - disabledUntil is null or in the past → true
|
|
297
348
|
* - disabledUntil is in the future → false
|
|
349
|
+
* - allEnabled (fail-open with no cache) → always true
|
|
298
350
|
*/
|
|
299
351
|
isTestEnabled(testId) {
|
|
300
352
|
if (!this.initialized) {
|
|
@@ -302,6 +354,7 @@ var SkipperResolver = class _SkipperResolver {
|
|
|
302
354
|
"[skipper] SkipperResolver.initialize() must be called before isTestEnabled(). Did you forget to add the globalSetup to your config?"
|
|
303
355
|
);
|
|
304
356
|
}
|
|
357
|
+
if (this.allEnabled) return true;
|
|
305
358
|
const normalized = normalizeTestId(testId);
|
|
306
359
|
if (!this.cache.has(normalized)) return true;
|
|
307
360
|
const iso = this.cache.get(normalized);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/logger.ts","../src/client.ts","../src/cache.ts","../src/writer.ts","../src/resolver.ts"],"sourcesContent":["export { log, warn, error } from './logger';\nexport { SheetsClient } from './client';\nexport { SheetsWriter } from './writer';\nexport { SkipperResolver } from './resolver';\nexport { buildTestId, normalizeTestId } from './cache';\nexport type {\n SkipperConfig,\n SkipperCredentials,\n SkipperMode,\n ServiceAccountCredentials,\n TestEntry,\n} from './types';\n","/**\n * Skipper logger — output is suppressed unless SKIPPER_DEBUG=1 (or any truthy value).\n */\n\nfunction isEnabled(): boolean {\n return Boolean(process.env.SKIPPER_DEBUG);\n}\n\nexport function log(message: string): void {\n if (isEnabled()) console.log(message);\n}\n\nexport function warn(message: string): void {\n if (isEnabled()) console.warn(message);\n}\n\nexport function error(message: string): void {\n if (isEnabled()) console.error(message);\n}\n","import * as fs from 'fs';\nimport { normalizeTestId } from './cache';\nimport { warn } from './logger';\nimport type { SkipperConfig, TestEntry, ServiceAccountCredentials } from './types';\n\n// googleapis and google-auth-library are imported dynamically inside fetchAll() so that\n// worker processes that only call SkipperResolver.fromJSON() never load these modules.\n// Loading googleapis initialises an HTTP keep-alive agent that prevents worker exit.\nimport type { sheets_v4 } from 'googleapis';\n\nfunction resolveCredentials(config: SkipperConfig): ServiceAccountCredentials {\n const { credentials } = config;\n\n if ('credentialsFile' in credentials) {\n const raw = fs.readFileSync(credentials.credentialsFile, 'utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n if ('credentialsBase64' in credentials) {\n const raw = Buffer.from(credentials.credentialsBase64, 'base64').toString('utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n return credentials as ServiceAccountCredentials;\n}\n\nexport interface SheetFetchResult {\n /** Resolved sheet tab name. */\n sheetName: string;\n /** Numeric sheet ID (used for batchUpdate deletions). */\n sheetId: number;\n /** Raw rows including the header row (row 0). */\n rawRows: string[][];\n /** Parsed header cells (trimmed). */\n header: string[];\n /** Parsed test entries. */\n entries: TestEntry[];\n}\n\nexport interface FetchAllResult {\n /** Full data for the primary (writable) sheet — used by SheetsWriter. */\n primary: SheetFetchResult;\n /** Merged entries from primary + all referenceSheets — used by SkipperResolver. */\n entries: TestEntry[];\n /**\n * Authenticated Sheets API client — returned here so callers (SheetsWriter)\n * can reuse the same auth session for write operations without a second auth call.\n */\n sheets: sheets_v4.Sheets;\n}\n\nexport class SheetsClient {\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n }\n\n private async fetchSheet(\n sheets: sheets_v4.Sheets,\n sheetName: string,\n sheetId: number,\n ): Promise<SheetFetchResult> {\n const spreadsheetId = this.config.spreadsheetId;\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const response = await sheets.spreadsheets.values.get({ spreadsheetId, range: sheetName });\n const rawRows = (response.data.values ?? []) as string[][];\n\n if (rawRows.length === 0) {\n return { sheetName, sheetId, rawRows, header: [], entries: [] };\n }\n\n const header = rawRows[0].map((h: string) => String(h).trim());\n const testIdIdx = header.indexOf(testIdCol);\n const disabledUntilIdx = header.indexOf(disabledUntilCol);\n const notesIdx = header.indexOf('notes');\n\n if (testIdIdx === -1) {\n throw new Error(\n `[skipper] Column \"${testIdCol}\" not found in sheet \"${sheetName}\". ` +\n `Found columns: ${header.join(', ')}`,\n );\n }\n\n const entries: TestEntry[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const row = rawRows[i];\n const testId = row[testIdIdx] ? String(row[testIdIdx]).trim() : '';\n if (!testId) continue;\n\n let disabledUntil: Date | null = null;\n if (disabledUntilIdx !== -1 && row[disabledUntilIdx]) {\n const raw = String(row[disabledUntilIdx]).trim();\n if (raw) {\n const parsed = new Date(raw);\n if (!isNaN(parsed.getTime())) {\n disabledUntil = parsed;\n } else {\n warn(\n `[skipper] Row ${i + 1} in \"${sheetName}\": invalid date \"${raw}\" in \"${disabledUntilCol}\" — treating as enabled`,\n );\n }\n }\n }\n\n const notes = notesIdx !== -1 && row[notesIdx] ? String(row[notesIdx]) : undefined;\n entries.push({ testId, disabledUntil, notes });\n }\n\n return { sheetName, sheetId, rawRows, header, entries };\n }\n\n /**\n * Fetches the primary sheet and all reference sheets in a single API session.\n *\n * Returns:\n * - `primary`: the primary sheet's full result (rawRows + header) for writer use\n * - `entries`: merged test entries from all sheets (for resolver use)\n * - `sheets`: the authenticated Sheets API client (reuse for write operations)\n *\n * googleapis and google-auth-library are loaded here via dynamic import so that\n * worker processes, which only call SkipperResolver.fromJSON(), never load them.\n *\n * Deduplication: when the same testId appears in multiple sheets, the most\n * restrictive (latest) disabledUntil wins.\n */\n async fetchAll(): Promise<FetchAllResult> {\n // Dynamic imports — only executed when actually fetching from the spreadsheet.\n const { google } = await import('googleapis');\n const { JWT } = await import('google-auth-library');\n\n const creds = resolveCredentials(this.config);\n const auth = new JWT({\n email: creds.client_email,\n key: creds.private_key,\n scopes: ['https://www.googleapis.com/auth/spreadsheets'],\n });\n const sheets = google.sheets({ version: 'v4', auth });\n\n const spreadsheetId = this.config.spreadsheetId;\n const meta = await sheets.spreadsheets.get({ spreadsheetId });\n const allSheetMeta = meta.data.sheets ?? [];\n\n const sheetIdByName = new Map<string, number>(\n allSheetMeta\n .filter((s) => s.properties?.title != null && s.properties.sheetId != null)\n .map((s) => [s.properties!.title!, s.properties!.sheetId!]),\n );\n\n const primaryName = this.config.sheetName ?? allSheetMeta[0]?.properties?.title ?? 'Sheet1';\n const primaryId = sheetIdByName.get(primaryName);\n if (primaryId == null) {\n throw new Error(`[skipper] Sheet \"${primaryName}\" not found in spreadsheet.`);\n }\n\n const primary = await this.fetchSheet(sheets, primaryName, primaryId);\n\n const referenceEntries: TestEntry[] = [];\n for (const refName of this.config.referenceSheets ?? []) {\n const refId = sheetIdByName.get(refName);\n if (refId == null) {\n warn(`[skipper] Reference sheet \"${refName}\" not found — skipping.`);\n continue;\n }\n const result = await this.fetchSheet(sheets, refName, refId);\n referenceEntries.push(...result.entries);\n }\n\n const merged = new Map<string, TestEntry>();\n for (const entry of [...primary.entries, ...referenceEntries]) {\n const key = normalizeTestId(entry.testId);\n const existing = merged.get(key);\n if (!existing) {\n merged.set(key, entry);\n } else if (entry.disabledUntil !== null) {\n if (existing.disabledUntil === null || entry.disabledUntil > existing.disabledUntil) {\n merged.set(key, entry);\n }\n }\n }\n\n return { primary, entries: [...merged.values()], sheets };\n }\n}\n","import * as path from 'path';\n\n/**\n * Normalizes a testId for consistent comparison:\n * - trim leading/trailing whitespace\n * - lowercase\n * - collapse multiple whitespace characters into a single space\n */\nexport function normalizeTestId(id: string): string {\n return id.trim().toLowerCase().replace(/\\s+/g, ' ');\n}\n\n/**\n * Builds a canonical testId from a file path and the test title path.\n *\n * Format: \"{relativePath} > {titlePath.join(' > ')}\"\n * Example: \"tests/auth/login.spec.ts > login > should log in with valid credentials\"\n *\n * The filePath is made relative to process.cwd() if it is absolute.\n * The titlePath is the array of describe block names + the test name,\n * as provided by the test framework (never pre-joined).\n */\nexport function buildTestId(filePath: string, titlePath: string[]): string {\n const relativePath = path.isAbsolute(filePath)\n ? path.relative(process.cwd(), filePath)\n : filePath;\n\n const normalizedPath = relativePath.split(path.sep).join('/');\n return [normalizedPath, ...titlePath].join(' > ');\n}\n","import { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport { log } from './logger';\nimport type { SkipperConfig, TestEntry } from './types';\n\nexport class SheetsWriter {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Reconciles the spreadsheet with the discovered test IDs:\n * - Appends rows for tests not yet in the primary sheet (with empty disabledUntil)\n * - Deletes rows for tests that no longer exist in the suite\n *\n * Only the primary sheet is modified. Reference sheets are never written to.\n * Rows are matched by normalized testId (case-insensitive, whitespace-collapsed).\n * The header row (row 1) is never modified.\n *\n * A single fetchAll() call is made to retrieve sheet metadata, existing entries,\n * and raw rows — no redundant API calls.\n */\n async sync(discoveredIds: string[]): Promise<void> {\n // One fetchAll() resolves the sheet name from metadata, fetches existing\n // entries, and returns raw rows and the authenticated Sheets client —\n // all in two API calls (metadata + values). No second auth/fetch needed.\n const { primary, entries: existingEntries, sheets } = await this.client.fetchAll();\n const { sheetName, sheetId, rawRows, header } = primary;\n\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const normalizedDiscovered = new Set(discoveredIds.map(normalizeTestId));\n const normalizedExisting = new Map<string, TestEntry>(\n existingEntries.map((e) => [normalizeTestId(e.testId), e]),\n );\n\n const toAdd = discoveredIds.filter((id) => !normalizedExisting.has(normalizeTestId(id)));\n const toRemoveNormalized = new Set(\n [...normalizedExisting.keys()].filter((nid) => !normalizedDiscovered.has(nid)),\n );\n\n if (toAdd.length === 0 && toRemoveNormalized.size === 0) {\n log('[skipper] Spreadsheet is already up to date.');\n return;\n }\n\n const spreadsheetId = this.config.spreadsheetId;\n\n const testIdIdx = header.indexOf(testIdCol);\n\n // Identify 0-based row indices (within rawRows) to delete, skipping header at 0.\n const rowIndicesToDelete: number[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const id = rawRows[i][testIdIdx] ? String(rawRows[i][testIdIdx]).trim() : '';\n if (id && toRemoveNormalized.has(normalizeTestId(id))) {\n rowIndicesToDelete.push(i);\n }\n }\n\n // Deletions must be sorted descending to avoid index shifting.\n const deleteRequests = rowIndicesToDelete\n .sort((a, b) => b - a)\n .map((rowIdx) => ({\n deleteDimension: {\n range: { sheetId, dimension: 'ROWS', startIndex: rowIdx, endIndex: rowIdx + 1 },\n },\n }));\n\n if (deleteRequests.length > 0) {\n await sheets.spreadsheets.batchUpdate({\n spreadsheetId,\n requestBody: { requests: deleteRequests },\n });\n log(`[skipper] Removed ${deleteRequests.length} obsolete test(s) from spreadsheet.`);\n }\n\n // Append new rows.\n if (toAdd.length > 0) {\n const headerIdxDisabledUntil = header.indexOf(disabledUntilCol);\n\n const newRows = toAdd.map((testId) => {\n const row: string[] = new Array(Math.max(testIdIdx + 1, headerIdxDisabledUntil + 1)).fill('');\n row[testIdIdx] = testId;\n if (headerIdxDisabledUntil !== -1) row[headerIdxDisabledUntil] = '';\n return row;\n });\n\n await sheets.spreadsheets.values.append({\n spreadsheetId,\n range: sheetName,\n valueInputOption: 'RAW',\n requestBody: { values: newRows },\n });\n log(`[skipper] Added ${toAdd.length} new test(s) to spreadsheet.`);\n }\n }\n}\n","import { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport type { SkipperConfig, SkipperMode } from './types';\n\n/**\n * SkipperResolver is the primary interface used by framework plugins.\n *\n * Lifecycle:\n * 1. Call `initialize()` once before tests run (in globalSetup / before hook).\n * 2. Call `isTestEnabled(testId)` per test to decide whether to skip.\n * 3. In sync mode, call `toJSON()` to serialize the cache for cross-process sharing.\n * 4. In worker processes, use `SkipperResolver.fromJSON()` to rehydrate.\n */\nexport class SkipperResolver {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n /** normalized testId → disabledUntil ISO string (null = no date = enabled) */\n private cache: Map<string, string | null> = new Map();\n private initialized = false;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Fetches the spreadsheet and populates the in-memory cache.\n * Must be called once before `isTestEnabled()`.\n */\n async initialize(): Promise<void> {\n const { entries } = await this.client.fetchAll();\n this.cache = new Map(\n entries.map((e) => [\n normalizeTestId(e.testId),\n e.disabledUntil ? e.disabledUntil.toISOString() : null,\n ]),\n );\n this.initialized = true;\n }\n\n /**\n * Returns true if the test should run.\n *\n * Logic:\n * - Not in spreadsheet → true (opt-out model: unknown tests run by default)\n * - disabledUntil is null or in the past → true\n * - disabledUntil is in the future → false\n */\n isTestEnabled(testId: string): boolean {\n if (!this.initialized) {\n throw new Error(\n '[skipper] SkipperResolver.initialize() must be called before isTestEnabled(). ' +\n 'Did you forget to add the globalSetup to your config?',\n );\n }\n\n const normalized = normalizeTestId(testId);\n if (!this.cache.has(normalized)) return true;\n\n const iso = this.cache.get(normalized);\n if (!iso) return true;\n\n return new Date(iso) <= new Date();\n }\n\n /**\n * Serializes the cache for cross-process sharing (e.g. globalSetup → workers).\n * Dates are stored as ISO strings; null means no date (enabled).\n */\n toJSON(): Record<string, string | null> {\n return Object.fromEntries(this.cache);\n }\n\n /**\n * Rehydrates a resolver from a serialized cache.\n * Used in worker processes that cannot call initialize() again.\n */\n static fromJSON(data: Record<string, string | null>): SkipperResolver {\n // We pass a dummy config since the client is never used after fromJSON\n const resolver = new SkipperResolver({\n spreadsheetId: '',\n credentials: { credentialsBase64: '' },\n });\n resolver.cache = new Map(Object.entries(data));\n resolver.initialized = true;\n return resolver;\n }\n\n getMode(): SkipperMode {\n const mode = process.env.SKIPPER_MODE;\n if (mode === 'sync') return 'sync';\n return 'read-only';\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/logger.ts","../src/client.ts","../src/cache.ts","../src/writer.ts","../src/resolver.ts"],"sourcesContent":["export { log, warn, error } from './logger';\nexport { SheetsClient } from './client';\nexport { SheetsWriter } from './writer';\nexport { SkipperResolver } from './resolver';\nexport { buildTestId, normalizeTestId } from './cache';\nexport type {\n SkipperConfig,\n SkipperCredentials,\n SkipperMode,\n ServiceAccountCredentials,\n TestEntry,\n} from './types';\n","/**\n * Skipper logger — output is suppressed unless SKIPPER_DEBUG=1 (or any truthy value).\n */\n\nfunction isEnabled(): boolean {\n return Boolean(process.env.SKIPPER_DEBUG);\n}\n\nexport function log(message: string): void {\n if (isEnabled()) console.log(message);\n}\n\nexport function warn(message: string): void {\n if (isEnabled()) console.warn(message);\n}\n\nexport function error(message: string): void {\n if (isEnabled()) console.error(message);\n}\n","import * as fs from 'fs';\nimport { normalizeTestId } from './cache';\nimport { warn } from './logger';\nimport type { SkipperConfig, TestEntry, ServiceAccountCredentials } from './types';\n\n// googleapis and google-auth-library are imported dynamically inside fetchAll() so that\n// worker processes that only call SkipperResolver.fromJSON() never load these modules.\n// Loading googleapis initialises an HTTP keep-alive agent that prevents worker exit.\nimport type { sheets_v4 } from 'googleapis';\n\nfunction resolveCredentials(config: SkipperConfig): ServiceAccountCredentials {\n const { credentials } = config;\n\n if ('credentialsFile' in credentials) {\n const raw = fs.readFileSync(credentials.credentialsFile, 'utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n if ('credentialsBase64' in credentials) {\n const raw = Buffer.from(credentials.credentialsBase64, 'base64').toString('utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n return credentials as ServiceAccountCredentials;\n}\n\nexport interface SheetFetchResult {\n /** Resolved sheet tab name. */\n sheetName: string;\n /** Numeric sheet ID (used for batchUpdate deletions). */\n sheetId: number;\n /** Raw rows including the header row (row 0). */\n rawRows: string[][];\n /** Parsed header cells (trimmed). */\n header: string[];\n /** Parsed test entries. */\n entries: TestEntry[];\n}\n\nexport interface FetchAllResult {\n /** Full data for the primary (writable) sheet — used by SheetsWriter. */\n primary: SheetFetchResult;\n /** Merged entries from primary + all referenceSheets — used by SkipperResolver. */\n entries: TestEntry[];\n /**\n * Authenticated Sheets API client — returned here so callers (SheetsWriter)\n * can reuse the same auth session for write operations without a second auth call.\n */\n sheets: sheets_v4.Sheets;\n}\n\nexport class SheetsClient {\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n }\n\n private async fetchSheet(\n sheets: sheets_v4.Sheets,\n sheetName: string,\n sheetId: number,\n ): Promise<SheetFetchResult> {\n const spreadsheetId = this.config.spreadsheetId;\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const response = await sheets.spreadsheets.values.get({ spreadsheetId, range: sheetName });\n const rawRows = (response.data.values ?? []) as string[][];\n\n if (rawRows.length === 0) {\n return { sheetName, sheetId, rawRows, header: [], entries: [] };\n }\n\n const header = rawRows[0].map((h: string) => String(h).trim());\n const testIdIdx = header.indexOf(testIdCol);\n const disabledUntilIdx = header.indexOf(disabledUntilCol);\n const notesIdx = header.indexOf('notes');\n\n if (testIdIdx === -1) {\n throw new Error(\n `[skipper] Column \"${testIdCol}\" not found in sheet \"${sheetName}\". ` +\n `Found columns: ${header.join(', ')}`,\n );\n }\n\n const entries: TestEntry[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const row = rawRows[i];\n const testId = row[testIdIdx] ? String(row[testIdIdx]).trim() : '';\n if (!testId) continue;\n\n let disabledUntil: Date | null = null;\n if (disabledUntilIdx !== -1 && row[disabledUntilIdx]) {\n const raw = String(row[disabledUntilIdx]).trim();\n if (raw) {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(raw)) {\n throw new Error(\n `[skipper] Row ${i + 1} in \"${sheetName}\": date \"${raw}\" in \"${disabledUntilCol}\" must be in YYYY-MM-DD format (e.g. \"2026-04-01\")`,\n );\n }\n const [year, month, day] = raw.split('-').map(Number);\n // Parse as midnight UTC of the next day: \"disabled until 2026-04-01\" keeps\n // the test disabled through the entire calendar day in UTC and re-enables\n // at 2026-04-02T00:00:00Z, regardless of the runner's local timezone.\n disabledUntil = new Date(Date.UTC(year, month - 1, day + 1));\n }\n }\n\n const notes = notesIdx !== -1 && row[notesIdx] ? String(row[notesIdx]) : undefined;\n entries.push({ testId, disabledUntil, notes });\n }\n\n return { sheetName, sheetId, rawRows, header, entries };\n }\n\n /**\n * Fetches the primary sheet and all reference sheets in a single API session.\n *\n * Returns:\n * - `primary`: the primary sheet's full result (rawRows + header) for writer use\n * - `entries`: merged test entries from all sheets (for resolver use)\n * - `sheets`: the authenticated Sheets API client (reuse for write operations)\n *\n * googleapis and google-auth-library are loaded here via dynamic import so that\n * worker processes, which only call SkipperResolver.fromJSON(), never load them.\n *\n * Deduplication: when the same testId appears in multiple sheets, the most\n * restrictive (latest) disabledUntil wins.\n */\n async fetchAll(): Promise<FetchAllResult> {\n // Dynamic imports — only executed when actually fetching from the spreadsheet.\n const { google } = await import('googleapis');\n const { JWT } = await import('google-auth-library');\n\n const creds = resolveCredentials(this.config);\n const auth = new JWT({\n email: creds.client_email,\n key: creds.private_key,\n scopes: ['https://www.googleapis.com/auth/spreadsheets'],\n });\n const sheets = google.sheets({ version: 'v4', auth });\n\n const spreadsheetId = this.config.spreadsheetId;\n const meta = await sheets.spreadsheets.get({ spreadsheetId });\n const allSheetMeta = meta.data.sheets ?? [];\n\n const sheetIdByName = new Map<string, number>(\n allSheetMeta\n .filter((s) => s.properties?.title != null && s.properties.sheetId != null)\n .map((s) => [s.properties!.title!, s.properties!.sheetId!]),\n );\n\n const primaryName = this.config.sheetName ?? allSheetMeta[0]?.properties?.title ?? 'Sheet1';\n const primaryId = sheetIdByName.get(primaryName);\n if (primaryId == null) {\n throw new Error(`[skipper] Sheet \"${primaryName}\" not found in spreadsheet.`);\n }\n\n const primary = await this.fetchSheet(sheets, primaryName, primaryId);\n\n const referenceEntries: TestEntry[] = [];\n for (const refName of this.config.referenceSheets ?? []) {\n const refId = sheetIdByName.get(refName);\n if (refId == null) {\n warn(`[skipper] Reference sheet \"${refName}\" not found — skipping.`);\n continue;\n }\n const result = await this.fetchSheet(sheets, refName, refId);\n referenceEntries.push(...result.entries);\n }\n\n const merged = new Map<string, TestEntry>();\n for (const entry of [...primary.entries, ...referenceEntries]) {\n const key = normalizeTestId(entry.testId);\n const existing = merged.get(key);\n if (!existing) {\n merged.set(key, entry);\n } else if (entry.disabledUntil !== null) {\n if (existing.disabledUntil === null || entry.disabledUntil > existing.disabledUntil) {\n merged.set(key, entry);\n }\n }\n }\n\n return { primary, entries: [...merged.values()], sheets };\n }\n}\n","import * as path from 'path';\n\n/**\n * Normalizes a testId for consistent comparison:\n * - trim leading/trailing whitespace\n * - lowercase\n * - collapse multiple whitespace characters into a single space\n */\nexport function normalizeTestId(id: string): string {\n return id.trim().toLowerCase().replace(/\\s+/g, ' ');\n}\n\n/**\n * Builds a canonical testId from a file path and the test title path.\n *\n * Format: \"{relativePath} > {titlePath.join(' > ')}\"\n * Example: \"tests/auth/login.spec.ts > login > should log in with valid credentials\"\n *\n * The filePath is made relative to process.cwd() if it is absolute.\n * The titlePath is the array of describe block names + the test name,\n * as provided by the test framework (never pre-joined).\n */\nexport function buildTestId(filePath: string, titlePath: string[]): string {\n const relativePath = path.isAbsolute(filePath)\n ? path.relative(process.cwd(), filePath)\n : filePath;\n\n const normalizedPath = relativePath.split(path.sep).join('/');\n return [normalizedPath, ...titlePath].join(' > ');\n}\n","import { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport { log, warn } from './logger';\nimport type { SkipperConfig, TestEntry } from './types';\n\nexport class SheetsWriter {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Reconciles the spreadsheet with the discovered test IDs:\n * - Appends rows for tests not yet in the primary sheet (with empty disabledUntil)\n * - Deletes rows for tests that no longer exist in the suite\n *\n * Only the primary sheet is modified. Reference sheets are never written to.\n * Rows are matched by normalized testId (case-insensitive, whitespace-collapsed).\n * The header row (row 1) is never modified.\n *\n * A single fetchAll() call is made to retrieve sheet metadata, existing entries,\n * and raw rows — no redundant API calls.\n */\n async sync(discoveredIds: string[]): Promise<void> {\n // One fetchAll() resolves the sheet name from metadata, fetches existing\n // entries, and returns raw rows and the authenticated Sheets client —\n // all in two API calls (metadata + values). No second auth/fetch needed.\n const { primary, entries: existingEntries, sheets } = await this.client.fetchAll();\n const { sheetName, sheetId, rawRows, header } = primary;\n\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const normalizedDiscovered = new Set(discoveredIds.map(normalizeTestId));\n const normalizedExisting = new Map<string, TestEntry>(\n existingEntries.map((e) => [normalizeTestId(e.testId), e]),\n );\n\n const toAdd = discoveredIds.filter((id) => !normalizedExisting.has(normalizeTestId(id)));\n const orphanedNormalized = new Set(\n [...normalizedExisting.keys()].filter((nid) => !normalizedDiscovered.has(nid)),\n );\n const allowDelete = process.env.SKIPPER_SYNC_ALLOW_DELETE === 'true';\n const toRemoveNormalized = allowDelete ? orphanedNormalized : new Set<string>();\n\n if (orphanedNormalized.size > 0 && !allowDelete) {\n warn(\n `[skipper] ${orphanedNormalized.size} orphaned test(s) found in spreadsheet but not in suite — ` +\n 'set SKIPPER_SYNC_ALLOW_DELETE=true to remove them.',\n );\n }\n\n if (toAdd.length === 0 && toRemoveNormalized.size === 0) {\n log('[skipper] Spreadsheet is already up to date.');\n return;\n }\n\n const spreadsheetId = this.config.spreadsheetId;\n\n const testIdIdx = header.indexOf(testIdCol);\n\n // Identify 0-based row indices (within rawRows) to delete, skipping header at 0.\n const rowIndicesToDelete: number[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const id = rawRows[i][testIdIdx] ? String(rawRows[i][testIdIdx]).trim() : '';\n if (id && toRemoveNormalized.has(normalizeTestId(id))) {\n rowIndicesToDelete.push(i);\n }\n }\n\n // Deletions must be sorted descending to avoid index shifting.\n const deleteRequests = rowIndicesToDelete\n .sort((a, b) => b - a)\n .map((rowIdx) => ({\n deleteDimension: {\n range: { sheetId, dimension: 'ROWS', startIndex: rowIdx, endIndex: rowIdx + 1 },\n },\n }));\n\n if (deleteRequests.length > 0) {\n await sheets.spreadsheets.batchUpdate({\n spreadsheetId,\n requestBody: { requests: deleteRequests },\n });\n log(`[skipper] Removed ${deleteRequests.length} obsolete test(s) from spreadsheet.`);\n }\n\n // Append new rows.\n if (toAdd.length > 0) {\n const headerIdxDisabledUntil = header.indexOf(disabledUntilCol);\n\n const newRows = toAdd.map((testId) => {\n const row: string[] = new Array(Math.max(testIdIdx + 1, headerIdxDisabledUntil + 1)).fill('');\n row[testIdIdx] = testId;\n if (headerIdxDisabledUntil !== -1) row[headerIdxDisabledUntil] = '';\n return row;\n });\n\n await sheets.spreadsheets.values.append({\n spreadsheetId,\n range: sheetName,\n valueInputOption: 'RAW',\n requestBody: { values: newRows },\n });\n log(`[skipper] Added ${toAdd.length} new test(s) to spreadsheet.`);\n }\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport { warn } from './logger';\nimport type { SkipperConfig, SkipperMode } from './types';\n\nconst DISK_CACHE_FILE = path.join(process.cwd(), '.skipper-cache.json');\n\ninterface DiskCacheData {\n timestamp: number;\n entries: Record<string, string | null>;\n}\n\nfunction readDiskCache(ttlSeconds: number): Record<string, string | null> | null {\n try {\n const raw = fs.readFileSync(DISK_CACHE_FILE, 'utf8');\n const data = JSON.parse(raw) as DiskCacheData;\n if ((Date.now() - data.timestamp) / 1000 <= ttlSeconds) return data.entries;\n } catch {\n // file missing or invalid — no cache available\n }\n return null;\n}\n\nfunction writeDiskCache(entries: Record<string, string | null>): void {\n try {\n fs.writeFileSync(DISK_CACHE_FILE, JSON.stringify({ timestamp: Date.now(), entries }));\n } catch {\n // non-fatal — cache write failure is ignored\n }\n}\n\n/**\n * SkipperResolver is the primary interface used by framework plugins.\n *\n * Lifecycle:\n * 1. Call `initialize()` once before tests run (in globalSetup / before hook).\n * 2. Call `isTestEnabled(testId)` per test to decide whether to skip.\n * 3. In sync mode, call `toJSON()` to serialize the cache for cross-process sharing.\n * 4. In worker processes, use `SkipperResolver.fromJSON()` to rehydrate.\n */\nexport class SkipperResolver {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n /** normalized testId → disabledUntil ISO string (null = no date = enabled) */\n private cache: Map<string, string | null> = new Map();\n private initialized = false;\n /** When true, all tests are enabled (fail-open fallback with no valid cache). */\n private allEnabled = false;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Fetches the spreadsheet and populates the in-memory cache.\n * Must be called once before `isTestEnabled()`.\n *\n * On API failure:\n * - If a valid `.skipper-cache.json` exists within SKIPPER_CACHE_TTL seconds, it is used.\n * - Otherwise, if SKIPPER_FAIL_OPEN is not \"false\", all tests are enabled (fail-open).\n * - Otherwise (SKIPPER_FAIL_OPEN=false), the original error is re-thrown.\n */\n async initialize(): Promise<void> {\n const ttl = parseInt(process.env.SKIPPER_CACHE_TTL ?? '300', 10);\n const failOpen = process.env.SKIPPER_FAIL_OPEN !== 'false';\n\n let entries: Record<string, string | null>;\n try {\n const result = await this.client.fetchAll();\n entries = Object.fromEntries(\n result.entries.map((e) => [\n normalizeTestId(e.testId),\n e.disabledUntil ? e.disabledUntil.toISOString() : null,\n ]),\n );\n writeDiskCache(entries);\n } catch (err) {\n const cached = readDiskCache(ttl);\n if (cached !== null) {\n warn('[skipper] API unreachable — using cached skip list (SKIPPER_CACHE_TTL).');\n entries = cached;\n } else if (failOpen) {\n warn('[skipper] API unreachable and no valid cache — running all tests (SKIPPER_FAIL_OPEN=true).');\n this.allEnabled = true;\n this.initialized = true;\n return;\n } else {\n throw err;\n }\n }\n\n this.cache = new Map(Object.entries(entries));\n this.initialized = true;\n }\n\n /**\n * Returns true if the test should run.\n *\n * Logic:\n * - Not in spreadsheet → true (opt-out model: unknown tests run by default)\n * - disabledUntil is null or in the past → true\n * - disabledUntil is in the future → false\n * - allEnabled (fail-open with no cache) → always true\n */\n isTestEnabled(testId: string): boolean {\n if (!this.initialized) {\n throw new Error(\n '[skipper] SkipperResolver.initialize() must be called before isTestEnabled(). ' +\n 'Did you forget to add the globalSetup to your config?',\n );\n }\n\n if (this.allEnabled) return true;\n\n const normalized = normalizeTestId(testId);\n if (!this.cache.has(normalized)) return true;\n\n const iso = this.cache.get(normalized);\n if (!iso) return true;\n\n return new Date(iso) <= new Date();\n }\n\n /**\n * Serializes the cache for cross-process sharing (e.g. globalSetup → workers).\n * Dates are stored as ISO strings; null means no date (enabled).\n */\n toJSON(): Record<string, string | null> {\n return Object.fromEntries(this.cache);\n }\n\n /**\n * Rehydrates a resolver from a serialized cache.\n * Used in worker processes that cannot call initialize() again.\n */\n static fromJSON(data: Record<string, string | null>): SkipperResolver {\n // We pass a dummy config since the client is never used after fromJSON\n const resolver = new SkipperResolver({\n spreadsheetId: '',\n credentials: { credentialsBase64: '' },\n });\n resolver.cache = new Map(Object.entries(data));\n resolver.initialized = true;\n return resolver;\n }\n\n getMode(): SkipperMode {\n const mode = process.env.SKIPPER_MODE;\n if (mode === 'sync') return 'sync';\n return 'read-only';\n }\n}\n"],"mappings":"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","names":["fs","path"]}
|
package/dist/index.mjs
CHANGED
|
@@ -70,14 +70,13 @@ var SheetsClient = class {
|
|
|
70
70
|
if (disabledUntilIdx !== -1 && row[disabledUntilIdx]) {
|
|
71
71
|
const raw = String(row[disabledUntilIdx]).trim();
|
|
72
72
|
if (raw) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
} else {
|
|
77
|
-
warn(
|
|
78
|
-
`[skipper] Row ${i + 1} in "${sheetName}": invalid date "${raw}" in "${disabledUntilCol}" \u2014 treating as enabled`
|
|
73
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`[skipper] Row ${i + 1} in "${sheetName}": date "${raw}" in "${disabledUntilCol}" must be in YYYY-MM-DD format (e.g. "2026-04-01")`
|
|
79
76
|
);
|
|
80
77
|
}
|
|
78
|
+
const [year, month, day] = raw.split("-").map(Number);
|
|
79
|
+
disabledUntil = new Date(Date.UTC(year, month - 1, day + 1));
|
|
81
80
|
}
|
|
82
81
|
}
|
|
83
82
|
const notes = notesIdx !== -1 && row[notesIdx] ? String(row[notesIdx]) : void 0;
|
|
@@ -175,9 +174,16 @@ var SheetsWriter = class {
|
|
|
175
174
|
existingEntries.map((e) => [normalizeTestId(e.testId), e])
|
|
176
175
|
);
|
|
177
176
|
const toAdd = discoveredIds.filter((id) => !normalizedExisting.has(normalizeTestId(id)));
|
|
178
|
-
const
|
|
177
|
+
const orphanedNormalized = new Set(
|
|
179
178
|
[...normalizedExisting.keys()].filter((nid) => !normalizedDiscovered.has(nid))
|
|
180
179
|
);
|
|
180
|
+
const allowDelete = process.env.SKIPPER_SYNC_ALLOW_DELETE === "true";
|
|
181
|
+
const toRemoveNormalized = allowDelete ? orphanedNormalized : /* @__PURE__ */ new Set();
|
|
182
|
+
if (orphanedNormalized.size > 0 && !allowDelete) {
|
|
183
|
+
warn(
|
|
184
|
+
`[skipper] ${orphanedNormalized.size} orphaned test(s) found in spreadsheet but not in suite \u2014 set SKIPPER_SYNC_ALLOW_DELETE=true to remove them.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
181
187
|
if (toAdd.length === 0 && toRemoveNormalized.size === 0) {
|
|
182
188
|
log("[skipper] Spreadsheet is already up to date.");
|
|
183
189
|
return;
|
|
@@ -223,26 +229,71 @@ var SheetsWriter = class {
|
|
|
223
229
|
};
|
|
224
230
|
|
|
225
231
|
// src/resolver.ts
|
|
232
|
+
import * as fs2 from "fs";
|
|
233
|
+
import * as path2 from "path";
|
|
234
|
+
var DISK_CACHE_FILE = path2.join(process.cwd(), ".skipper-cache.json");
|
|
235
|
+
function readDiskCache(ttlSeconds) {
|
|
236
|
+
try {
|
|
237
|
+
const raw = fs2.readFileSync(DISK_CACHE_FILE, "utf8");
|
|
238
|
+
const data = JSON.parse(raw);
|
|
239
|
+
if ((Date.now() - data.timestamp) / 1e3 <= ttlSeconds) return data.entries;
|
|
240
|
+
} catch {
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
function writeDiskCache(entries) {
|
|
245
|
+
try {
|
|
246
|
+
fs2.writeFileSync(DISK_CACHE_FILE, JSON.stringify({ timestamp: Date.now(), entries }));
|
|
247
|
+
} catch {
|
|
248
|
+
}
|
|
249
|
+
}
|
|
226
250
|
var SkipperResolver = class _SkipperResolver {
|
|
227
251
|
constructor(config) {
|
|
228
252
|
/** normalized testId → disabledUntil ISO string (null = no date = enabled) */
|
|
229
253
|
this.cache = /* @__PURE__ */ new Map();
|
|
230
254
|
this.initialized = false;
|
|
255
|
+
/** When true, all tests are enabled (fail-open fallback with no valid cache). */
|
|
256
|
+
this.allEnabled = false;
|
|
231
257
|
this.config = config;
|
|
232
258
|
this.client = new SheetsClient(config);
|
|
233
259
|
}
|
|
234
260
|
/**
|
|
235
261
|
* Fetches the spreadsheet and populates the in-memory cache.
|
|
236
262
|
* Must be called once before `isTestEnabled()`.
|
|
263
|
+
*
|
|
264
|
+
* On API failure:
|
|
265
|
+
* - If a valid `.skipper-cache.json` exists within SKIPPER_CACHE_TTL seconds, it is used.
|
|
266
|
+
* - Otherwise, if SKIPPER_FAIL_OPEN is not "false", all tests are enabled (fail-open).
|
|
267
|
+
* - Otherwise (SKIPPER_FAIL_OPEN=false), the original error is re-thrown.
|
|
237
268
|
*/
|
|
238
269
|
async initialize() {
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
270
|
+
const ttl = parseInt(process.env.SKIPPER_CACHE_TTL ?? "300", 10);
|
|
271
|
+
const failOpen = process.env.SKIPPER_FAIL_OPEN !== "false";
|
|
272
|
+
let entries;
|
|
273
|
+
try {
|
|
274
|
+
const result = await this.client.fetchAll();
|
|
275
|
+
entries = Object.fromEntries(
|
|
276
|
+
result.entries.map((e) => [
|
|
277
|
+
normalizeTestId(e.testId),
|
|
278
|
+
e.disabledUntil ? e.disabledUntil.toISOString() : null
|
|
279
|
+
])
|
|
280
|
+
);
|
|
281
|
+
writeDiskCache(entries);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
const cached = readDiskCache(ttl);
|
|
284
|
+
if (cached !== null) {
|
|
285
|
+
warn("[skipper] API unreachable \u2014 using cached skip list (SKIPPER_CACHE_TTL).");
|
|
286
|
+
entries = cached;
|
|
287
|
+
} else if (failOpen) {
|
|
288
|
+
warn("[skipper] API unreachable and no valid cache \u2014 running all tests (SKIPPER_FAIL_OPEN=true).");
|
|
289
|
+
this.allEnabled = true;
|
|
290
|
+
this.initialized = true;
|
|
291
|
+
return;
|
|
292
|
+
} else {
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
this.cache = new Map(Object.entries(entries));
|
|
246
297
|
this.initialized = true;
|
|
247
298
|
}
|
|
248
299
|
/**
|
|
@@ -252,6 +303,7 @@ var SkipperResolver = class _SkipperResolver {
|
|
|
252
303
|
* - Not in spreadsheet → true (opt-out model: unknown tests run by default)
|
|
253
304
|
* - disabledUntil is null or in the past → true
|
|
254
305
|
* - disabledUntil is in the future → false
|
|
306
|
+
* - allEnabled (fail-open with no cache) → always true
|
|
255
307
|
*/
|
|
256
308
|
isTestEnabled(testId) {
|
|
257
309
|
if (!this.initialized) {
|
|
@@ -259,6 +311,7 @@ var SkipperResolver = class _SkipperResolver {
|
|
|
259
311
|
"[skipper] SkipperResolver.initialize() must be called before isTestEnabled(). Did you forget to add the globalSetup to your config?"
|
|
260
312
|
);
|
|
261
313
|
}
|
|
314
|
+
if (this.allEnabled) return true;
|
|
262
315
|
const normalized = normalizeTestId(testId);
|
|
263
316
|
if (!this.cache.has(normalized)) return true;
|
|
264
317
|
const iso = this.cache.get(normalized);
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/logger.ts","../src/client.ts","../src/cache.ts","../src/writer.ts","../src/resolver.ts"],"sourcesContent":["/**\n * Skipper logger — output is suppressed unless SKIPPER_DEBUG=1 (or any truthy value).\n */\n\nfunction isEnabled(): boolean {\n return Boolean(process.env.SKIPPER_DEBUG);\n}\n\nexport function log(message: string): void {\n if (isEnabled()) console.log(message);\n}\n\nexport function warn(message: string): void {\n if (isEnabled()) console.warn(message);\n}\n\nexport function error(message: string): void {\n if (isEnabled()) console.error(message);\n}\n","import * as fs from 'fs';\nimport { normalizeTestId } from './cache';\nimport { warn } from './logger';\nimport type { SkipperConfig, TestEntry, ServiceAccountCredentials } from './types';\n\n// googleapis and google-auth-library are imported dynamically inside fetchAll() so that\n// worker processes that only call SkipperResolver.fromJSON() never load these modules.\n// Loading googleapis initialises an HTTP keep-alive agent that prevents worker exit.\nimport type { sheets_v4 } from 'googleapis';\n\nfunction resolveCredentials(config: SkipperConfig): ServiceAccountCredentials {\n const { credentials } = config;\n\n if ('credentialsFile' in credentials) {\n const raw = fs.readFileSync(credentials.credentialsFile, 'utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n if ('credentialsBase64' in credentials) {\n const raw = Buffer.from(credentials.credentialsBase64, 'base64').toString('utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n return credentials as ServiceAccountCredentials;\n}\n\nexport interface SheetFetchResult {\n /** Resolved sheet tab name. */\n sheetName: string;\n /** Numeric sheet ID (used for batchUpdate deletions). */\n sheetId: number;\n /** Raw rows including the header row (row 0). */\n rawRows: string[][];\n /** Parsed header cells (trimmed). */\n header: string[];\n /** Parsed test entries. */\n entries: TestEntry[];\n}\n\nexport interface FetchAllResult {\n /** Full data for the primary (writable) sheet — used by SheetsWriter. */\n primary: SheetFetchResult;\n /** Merged entries from primary + all referenceSheets — used by SkipperResolver. */\n entries: TestEntry[];\n /**\n * Authenticated Sheets API client — returned here so callers (SheetsWriter)\n * can reuse the same auth session for write operations without a second auth call.\n */\n sheets: sheets_v4.Sheets;\n}\n\nexport class SheetsClient {\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n }\n\n private async fetchSheet(\n sheets: sheets_v4.Sheets,\n sheetName: string,\n sheetId: number,\n ): Promise<SheetFetchResult> {\n const spreadsheetId = this.config.spreadsheetId;\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const response = await sheets.spreadsheets.values.get({ spreadsheetId, range: sheetName });\n const rawRows = (response.data.values ?? []) as string[][];\n\n if (rawRows.length === 0) {\n return { sheetName, sheetId, rawRows, header: [], entries: [] };\n }\n\n const header = rawRows[0].map((h: string) => String(h).trim());\n const testIdIdx = header.indexOf(testIdCol);\n const disabledUntilIdx = header.indexOf(disabledUntilCol);\n const notesIdx = header.indexOf('notes');\n\n if (testIdIdx === -1) {\n throw new Error(\n `[skipper] Column \"${testIdCol}\" not found in sheet \"${sheetName}\". ` +\n `Found columns: ${header.join(', ')}`,\n );\n }\n\n const entries: TestEntry[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const row = rawRows[i];\n const testId = row[testIdIdx] ? String(row[testIdIdx]).trim() : '';\n if (!testId) continue;\n\n let disabledUntil: Date | null = null;\n if (disabledUntilIdx !== -1 && row[disabledUntilIdx]) {\n const raw = String(row[disabledUntilIdx]).trim();\n if (raw) {\n const parsed = new Date(raw);\n if (!isNaN(parsed.getTime())) {\n disabledUntil = parsed;\n } else {\n warn(\n `[skipper] Row ${i + 1} in \"${sheetName}\": invalid date \"${raw}\" in \"${disabledUntilCol}\" — treating as enabled`,\n );\n }\n }\n }\n\n const notes = notesIdx !== -1 && row[notesIdx] ? String(row[notesIdx]) : undefined;\n entries.push({ testId, disabledUntil, notes });\n }\n\n return { sheetName, sheetId, rawRows, header, entries };\n }\n\n /**\n * Fetches the primary sheet and all reference sheets in a single API session.\n *\n * Returns:\n * - `primary`: the primary sheet's full result (rawRows + header) for writer use\n * - `entries`: merged test entries from all sheets (for resolver use)\n * - `sheets`: the authenticated Sheets API client (reuse for write operations)\n *\n * googleapis and google-auth-library are loaded here via dynamic import so that\n * worker processes, which only call SkipperResolver.fromJSON(), never load them.\n *\n * Deduplication: when the same testId appears in multiple sheets, the most\n * restrictive (latest) disabledUntil wins.\n */\n async fetchAll(): Promise<FetchAllResult> {\n // Dynamic imports — only executed when actually fetching from the spreadsheet.\n const { google } = await import('googleapis');\n const { JWT } = await import('google-auth-library');\n\n const creds = resolveCredentials(this.config);\n const auth = new JWT({\n email: creds.client_email,\n key: creds.private_key,\n scopes: ['https://www.googleapis.com/auth/spreadsheets'],\n });\n const sheets = google.sheets({ version: 'v4', auth });\n\n const spreadsheetId = this.config.spreadsheetId;\n const meta = await sheets.spreadsheets.get({ spreadsheetId });\n const allSheetMeta = meta.data.sheets ?? [];\n\n const sheetIdByName = new Map<string, number>(\n allSheetMeta\n .filter((s) => s.properties?.title != null && s.properties.sheetId != null)\n .map((s) => [s.properties!.title!, s.properties!.sheetId!]),\n );\n\n const primaryName = this.config.sheetName ?? allSheetMeta[0]?.properties?.title ?? 'Sheet1';\n const primaryId = sheetIdByName.get(primaryName);\n if (primaryId == null) {\n throw new Error(`[skipper] Sheet \"${primaryName}\" not found in spreadsheet.`);\n }\n\n const primary = await this.fetchSheet(sheets, primaryName, primaryId);\n\n const referenceEntries: TestEntry[] = [];\n for (const refName of this.config.referenceSheets ?? []) {\n const refId = sheetIdByName.get(refName);\n if (refId == null) {\n warn(`[skipper] Reference sheet \"${refName}\" not found — skipping.`);\n continue;\n }\n const result = await this.fetchSheet(sheets, refName, refId);\n referenceEntries.push(...result.entries);\n }\n\n const merged = new Map<string, TestEntry>();\n for (const entry of [...primary.entries, ...referenceEntries]) {\n const key = normalizeTestId(entry.testId);\n const existing = merged.get(key);\n if (!existing) {\n merged.set(key, entry);\n } else if (entry.disabledUntil !== null) {\n if (existing.disabledUntil === null || entry.disabledUntil > existing.disabledUntil) {\n merged.set(key, entry);\n }\n }\n }\n\n return { primary, entries: [...merged.values()], sheets };\n }\n}\n","import * as path from 'path';\n\n/**\n * Normalizes a testId for consistent comparison:\n * - trim leading/trailing whitespace\n * - lowercase\n * - collapse multiple whitespace characters into a single space\n */\nexport function normalizeTestId(id: string): string {\n return id.trim().toLowerCase().replace(/\\s+/g, ' ');\n}\n\n/**\n * Builds a canonical testId from a file path and the test title path.\n *\n * Format: \"{relativePath} > {titlePath.join(' > ')}\"\n * Example: \"tests/auth/login.spec.ts > login > should log in with valid credentials\"\n *\n * The filePath is made relative to process.cwd() if it is absolute.\n * The titlePath is the array of describe block names + the test name,\n * as provided by the test framework (never pre-joined).\n */\nexport function buildTestId(filePath: string, titlePath: string[]): string {\n const relativePath = path.isAbsolute(filePath)\n ? path.relative(process.cwd(), filePath)\n : filePath;\n\n const normalizedPath = relativePath.split(path.sep).join('/');\n return [normalizedPath, ...titlePath].join(' > ');\n}\n","import { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport { log } from './logger';\nimport type { SkipperConfig, TestEntry } from './types';\n\nexport class SheetsWriter {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Reconciles the spreadsheet with the discovered test IDs:\n * - Appends rows for tests not yet in the primary sheet (with empty disabledUntil)\n * - Deletes rows for tests that no longer exist in the suite\n *\n * Only the primary sheet is modified. Reference sheets are never written to.\n * Rows are matched by normalized testId (case-insensitive, whitespace-collapsed).\n * The header row (row 1) is never modified.\n *\n * A single fetchAll() call is made to retrieve sheet metadata, existing entries,\n * and raw rows — no redundant API calls.\n */\n async sync(discoveredIds: string[]): Promise<void> {\n // One fetchAll() resolves the sheet name from metadata, fetches existing\n // entries, and returns raw rows and the authenticated Sheets client —\n // all in two API calls (metadata + values). No second auth/fetch needed.\n const { primary, entries: existingEntries, sheets } = await this.client.fetchAll();\n const { sheetName, sheetId, rawRows, header } = primary;\n\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const normalizedDiscovered = new Set(discoveredIds.map(normalizeTestId));\n const normalizedExisting = new Map<string, TestEntry>(\n existingEntries.map((e) => [normalizeTestId(e.testId), e]),\n );\n\n const toAdd = discoveredIds.filter((id) => !normalizedExisting.has(normalizeTestId(id)));\n const toRemoveNormalized = new Set(\n [...normalizedExisting.keys()].filter((nid) => !normalizedDiscovered.has(nid)),\n );\n\n if (toAdd.length === 0 && toRemoveNormalized.size === 0) {\n log('[skipper] Spreadsheet is already up to date.');\n return;\n }\n\n const spreadsheetId = this.config.spreadsheetId;\n\n const testIdIdx = header.indexOf(testIdCol);\n\n // Identify 0-based row indices (within rawRows) to delete, skipping header at 0.\n const rowIndicesToDelete: number[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const id = rawRows[i][testIdIdx] ? String(rawRows[i][testIdIdx]).trim() : '';\n if (id && toRemoveNormalized.has(normalizeTestId(id))) {\n rowIndicesToDelete.push(i);\n }\n }\n\n // Deletions must be sorted descending to avoid index shifting.\n const deleteRequests = rowIndicesToDelete\n .sort((a, b) => b - a)\n .map((rowIdx) => ({\n deleteDimension: {\n range: { sheetId, dimension: 'ROWS', startIndex: rowIdx, endIndex: rowIdx + 1 },\n },\n }));\n\n if (deleteRequests.length > 0) {\n await sheets.spreadsheets.batchUpdate({\n spreadsheetId,\n requestBody: { requests: deleteRequests },\n });\n log(`[skipper] Removed ${deleteRequests.length} obsolete test(s) from spreadsheet.`);\n }\n\n // Append new rows.\n if (toAdd.length > 0) {\n const headerIdxDisabledUntil = header.indexOf(disabledUntilCol);\n\n const newRows = toAdd.map((testId) => {\n const row: string[] = new Array(Math.max(testIdIdx + 1, headerIdxDisabledUntil + 1)).fill('');\n row[testIdIdx] = testId;\n if (headerIdxDisabledUntil !== -1) row[headerIdxDisabledUntil] = '';\n return row;\n });\n\n await sheets.spreadsheets.values.append({\n spreadsheetId,\n range: sheetName,\n valueInputOption: 'RAW',\n requestBody: { values: newRows },\n });\n log(`[skipper] Added ${toAdd.length} new test(s) to spreadsheet.`);\n }\n }\n}\n","import { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport type { SkipperConfig, SkipperMode } from './types';\n\n/**\n * SkipperResolver is the primary interface used by framework plugins.\n *\n * Lifecycle:\n * 1. Call `initialize()` once before tests run (in globalSetup / before hook).\n * 2. Call `isTestEnabled(testId)` per test to decide whether to skip.\n * 3. In sync mode, call `toJSON()` to serialize the cache for cross-process sharing.\n * 4. In worker processes, use `SkipperResolver.fromJSON()` to rehydrate.\n */\nexport class SkipperResolver {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n /** normalized testId → disabledUntil ISO string (null = no date = enabled) */\n private cache: Map<string, string | null> = new Map();\n private initialized = false;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Fetches the spreadsheet and populates the in-memory cache.\n * Must be called once before `isTestEnabled()`.\n */\n async initialize(): Promise<void> {\n const { entries } = await this.client.fetchAll();\n this.cache = new Map(\n entries.map((e) => [\n normalizeTestId(e.testId),\n e.disabledUntil ? e.disabledUntil.toISOString() : null,\n ]),\n );\n this.initialized = true;\n }\n\n /**\n * Returns true if the test should run.\n *\n * Logic:\n * - Not in spreadsheet → true (opt-out model: unknown tests run by default)\n * - disabledUntil is null or in the past → true\n * - disabledUntil is in the future → false\n */\n isTestEnabled(testId: string): boolean {\n if (!this.initialized) {\n throw new Error(\n '[skipper] SkipperResolver.initialize() must be called before isTestEnabled(). ' +\n 'Did you forget to add the globalSetup to your config?',\n );\n }\n\n const normalized = normalizeTestId(testId);\n if (!this.cache.has(normalized)) return true;\n\n const iso = this.cache.get(normalized);\n if (!iso) return true;\n\n return new Date(iso) <= new Date();\n }\n\n /**\n * Serializes the cache for cross-process sharing (e.g. globalSetup → workers).\n * Dates are stored as ISO strings; null means no date (enabled).\n */\n toJSON(): Record<string, string | null> {\n return Object.fromEntries(this.cache);\n }\n\n /**\n * Rehydrates a resolver from a serialized cache.\n * Used in worker processes that cannot call initialize() again.\n */\n static fromJSON(data: Record<string, string | null>): SkipperResolver {\n // We pass a dummy config since the client is never used after fromJSON\n const resolver = new SkipperResolver({\n spreadsheetId: '',\n credentials: { credentialsBase64: '' },\n });\n resolver.cache = new Map(Object.entries(data));\n resolver.initialized = true;\n return resolver;\n }\n\n getMode(): SkipperMode {\n const mode = process.env.SKIPPER_MODE;\n if (mode === 'sync') return 'sync';\n return 'read-only';\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/logger.ts","../src/client.ts","../src/cache.ts","../src/writer.ts","../src/resolver.ts"],"sourcesContent":["/**\n * Skipper logger — output is suppressed unless SKIPPER_DEBUG=1 (or any truthy value).\n */\n\nfunction isEnabled(): boolean {\n return Boolean(process.env.SKIPPER_DEBUG);\n}\n\nexport function log(message: string): void {\n if (isEnabled()) console.log(message);\n}\n\nexport function warn(message: string): void {\n if (isEnabled()) console.warn(message);\n}\n\nexport function error(message: string): void {\n if (isEnabled()) console.error(message);\n}\n","import * as fs from 'fs';\nimport { normalizeTestId } from './cache';\nimport { warn } from './logger';\nimport type { SkipperConfig, TestEntry, ServiceAccountCredentials } from './types';\n\n// googleapis and google-auth-library are imported dynamically inside fetchAll() so that\n// worker processes that only call SkipperResolver.fromJSON() never load these modules.\n// Loading googleapis initialises an HTTP keep-alive agent that prevents worker exit.\nimport type { sheets_v4 } from 'googleapis';\n\nfunction resolveCredentials(config: SkipperConfig): ServiceAccountCredentials {\n const { credentials } = config;\n\n if ('credentialsFile' in credentials) {\n const raw = fs.readFileSync(credentials.credentialsFile, 'utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n if ('credentialsBase64' in credentials) {\n const raw = Buffer.from(credentials.credentialsBase64, 'base64').toString('utf8');\n return JSON.parse(raw) as ServiceAccountCredentials;\n }\n\n return credentials as ServiceAccountCredentials;\n}\n\nexport interface SheetFetchResult {\n /** Resolved sheet tab name. */\n sheetName: string;\n /** Numeric sheet ID (used for batchUpdate deletions). */\n sheetId: number;\n /** Raw rows including the header row (row 0). */\n rawRows: string[][];\n /** Parsed header cells (trimmed). */\n header: string[];\n /** Parsed test entries. */\n entries: TestEntry[];\n}\n\nexport interface FetchAllResult {\n /** Full data for the primary (writable) sheet — used by SheetsWriter. */\n primary: SheetFetchResult;\n /** Merged entries from primary + all referenceSheets — used by SkipperResolver. */\n entries: TestEntry[];\n /**\n * Authenticated Sheets API client — returned here so callers (SheetsWriter)\n * can reuse the same auth session for write operations without a second auth call.\n */\n sheets: sheets_v4.Sheets;\n}\n\nexport class SheetsClient {\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n }\n\n private async fetchSheet(\n sheets: sheets_v4.Sheets,\n sheetName: string,\n sheetId: number,\n ): Promise<SheetFetchResult> {\n const spreadsheetId = this.config.spreadsheetId;\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const response = await sheets.spreadsheets.values.get({ spreadsheetId, range: sheetName });\n const rawRows = (response.data.values ?? []) as string[][];\n\n if (rawRows.length === 0) {\n return { sheetName, sheetId, rawRows, header: [], entries: [] };\n }\n\n const header = rawRows[0].map((h: string) => String(h).trim());\n const testIdIdx = header.indexOf(testIdCol);\n const disabledUntilIdx = header.indexOf(disabledUntilCol);\n const notesIdx = header.indexOf('notes');\n\n if (testIdIdx === -1) {\n throw new Error(\n `[skipper] Column \"${testIdCol}\" not found in sheet \"${sheetName}\". ` +\n `Found columns: ${header.join(', ')}`,\n );\n }\n\n const entries: TestEntry[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const row = rawRows[i];\n const testId = row[testIdIdx] ? String(row[testIdIdx]).trim() : '';\n if (!testId) continue;\n\n let disabledUntil: Date | null = null;\n if (disabledUntilIdx !== -1 && row[disabledUntilIdx]) {\n const raw = String(row[disabledUntilIdx]).trim();\n if (raw) {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(raw)) {\n throw new Error(\n `[skipper] Row ${i + 1} in \"${sheetName}\": date \"${raw}\" in \"${disabledUntilCol}\" must be in YYYY-MM-DD format (e.g. \"2026-04-01\")`,\n );\n }\n const [year, month, day] = raw.split('-').map(Number);\n // Parse as midnight UTC of the next day: \"disabled until 2026-04-01\" keeps\n // the test disabled through the entire calendar day in UTC and re-enables\n // at 2026-04-02T00:00:00Z, regardless of the runner's local timezone.\n disabledUntil = new Date(Date.UTC(year, month - 1, day + 1));\n }\n }\n\n const notes = notesIdx !== -1 && row[notesIdx] ? String(row[notesIdx]) : undefined;\n entries.push({ testId, disabledUntil, notes });\n }\n\n return { sheetName, sheetId, rawRows, header, entries };\n }\n\n /**\n * Fetches the primary sheet and all reference sheets in a single API session.\n *\n * Returns:\n * - `primary`: the primary sheet's full result (rawRows + header) for writer use\n * - `entries`: merged test entries from all sheets (for resolver use)\n * - `sheets`: the authenticated Sheets API client (reuse for write operations)\n *\n * googleapis and google-auth-library are loaded here via dynamic import so that\n * worker processes, which only call SkipperResolver.fromJSON(), never load them.\n *\n * Deduplication: when the same testId appears in multiple sheets, the most\n * restrictive (latest) disabledUntil wins.\n */\n async fetchAll(): Promise<FetchAllResult> {\n // Dynamic imports — only executed when actually fetching from the spreadsheet.\n const { google } = await import('googleapis');\n const { JWT } = await import('google-auth-library');\n\n const creds = resolveCredentials(this.config);\n const auth = new JWT({\n email: creds.client_email,\n key: creds.private_key,\n scopes: ['https://www.googleapis.com/auth/spreadsheets'],\n });\n const sheets = google.sheets({ version: 'v4', auth });\n\n const spreadsheetId = this.config.spreadsheetId;\n const meta = await sheets.spreadsheets.get({ spreadsheetId });\n const allSheetMeta = meta.data.sheets ?? [];\n\n const sheetIdByName = new Map<string, number>(\n allSheetMeta\n .filter((s) => s.properties?.title != null && s.properties.sheetId != null)\n .map((s) => [s.properties!.title!, s.properties!.sheetId!]),\n );\n\n const primaryName = this.config.sheetName ?? allSheetMeta[0]?.properties?.title ?? 'Sheet1';\n const primaryId = sheetIdByName.get(primaryName);\n if (primaryId == null) {\n throw new Error(`[skipper] Sheet \"${primaryName}\" not found in spreadsheet.`);\n }\n\n const primary = await this.fetchSheet(sheets, primaryName, primaryId);\n\n const referenceEntries: TestEntry[] = [];\n for (const refName of this.config.referenceSheets ?? []) {\n const refId = sheetIdByName.get(refName);\n if (refId == null) {\n warn(`[skipper] Reference sheet \"${refName}\" not found — skipping.`);\n continue;\n }\n const result = await this.fetchSheet(sheets, refName, refId);\n referenceEntries.push(...result.entries);\n }\n\n const merged = new Map<string, TestEntry>();\n for (const entry of [...primary.entries, ...referenceEntries]) {\n const key = normalizeTestId(entry.testId);\n const existing = merged.get(key);\n if (!existing) {\n merged.set(key, entry);\n } else if (entry.disabledUntil !== null) {\n if (existing.disabledUntil === null || entry.disabledUntil > existing.disabledUntil) {\n merged.set(key, entry);\n }\n }\n }\n\n return { primary, entries: [...merged.values()], sheets };\n }\n}\n","import * as path from 'path';\n\n/**\n * Normalizes a testId for consistent comparison:\n * - trim leading/trailing whitespace\n * - lowercase\n * - collapse multiple whitespace characters into a single space\n */\nexport function normalizeTestId(id: string): string {\n return id.trim().toLowerCase().replace(/\\s+/g, ' ');\n}\n\n/**\n * Builds a canonical testId from a file path and the test title path.\n *\n * Format: \"{relativePath} > {titlePath.join(' > ')}\"\n * Example: \"tests/auth/login.spec.ts > login > should log in with valid credentials\"\n *\n * The filePath is made relative to process.cwd() if it is absolute.\n * The titlePath is the array of describe block names + the test name,\n * as provided by the test framework (never pre-joined).\n */\nexport function buildTestId(filePath: string, titlePath: string[]): string {\n const relativePath = path.isAbsolute(filePath)\n ? path.relative(process.cwd(), filePath)\n : filePath;\n\n const normalizedPath = relativePath.split(path.sep).join('/');\n return [normalizedPath, ...titlePath].join(' > ');\n}\n","import { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport { log, warn } from './logger';\nimport type { SkipperConfig, TestEntry } from './types';\n\nexport class SheetsWriter {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Reconciles the spreadsheet with the discovered test IDs:\n * - Appends rows for tests not yet in the primary sheet (with empty disabledUntil)\n * - Deletes rows for tests that no longer exist in the suite\n *\n * Only the primary sheet is modified. Reference sheets are never written to.\n * Rows are matched by normalized testId (case-insensitive, whitespace-collapsed).\n * The header row (row 1) is never modified.\n *\n * A single fetchAll() call is made to retrieve sheet metadata, existing entries,\n * and raw rows — no redundant API calls.\n */\n async sync(discoveredIds: string[]): Promise<void> {\n // One fetchAll() resolves the sheet name from metadata, fetches existing\n // entries, and returns raw rows and the authenticated Sheets client —\n // all in two API calls (metadata + values). No second auth/fetch needed.\n const { primary, entries: existingEntries, sheets } = await this.client.fetchAll();\n const { sheetName, sheetId, rawRows, header } = primary;\n\n const testIdCol = this.config.testIdColumn ?? 'testId';\n const disabledUntilCol = this.config.disabledUntilColumn ?? 'disabledUntil';\n\n const normalizedDiscovered = new Set(discoveredIds.map(normalizeTestId));\n const normalizedExisting = new Map<string, TestEntry>(\n existingEntries.map((e) => [normalizeTestId(e.testId), e]),\n );\n\n const toAdd = discoveredIds.filter((id) => !normalizedExisting.has(normalizeTestId(id)));\n const orphanedNormalized = new Set(\n [...normalizedExisting.keys()].filter((nid) => !normalizedDiscovered.has(nid)),\n );\n const allowDelete = process.env.SKIPPER_SYNC_ALLOW_DELETE === 'true';\n const toRemoveNormalized = allowDelete ? orphanedNormalized : new Set<string>();\n\n if (orphanedNormalized.size > 0 && !allowDelete) {\n warn(\n `[skipper] ${orphanedNormalized.size} orphaned test(s) found in spreadsheet but not in suite — ` +\n 'set SKIPPER_SYNC_ALLOW_DELETE=true to remove them.',\n );\n }\n\n if (toAdd.length === 0 && toRemoveNormalized.size === 0) {\n log('[skipper] Spreadsheet is already up to date.');\n return;\n }\n\n const spreadsheetId = this.config.spreadsheetId;\n\n const testIdIdx = header.indexOf(testIdCol);\n\n // Identify 0-based row indices (within rawRows) to delete, skipping header at 0.\n const rowIndicesToDelete: number[] = [];\n for (let i = 1; i < rawRows.length; i++) {\n const id = rawRows[i][testIdIdx] ? String(rawRows[i][testIdIdx]).trim() : '';\n if (id && toRemoveNormalized.has(normalizeTestId(id))) {\n rowIndicesToDelete.push(i);\n }\n }\n\n // Deletions must be sorted descending to avoid index shifting.\n const deleteRequests = rowIndicesToDelete\n .sort((a, b) => b - a)\n .map((rowIdx) => ({\n deleteDimension: {\n range: { sheetId, dimension: 'ROWS', startIndex: rowIdx, endIndex: rowIdx + 1 },\n },\n }));\n\n if (deleteRequests.length > 0) {\n await sheets.spreadsheets.batchUpdate({\n spreadsheetId,\n requestBody: { requests: deleteRequests },\n });\n log(`[skipper] Removed ${deleteRequests.length} obsolete test(s) from spreadsheet.`);\n }\n\n // Append new rows.\n if (toAdd.length > 0) {\n const headerIdxDisabledUntil = header.indexOf(disabledUntilCol);\n\n const newRows = toAdd.map((testId) => {\n const row: string[] = new Array(Math.max(testIdIdx + 1, headerIdxDisabledUntil + 1)).fill('');\n row[testIdIdx] = testId;\n if (headerIdxDisabledUntil !== -1) row[headerIdxDisabledUntil] = '';\n return row;\n });\n\n await sheets.spreadsheets.values.append({\n spreadsheetId,\n range: sheetName,\n valueInputOption: 'RAW',\n requestBody: { values: newRows },\n });\n log(`[skipper] Added ${toAdd.length} new test(s) to spreadsheet.`);\n }\n }\n}\n","import * as fs from 'fs';\nimport * as path from 'path';\nimport { SheetsClient } from './client';\nimport { normalizeTestId } from './cache';\nimport { warn } from './logger';\nimport type { SkipperConfig, SkipperMode } from './types';\n\nconst DISK_CACHE_FILE = path.join(process.cwd(), '.skipper-cache.json');\n\ninterface DiskCacheData {\n timestamp: number;\n entries: Record<string, string | null>;\n}\n\nfunction readDiskCache(ttlSeconds: number): Record<string, string | null> | null {\n try {\n const raw = fs.readFileSync(DISK_CACHE_FILE, 'utf8');\n const data = JSON.parse(raw) as DiskCacheData;\n if ((Date.now() - data.timestamp) / 1000 <= ttlSeconds) return data.entries;\n } catch {\n // file missing or invalid — no cache available\n }\n return null;\n}\n\nfunction writeDiskCache(entries: Record<string, string | null>): void {\n try {\n fs.writeFileSync(DISK_CACHE_FILE, JSON.stringify({ timestamp: Date.now(), entries }));\n } catch {\n // non-fatal — cache write failure is ignored\n }\n}\n\n/**\n * SkipperResolver is the primary interface used by framework plugins.\n *\n * Lifecycle:\n * 1. Call `initialize()` once before tests run (in globalSetup / before hook).\n * 2. Call `isTestEnabled(testId)` per test to decide whether to skip.\n * 3. In sync mode, call `toJSON()` to serialize the cache for cross-process sharing.\n * 4. In worker processes, use `SkipperResolver.fromJSON()` to rehydrate.\n */\nexport class SkipperResolver {\n private readonly client: SheetsClient;\n private readonly config: SkipperConfig;\n /** normalized testId → disabledUntil ISO string (null = no date = enabled) */\n private cache: Map<string, string | null> = new Map();\n private initialized = false;\n /** When true, all tests are enabled (fail-open fallback with no valid cache). */\n private allEnabled = false;\n\n constructor(config: SkipperConfig) {\n this.config = config;\n this.client = new SheetsClient(config);\n }\n\n /**\n * Fetches the spreadsheet and populates the in-memory cache.\n * Must be called once before `isTestEnabled()`.\n *\n * On API failure:\n * - If a valid `.skipper-cache.json` exists within SKIPPER_CACHE_TTL seconds, it is used.\n * - Otherwise, if SKIPPER_FAIL_OPEN is not \"false\", all tests are enabled (fail-open).\n * - Otherwise (SKIPPER_FAIL_OPEN=false), the original error is re-thrown.\n */\n async initialize(): Promise<void> {\n const ttl = parseInt(process.env.SKIPPER_CACHE_TTL ?? '300', 10);\n const failOpen = process.env.SKIPPER_FAIL_OPEN !== 'false';\n\n let entries: Record<string, string | null>;\n try {\n const result = await this.client.fetchAll();\n entries = Object.fromEntries(\n result.entries.map((e) => [\n normalizeTestId(e.testId),\n e.disabledUntil ? e.disabledUntil.toISOString() : null,\n ]),\n );\n writeDiskCache(entries);\n } catch (err) {\n const cached = readDiskCache(ttl);\n if (cached !== null) {\n warn('[skipper] API unreachable — using cached skip list (SKIPPER_CACHE_TTL).');\n entries = cached;\n } else if (failOpen) {\n warn('[skipper] API unreachable and no valid cache — running all tests (SKIPPER_FAIL_OPEN=true).');\n this.allEnabled = true;\n this.initialized = true;\n return;\n } else {\n throw err;\n }\n }\n\n this.cache = new Map(Object.entries(entries));\n this.initialized = true;\n }\n\n /**\n * Returns true if the test should run.\n *\n * Logic:\n * - Not in spreadsheet → true (opt-out model: unknown tests run by default)\n * - disabledUntil is null or in the past → true\n * - disabledUntil is in the future → false\n * - allEnabled (fail-open with no cache) → always true\n */\n isTestEnabled(testId: string): boolean {\n if (!this.initialized) {\n throw new Error(\n '[skipper] SkipperResolver.initialize() must be called before isTestEnabled(). ' +\n 'Did you forget to add the globalSetup to your config?',\n );\n }\n\n if (this.allEnabled) return true;\n\n const normalized = normalizeTestId(testId);\n if (!this.cache.has(normalized)) return true;\n\n const iso = this.cache.get(normalized);\n if (!iso) return true;\n\n return new Date(iso) <= new Date();\n }\n\n /**\n * Serializes the cache for cross-process sharing (e.g. globalSetup → workers).\n * Dates are stored as ISO strings; null means no date (enabled).\n */\n toJSON(): Record<string, string | null> {\n return Object.fromEntries(this.cache);\n }\n\n /**\n * Rehydrates a resolver from a serialized cache.\n * Used in worker processes that cannot call initialize() again.\n */\n static fromJSON(data: Record<string, string | null>): SkipperResolver {\n // We pass a dummy config since the client is never used after fromJSON\n const resolver = new SkipperResolver({\n spreadsheetId: '',\n credentials: { credentialsBase64: '' },\n });\n resolver.cache = new Map(Object.entries(data));\n resolver.initialized = true;\n return resolver;\n }\n\n getMode(): SkipperMode {\n const mode = process.env.SKIPPER_MODE;\n if (mode === 'sync') return 'sync';\n return 'read-only';\n }\n}\n"],"mappings":"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","names":["fs","path"]}
|