import * as fs from 'fs'; import * as path from 'path'; import * as ts from 'typescript'; import * as cheerio from 'cheerio'; describe('Global Function Exports (AST Analysis)', () => { const srcDir = path.resolve(__dirname, '../'); const globalFile = path.join(srcDir, 'global.ts'); // --- Helper: Parse Global Exports --- const getGlobalExports = (): Set => { const content = fs.readFileSync(globalFile, 'utf-8'); const sourceFile = ts.createSourceFile('global.ts', content, ts.ScriptTarget.Latest, true); const exports = new Set(); const visit = (node: ts.Node) => { // Look for: ;(global as any).funcName = ... if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) { let left = node.left; // Handle property access: (exp).funcName or exp.funcName if (ts.isPropertyAccessExpression(left)) { // Check if expression is (global as any) or global let expression: ts.Expression = left.expression; // Unprap parens: ((global as any)) while (ts.isParenthesizedExpression(expression)) { expression = expression.expression; } // Unwrap 'as': global as any if (ts.isAsExpression(expression)) { expression = expression.expression; } if (ts.isIdentifier(expression) && expression.text === 'global') { if (ts.isIdentifier(left.name)) { exports.add(left.name.text); } } } } ts.forEachChild(node, visit); }; visit(sourceFile); return exports; }; // --- Helper: Find google.script.run Calls --- const getFrontendCalls = (): Map => { const calls = new Map(); // functionName -> filename const scanDir = (dir: string) => { const files = fs.readdirSync(dir); for (const file of files) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { scanDir(fullPath); } else if (file.endsWith('.html')) { const htmlContent = fs.readFileSync(fullPath, 'utf-8'); const $ = cheerio.load(htmlContent); $('script').each((_, script) => { const scriptContent = $(script).html(); if (!scriptContent) return; const sourceFile = ts.createSourceFile(file + '.js', scriptContent, ts.ScriptTarget.Latest, true); const visit = (node: ts.Node) => { if (ts.isCallExpression(node)) { // Check if this call is part of a google.script.run chain const chain = analyzeChain(node.expression); if (chain && chain.isGoogleScriptRun) { if (!['withSuccessHandler', 'withFailureHandler', 'withUserObject'].includes(chain.methodName)) { calls.set(chain.methodName, file); } } } ts.forEachChild(node, visit); }; visit(sourceFile); }); } } }; scanDir(srcDir); return calls; }; // Helper to analyze property access chain // Returns { isGoogleScriptRun: boolean, methodName: string } if valid const analyzeChain = (expression: ts.Expression): { isGoogleScriptRun: boolean, methodName: string } | null => { if (!ts.isPropertyAccessExpression(expression)) { return null; } if (!ts.isIdentifier(expression.name)) { return null; } const methodName = expression.name.text; let current = expression.expression; let depth = 0; let p = current; while (depth < 20) { // Safety break if (ts.isCallExpression(p)) { p = p.expression; } else if (ts.isPropertyAccessExpression(p)) { // Check for google.script.run if (ts.isIdentifier(p.name) && p.name.text === 'run') { // check exp.exp is script, exp.exp.exp is google if (ts.isPropertyAccessExpression(p.expression) && ts.isIdentifier(p.expression.name) && p.expression.name.text === 'script' && ts.isIdentifier(p.expression.expression) && p.expression.expression.text === 'google') { return { isGoogleScriptRun: true, methodName }; } } p = p.expression; } else { break; } depth++; } return null; }; test('All client-side google.script.run calls must be exported in global.ts', () => { const globalExports = getGlobalExports(); const frontendCalls = getFrontendCalls(); const missingQuery: string[] = []; frontendCalls.forEach((filename, funcName) => { if (!globalExports.has(funcName)) { missingQuery.push(`${funcName} (called in ${filename})`); } }); if (missingQuery.length > 0) { throw new Error( `The following backend functions are called from the frontend but missing from src/global.ts:\n` + missingQuery.join('\n') + `\n\nPlease add them to src/global.ts like: ;(global as any).${missingQuery[0].split(' ')[0]} = ...` ); } }); });