Implement interactive execution plan and strict HTML validation
Features: - **Interactive Checklist**: 'Review Changes' modal now updates in real-time as save tasks complete. - **Signal Logging**: Backend emits [SIGNAL] logs for deletions, adoptions, uploads, and reorders. - **UI Cleanup**: Removed redundant textual 'Execute Progress' log pane. Build & Quality: - **HTML Validation**: Added ools/validate_html.ts to build pipeline to prevent syntax errors in embedded JS. - **Strict Build**: pm run build now runs alidate:html first.
This commit is contained in:
@ -1,25 +1,59 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as ts from 'typescript';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
describe('Global Function Exports', () => {
|
||||
const srcDir = path.resolve(__dirname, '../'); // Assumes src/test/GlobalFunctions.test.ts
|
||||
describe('Global Function Exports (AST Analysis)', () => {
|
||||
const srcDir = path.resolve(__dirname, '../');
|
||||
const globalFile = path.join(srcDir, 'global.ts');
|
||||
|
||||
// 1. Get all globally exported function names
|
||||
// --- Helper: Parse Global Exports ---
|
||||
const getGlobalExports = (): Set<string> => {
|
||||
const content = fs.readFileSync(globalFile, 'utf-8');
|
||||
const regex = /;\(global as any\)\.(\w+)\s*=/g;
|
||||
const sourceFile = ts.createSourceFile('global.ts', content, ts.ScriptTarget.Latest, true);
|
||||
const exports = new Set<string>();
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
exports.add(match[1]);
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 2. Find all google.script.run calls in HTML files
|
||||
// --- Helper: Find google.script.run Calls ---
|
||||
const getFrontendCalls = (): Map<string, string> => {
|
||||
const calls = new Map<string, string>(); // functionName -> filename (for error msg)
|
||||
const calls = new Map<string, string>(); // functionName -> filename
|
||||
|
||||
const scanDir = (dir: string) => {
|
||||
const files = fs.readdirSync(dir);
|
||||
@ -30,27 +64,30 @@ describe('Global Function Exports', () => {
|
||||
if (stat.isDirectory()) {
|
||||
scanDir(fullPath);
|
||||
} else if (file.endsWith('.html')) {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
// Matches:
|
||||
// google.script.run.myFunc()
|
||||
// google.script.run.withSuccessHandler(...).myFunc()
|
||||
// google.script.run.withFailureHandler(...).myFunc()
|
||||
// google.script.run.withSuccessHandler(...).withFailureHandler(...).myFunc()
|
||||
const htmlContent = fs.readFileSync(fullPath, 'utf-8');
|
||||
const $ = cheerio.load(htmlContent);
|
||||
|
||||
// Regex strategy:
|
||||
// 1. Find "google.script.run"
|
||||
// 2. Consume optional handlers .with...(...)
|
||||
// 3. Capture the final function name .FunctionName(
|
||||
$('script').each((_, script) => {
|
||||
const scriptContent = $(script).html();
|
||||
if (!scriptContent) return;
|
||||
|
||||
const callRegex = /google\.script\.run(?:[\s\n]*\.(?:withSuccessHandler|withFailureHandler|withUserObject)\([^)]*\))*[\s\n]*\.(\w+)\s*\(/g;
|
||||
const sourceFile = ts.createSourceFile(file + '.js', scriptContent, ts.ScriptTarget.Latest, true);
|
||||
|
||||
let match;
|
||||
while ((match = callRegex.exec(content)) !== null) {
|
||||
const funcName = match[1];
|
||||
if (!['withSuccessHandler', 'withFailureHandler', 'withUserObject'].includes(funcName)) {
|
||||
calls.set(funcName, file);
|
||||
}
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -59,10 +96,53 @@ describe('Global Function Exports', () => {
|
||||
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 = [];
|
||||
const missingQuery: string[] = [];
|
||||
|
||||
frontendCalls.forEach((filename, funcName) => {
|
||||
if (!globalExports.has(funcName)) {
|
||||
|
||||
Reference in New Issue
Block a user