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:
Ben Miller
2026-01-02 00:23:01 -07:00
parent ee5fd782fe
commit 1068c912dc
9 changed files with 1021 additions and 327 deletions

View File

@ -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)) {