diff --git a/tests/unit/backend.test.js b/tests/unit/backend.test.js index 5a7306b..4ee892f 100644 --- a/tests/unit/backend.test.js +++ b/tests/unit/backend.test.js @@ -2,24 +2,29 @@ * Backend GAS function tests using mock runtime. * Ref: #51 — Wave 5 GAS mock testing phase 2 * - * Strategy: Wrap 程式碼.js source in a factory function that receives mock - * objects as params, returning the declared functions for direct testing. + * Strategy: Install GAS mocks as globals via vi.stubGlobal, then require() + * 程式碼.js directly so v8 can instrument it for coverage. Module cache is + * invalidated between calls to get a fresh evaluation (matching the old + * sandbox-per-test behavior). + * + * Ref: #107 — Migrated from new Function() sandbox to direct require() for + * v8 coverage instrumentation. */ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { resolve } from 'path'; +import { describe, it, expect, vi } from 'vitest'; +import { createRequire } from 'module'; import { createMockSheet, createMockSpreadsheetApp, createMockSession, createMockLockService, createMockPropertiesService, createMockLogger, createMockHtmlService, createMockDriveApp } from '../mocks/gasMocks.js'; -const gasSource = readFileSync( - resolve(import.meta.dirname, '../../程式碼.js'), - 'utf-8' -); +const _require = createRequire(import.meta.url); +const backendPath = _require.resolve('../../程式碼.js'); /** - * Create a sandboxed GAS environment — wraps 程式碼.js in a function that - * receives GAS globals as parameters and returns all declared functions. + * Create a GAS environment — installs mocks as globals, then require()s + * 程式碼.js with a fresh module evaluation (cache invalidated). + * + * Ref: #107 — replaced new Function() sandbox with direct require() so + * v8 coverage provider can instrument 程式碼.js. */ function createGasEnv(opts = {}) { const sheets = opts.sheets || {}; @@ -31,26 +36,19 @@ function createGasEnv(opts = {}) { const HtmlService = createMockHtmlService(); const DriveApp = createMockDriveApp(opts.driveFiles || {}); - // Wrap the GAS source so that all top-level functions become properties - // of an object we can return. We inject the GAS globals as local variables. - const wrappedSource = ` - return (function(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp) { - ${gasSource} - return { - getConfig, _findScheduleRowInfo, _checkPermission, _getSs, getSheet, getOrCreateSheet, - doGet, getData, saveData, checkMetadata, addSchedule, - updateScheduleMetadata, deleteSchedule, copySchedule, - getVersions, getVersionData, getFontBase64FromDrive - }; - })(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp); - `; - - const factory = new Function( - 'SpreadsheetApp', 'Session', 'LockService', 'PropertiesService', 'Logger', 'HtmlService', 'DriveApp', - wrappedSource - ); - - return factory(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp); + // Install GAS globals so 程式碼.js's top-level code can find them + vi.stubGlobal('SpreadsheetApp', SpreadsheetApp); + vi.stubGlobal('Session', Session); + vi.stubGlobal('LockService', LockService); + vi.stubGlobal('PropertiesService', PropertiesService); + vi.stubGlobal('Logger', Logger); + vi.stubGlobal('HtmlService', HtmlService); + vi.stubGlobal('DriveApp', DriveApp); + + // Invalidate module cache to get a fresh evaluation (resets _ss memoization) + delete _require.cache[backendPath]; + + return _require(backendPath); } // ─── checkMetadata ─────────────────────────────────────────────────────── diff --git a/tests/unit/backendSignatureContracts.test.js b/tests/unit/backendSignatureContracts.test.js index 5973b6a..4479bd1 100644 --- a/tests/unit/backendSignatureContracts.test.js +++ b/tests/unit/backendSignatureContracts.test.js @@ -4,26 +4,26 @@ * * Ref: #107 — P0 Coverage Sprint Wave 1 (signature contracts) * - * Strategy: Reuses the createGasEnv() sandbox pattern from backend.test.js - * to wrap 程式碼.js and expose all functions. Validates: + * Strategy: Installs GAS mocks as globals, then require()s 程式碼.js directly + * so v8 can instrument it for coverage. Validates: * 1. All 17 functions exist and are typeof 'function' * 2. Each function's .length matches its declared parameter count * 3. The 9 frontend-called functions (via ServerApi.call) exist in env + * + * Ref: #107 — Migrated from new Function() sandbox to direct require(). */ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { resolve } from 'path'; -import { createMockSheet, createMockSpreadsheetApp, createMockSession, +import { describe, it, expect, vi } from 'vitest'; +import { createRequire } from 'module'; +import { createMockSpreadsheetApp, createMockSession, createMockLockService, createMockPropertiesService, createMockLogger, createMockHtmlService, createMockDriveApp } from '../mocks/gasMocks.js'; -const gasSource = readFileSync( - resolve(import.meta.dirname, '../../程式碼.js'), - 'utf-8' -); +const _require = createRequire(import.meta.url); +const backendPath = _require.resolve('../../程式碼.js'); /** - * Create a sandboxed GAS environment — mirrors backend.test.js createGasEnv. + * Create a GAS environment — mirrors backend.test.js createGasEnv. + * Ref: #107 — direct require() for v8 coverage. */ function createGasEnv(opts = {}) { const sheets = opts.sheets || {}; @@ -35,24 +35,17 @@ function createGasEnv(opts = {}) { const HtmlService = createMockHtmlService(); const DriveApp = createMockDriveApp(opts.driveFiles || {}); - const wrappedSource = ` - return (function(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp) { - ${gasSource} - return { - getConfig, _findScheduleRowInfo, _checkPermission, _getSs, getSheet, getOrCreateSheet, - doGet, getData, saveData, checkMetadata, addSchedule, - updateScheduleMetadata, deleteSchedule, copySchedule, - getVersions, getVersionData, getFontBase64FromDrive - }; - })(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp); - `; - - const factory = new Function( - 'SpreadsheetApp', 'Session', 'LockService', 'PropertiesService', 'Logger', 'HtmlService', 'DriveApp', - wrappedSource - ); - - return factory(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp); + vi.stubGlobal('SpreadsheetApp', SpreadsheetApp); + vi.stubGlobal('Session', Session); + vi.stubGlobal('LockService', LockService); + vi.stubGlobal('PropertiesService', PropertiesService); + vi.stubGlobal('Logger', Logger); + vi.stubGlobal('HtmlService', HtmlService); + vi.stubGlobal('DriveApp', DriveApp); + + delete _require.cache[backendPath]; + + return _require(backendPath); } // ─── All 17 top-level functions with expected parameter counts ──────────── @@ -116,8 +109,10 @@ describe('程式碼.js Signature Contracts', () => { } ); - it('env exposes exactly 17 functions', () => { - const fnCount = Object.keys(env).filter(k => typeof env[k] === 'function').length; + it('env exposes exactly 17 production functions (excluding test helpers)', () => { + const fnCount = Object.keys(env) + .filter(k => typeof env[k] === 'function' && !k.startsWith('_reset')) + .length; expect(fnCount).toBe(17); }); }); diff --git a/tests/unit/businessLogicEdgeCases.test.js b/tests/unit/businessLogicEdgeCases.test.js index e743dfd..48be1e6 100644 --- a/tests/unit/businessLogicEdgeCases.test.js +++ b/tests/unit/businessLogicEdgeCases.test.js @@ -12,9 +12,8 @@ * * Closes #138 */ -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'fs'; -import { resolve } from 'path'; +import { describe, it, expect, vi } from 'vitest'; +import { createRequire } from 'module'; import { timeToMinutes, checkTimeConflict, @@ -23,12 +22,10 @@ import { createMockSheet, createMockSpreadsheetApp, createMockSession, createMockLockService, createMockPropertiesService, createMockLogger, createMockHtmlService, createMockDriveApp } from '../mocks/gasMocks.js'; -// ─── GAS environment factory (same as backend.test.js) ───────────────────── +// ─── GAS environment factory (Ref: #107 — direct require for v8 coverage) ── -const gasSource = readFileSync( - resolve(import.meta.dirname, '../../程式碼.js'), - 'utf-8' -); +const _require = createRequire(import.meta.url); +const backendPath = _require.resolve('../../程式碼.js'); function createGasEnv(opts = {}) { const sheets = opts.sheets || {}; @@ -40,24 +37,17 @@ function createGasEnv(opts = {}) { const HtmlService = createMockHtmlService(); const DriveApp = createMockDriveApp(opts.driveFiles || {}); - const wrappedSource = ` - return (function(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp) { - ${gasSource} - return { - getConfig, _findScheduleRowInfo, _checkPermission, _getSs, getSheet, getOrCreateSheet, - doGet, getData, saveData, checkMetadata, addSchedule, - updateScheduleMetadata, deleteSchedule, copySchedule, - getVersions, getVersionData, getFontBase64FromDrive - }; - })(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp); - `; - - const factory = new Function( - 'SpreadsheetApp', 'Session', 'LockService', 'PropertiesService', 'Logger', 'HtmlService', 'DriveApp', - wrappedSource - ); - - return factory(SpreadsheetApp, Session, LockService, PropertiesService, Logger, HtmlService, DriveApp); + vi.stubGlobal('SpreadsheetApp', SpreadsheetApp); + vi.stubGlobal('Session', Session); + vi.stubGlobal('LockService', LockService); + vi.stubGlobal('PropertiesService', PropertiesService); + vi.stubGlobal('Logger', Logger); + vi.stubGlobal('HtmlService', HtmlService); + vi.stubGlobal('DriveApp', DriveApp); + + delete _require.cache[backendPath]; + + return _require(backendPath); } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/tests/unit/wiringContracts.test.js b/tests/unit/wiringContracts.test.js index de64544..3e7672e 100644 --- a/tests/unit/wiringContracts.test.js +++ b/tests/unit/wiringContracts.test.js @@ -40,42 +40,39 @@ function extractProductionFunctions(source) { const productionFunctions = extractProductionFunctions(productionSource); -// ─── Parse backend.test.js factory return ────────────────────────────────── - -const backendTestSource = readFileSync( - resolve(import.meta.dirname, './backend.test.js'), - 'utf-8' +// ─── Verify 程式碼.js exports via direct require ───────────────────────────── +// Ref: #107 — backend.test.js no longer uses factory return block; instead +// 程式碼.js exports its functions via conditional module.exports. We verify +// the wiring by requiring the module directly. + +import { createRequire } from 'module'; +import { createMockSpreadsheetApp, createMockSession, + createMockLockService, createMockPropertiesService, createMockLogger, + createMockHtmlService, createMockDriveApp } from '../mocks/gasMocks.js'; + +const _require = createRequire(import.meta.url); +const backendPath = _require.resolve('../../程式碼.js'); + +// Install minimal GAS mocks so require() succeeds (程式碼.js accesses no globals +// at module-load time, but the conditional export block reads function refs) +globalThis.SpreadsheetApp = createMockSpreadsheetApp({}); +globalThis.Session = createMockSession('test@example.com'); +globalThis.LockService = createMockLockService(); +globalThis.PropertiesService = createMockPropertiesService({}); +globalThis.Logger = createMockLogger(); +globalThis.HtmlService = createMockHtmlService(); +globalThis.DriveApp = createMockDriveApp({}); + +delete _require.cache[backendPath]; +const backendExports = _require(backendPath); + +// Extract function names from the module exports (excluding test-only helpers) +const factoryReturnNames = new Set( + Object.keys(backendExports).filter(k => + typeof backendExports[k] === 'function' && !k.startsWith('_reset') + ) ); -/** - * Extract function names from the factory return block in backend.test.js. - * The factory pattern returns an object literal: `return { fn1, fn2, ... };` - */ -function extractFactoryReturnNames(source) { - // Find the return { ... } block inside the wrapped source template literal - const returnBlockRegex = /return\s*\{[\s\S]*?\};\s*\}\)\(SpreadsheetApp/; - const blockMatch = source.match(returnBlockRegex); - if (!blockMatch) return new Set(); - - const block = blockMatch[0]; - // Extract identifiers (handles multiline, trailing commas) - const names = new Set(); - const idRegex = /\b(\w+)\b/g; - let m; - // Skip the 'return' keyword and the closing parens - const inner = block.replace(/^return\s*\{/, '').replace(/\};\s*\}\)\(SpreadsheetApp$/, ''); - while ((m = idRegex.exec(inner)) !== null) { - const name = m[1]; - // Filter out non-function tokens - if (name !== 'return' && name !== 'SpreadsheetApp') { - names.add(name); - } - } - return names; -} - -const factoryReturnNames = extractFactoryReturnNames(backendTestSource); - // ─── Parse tests/lib/ exported functions ─────────────────────────────────── const libDir = resolve(import.meta.dirname, '../lib'); diff --git "a/\347\250\213\345\274\217\347\242\274.js" "b/\347\250\213\345\274\217\347\242\274.js" index 04d24e2..5cf555c 100644 --- "a/\347\250\213\345\274\217\347\242\274.js" +++ "b/\347\250\213\345\274\217\347\242\274.js" @@ -602,4 +602,19 @@ function getFontBase64FromDrive() { Logger.log(`從 Drive 獲取字體時發生錯誤: ${e.stack}`); return { success: false, error: e.toString() }; } -} \ No newline at end of file +} + +// Ref: #107 — Enable vitest v8 coverage instrumentation. +// GAS runtime has no `module` global, so this export block is a no-op in production. +/* eslint-disable no-undef */ +if (typeof module !== 'undefined') { + module.exports = { + _getSs, getConfig, _findScheduleRowInfo, _checkPermission, + getSheet, getOrCreateSheet, doGet, getData, saveData, checkMetadata, + addSchedule, updateScheduleMetadata, deleteSchedule, copySchedule, + getVersions, getVersionData, getFontBase64FromDrive, + // Test-only: allow resetting memoized spreadsheet reference between tests + _resetSs: () => { _ss = null; }, + }; +} +/* eslint-enable no-undef */ \ No newline at end of file