Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
6d86cea
docs: sidebar refactor design — client TS/CSS split with RunConfig au…
aaronbrezel Feb 24, 2026
7c9f989
docs: sidebar refactor implementation plan
aaronbrezel Feb 24, 2026
8e43a79
feat: add google.d.ts type stub and DOM lib for client code
aaronbrezel Feb 24, 2026
e4d7efd
fix: use tsconfig.client.json for client types, fix withFailureHandle…
aaronbrezel Feb 24, 2026
f16994c
feat: extract Sidebar CSS to sidebar.css, drop Google Fonts
aaronbrezel Feb 24, 2026
b5a8b45
feat: add sidebar.ts skeleton with exported stubs
aaronbrezel Feb 24, 2026
a0e261f
fix: correct import path in google.d.ts
aaronbrezel Feb 24, 2026
54b286d
feat: implement buildTagList with tests
aaronbrezel Feb 24, 2026
11c1086
fix: pin jest-environment-jsdom to jest 29 major version
aaronbrezel Feb 24, 2026
172a127
feat: implement buildSingleTagList with tests
aaronbrezel Feb 24, 2026
ab5efe2
feat: implement handleRowRangeChange with tests
aaronbrezel Feb 24, 2026
52c5cef
feat: implement applyPreset with tests
aaronbrezel Feb 24, 2026
ff874ba
feat: implement assembleRunConfig with tests
aaronbrezel Feb 24, 2026
7c90f1a
fix: applyPreset reveals new-col-input for __new__ preset; add sideba…
aaronbrezel Feb 24, 2026
5dd0afa
fix: restrict tsconfig types to prevent jsdom MimeType collision
aaronbrezel Feb 24, 2026
305e161
fix: declare global in google.d.ts and clear inherited exclude in cli…
aaronbrezel Feb 24, 2026
fc3e8e9
feat: add showAIPanel, hideAIPanel, dispatchTool, runAI, init
aaronbrezel Feb 24, 2026
2a8f078
feat: convert Sidebar.html to build template with id-wired buttons
aaronbrezel Feb 24, 2026
b7de51e
feat: add inlineSidebarHtml Rollup plugin and client build config
aaronbrezel Feb 24, 2026
0fe4edd
chore: remove manual Sidebar.html copy from build scripts
aaronbrezel Feb 24, 2026
77695dd
chore: remove redundant tsconfig.test.client.json, consolidate to tsc…
aaronbrezel Feb 24, 2026
21fb410
fix: suppress sourcemap warning in client Rollup config
aaronbrezel Feb 24, 2026
d43e71b
refactor: split sidebar into pure helpers and GAS-coupled entry point
aaronbrezel Feb 24, 2026
5e4c0b1
adding npm fixes
aaronbrezel Feb 24, 2026
3ddef44
docs: sidebar entry point testing design — fixture reuse + callback c…
aaronbrezel Feb 24, 2026
2bab849
docs: sidebar entry point testing implementation plan
aaronbrezel Feb 24, 2026
c854e74
test: add shared sidebar DOM fixture module
aaronbrezel Feb 24, 2026
93abc69
refactor: sidebar tests use shared fixture module
aaronbrezel Feb 24, 2026
eaafeab
fix: scope Node types to fixture file, tighten tsconfig.client.json i…
aaronbrezel Feb 24, 2026
da4f1ec
feat: export sidebar-entry functions for testing
aaronbrezel Feb 24, 2026
b79df86
test: hideAIPanel and showAIPanel callback tests
aaronbrezel Feb 24, 2026
93b8f98
test: dispatchTool loading state and runTool callback tests
aaronbrezel Feb 24, 2026
84ac78b
test: runAI assembly, runBatchAI dispatch, and callback tests
aaronbrezel Feb 24, 2026
7c74bf0
test: sidebar-entry callback tests (hideAIPanel, showAIPanel, dispatc…
aaronbrezel Feb 24, 2026
847f5a1
chore: include sidebar-entry.ts in coverage with per-file thresholds
aaronbrezel Feb 24, 2026
77d50a4
docs: update sidebar-entry.ts header — file is now partially covered
aaronbrezel Feb 24, 2026
935837d
docs: update CLAUDE.md with client build pipeline and sidebar test ri…
aaronbrezel Feb 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ npm run lint # ESLint on src/
npm run lint:fix # ESLint with auto-fix
npm run format # Prettier on src/ (rewrites files)
npm run format:check # Check Prettier formatting without modifying files
npm run typecheck # TypeScript type check without building (tsc --noEmit)
npm run typecheck # TypeScript type check without building (server + client tsconfigs)
npm run deploy:dev # Build + clasp push to dev script
npm run deploy:prod # Build + clasp push to prod script
npm run deploy:watch:dev # Continuous build + clasp push watch (dev)
Expand All @@ -41,13 +41,17 @@ Run a single test by name: `npx jest -t "extractId"`

### Build Pipeline

The build produces two outputs via a `rollup.config.js` array:

**Config 1 — Server bundle:**

`src/server/index.ts` → Rollup (IIFE format, assigned to `_GASEntry`) → `dist/index.js`

Apps Script has no module system — it only sees top-level functions in the global scope. Rollup wraps everything in an IIFE assigned to `_GASEntry`, so exports from `index.ts` are not directly visible. The `footer` field in `rollup.config.js` bridges this gap by appending plain global function stubs that delegate into the IIFE:

```js
function onOpen(e) { _GASEntry.onOpen(e); }
function showSourceDialog() { _GASEntry.showSourceDialog(); }
function showSidebar() { _GASEntry.showSidebar(); }
// ... one stub per public entry point
```

Expand All @@ -62,8 +66,22 @@ If you skip step 2, the function will exist in the bundle but Apps Script won't

The TypeScript-level JSDoc is compiled away by Rollup and does not appear on the global stub. Google Sheets only registers a function as a custom function when `@customfunction` is present in a JSDoc comment on the **global** declaration — the one in the footer. Without it the function executes correctly when called explicitly but does not appear in autocomplete and is not recognized as a custom function by Sheets.

**Config 2 — Client bundle → `dist/Sidebar.html`:**

`src/client/sidebar-entry.ts` → Rollup (IIFE) → `inlineSidebarHtml` plugin → `dist/Sidebar.html`

HtmlService can only serve `.html` files — all JavaScript and CSS must be inlined at build time. The custom `inlineSidebarHtml` Rollup plugin handles this:
1. Compiles `sidebar-entry.ts` to an intermediate JS chunk
2. Reads `src/Sidebar.html` (the template), `src/client/sidebar.css`
3. Replaces `{{STYLES}}` with `<style>…css…</style>` and `{{SCRIPTS}}` with `<script>…js…</script>`
4. Emits `dist/Sidebar.html` as an asset
5. Deletes the intermediate `.js` chunk so clasp never pushes it as a `.gs` file

The `src/Sidebar.html` template is also read at test time by `__tests__/helpers/sidebar-fixtures.ts` to keep DOM fixtures structurally in sync with the real template.

### Module Dependency Graph

**Server:**
```
src/server/index.ts (entry point — menu, 4 tool orchestrators, UI handlers, re-exports custom functions)
├── src/server/config.ts (CONFIG object: API key property name, model, column names, limits)
Expand All @@ -75,17 +93,70 @@ src/server/index.ts (entry point — menu, 4 tool orchestrators, UI han
└── src/shared/types.ts (shared interfaces: AppConfig, AIMode, ColumnMap, GeminiRequest, etc.)
```

**Client:**
```
src/client/sidebar-entry.ts (GAS-coupled entry point — showAIPanel, hideAIPanel, dispatchTool, runAI, init)
└── src/client/sidebar.ts (pure helpers — buildTagList, buildSingleTagList, applyPreset, assembleRunConfig, handleRowRangeChange)
└── src/shared/types.ts

src/client/google.d.ts (compile-time type stub for google.script.run — uses declare global{} pattern)
src/client/sidebar.css (sidebar styles — inlined into dist/Sidebar.html at build time)
src/Sidebar.html (sidebar template — {{STYLES}} and {{SCRIPTS}} placeholders replaced at build time)
```

Source files use relative imports (e.g. `../shared/types`). The `@server/*` and `@shared/*` aliases are **Jest-only** (mapped in `jest.config.cjs`) and are not available in TypeScript source.

Only `index.ts` should reference Google Apps Script UI services (SpreadsheetApp, HtmlService, PropertiesService). Other modules use injected values or specific GAS globals documented in their headers.
Only `index.ts` should reference Google Apps Script UI services (SpreadsheetApp, HtmlService, PropertiesService). On the client side, only `sidebar-entry.ts` calls `google.script.run`.

### TypeScript Configuration

Two tsconfigs for two build environments:

- **`tsconfig.json`** — server build. Targets ES2019, no DOM lib, excludes `src/client/`.
- **`tsconfig.client.json`** — client build and client tests. Extends base, adds `"lib": ["ES2019", "DOM"]`, sets `rootDir: "."` (covers both `src/` and `__tests__/`). Includes precise file patterns: `src/client/**/*.ts`, `src/shared/**/*.ts`, and the three client-side test files.

`npm run typecheck` runs both: `tsc --noEmit && tsc -p tsconfig.client.json --noEmit`.

**Note on types:** `tsconfig.client.json` uses `"types": ["google-apps-script", "jest"]` — do **not** add `"node"` here, as it causes `MimeType` collisions with the google-apps-script types. When a file needs Node.js types (e.g. `readFileSync`), use a triple-slash directive at the top of that file: `/// <reference types="node" />`.

### Testing

Jest with ts-jest preset. Tests live in `__tests__/`. Path aliases `@server/*` and `@shared/*` are mapped in `jest.config.cjs`.

**Pattern for mocking GAS globals:** Declare mocks (UrlFetchApp, DriveApp, SpreadsheetApp, etc.) as `globalThis` properties **before** importing the module under test, since imports execute immediately.

**Coverage:** Run `npm run test:coverage` to collect coverage and enforce per-file thresholds. Coverage is opt-in — the pre-commit hook runs `jest --bail` without `--coverage`. `src/server/index.ts` is excluded from coverage collection: `onOpen` and `openQuickstartDoc` are tested in `menu.test.ts`, but the four tool orchestrators are deeply coupled to SpreadsheetApp UI globals and are not unit-tested. See `docs/plans/2026-02-18-testing-coverage-design.md` for full rationale.
**Client-side mock pattern:** For `google.script.run`, capture the success/failure handlers registered by the function under test:

```ts
const mockRun = {
withSuccessHandler: jest.fn().mockReturnThis(),
withFailureHandler: jest.fn().mockReturnThis(),
getSheetHeaders: jest.fn(),
// ...
};
(globalThis as unknown as { google: unknown }).google = { script: { run: mockRun } };

let capturedSuccess: (v: unknown) => void;
beforeEach(() => {
mockRun.withSuccessHandler.mockImplementation((fn) => { capturedSuccess = fn; return mockRun; });
});
// Then invoke capturedSuccess(...) / capturedFailure(...) to simulate GAS callbacks.
```

**Shared DOM fixtures:** `__tests__/helpers/sidebar-fixtures.ts` exports:
- `FULL_SIDEBAR_HTML` — the sidebar HTML template read from `src/Sidebar.html` at test time (placeholders stripped), so tests stay structurally in sync with the real template without manual drift.
- `setupConfigPanel(headers?)` — sets `document.body.innerHTML` and populates all tag containers.
- `setupWithSelections(opts)` — calls `setupConfigPanel` then pre-selects values via `applyPreset`.

The `__tests__/helpers/` directory is excluded from test discovery via `testPathIgnorePatterns` in `jest.config.cjs`.

**Coverage:** Run `npm run test:coverage` to collect coverage and enforce per-file thresholds. Coverage is opt-in — the pre-commit hook runs `jest --bail` without `--coverage`.

Two boundary files are excluded from high thresholds:
- `src/server/index.ts` — excluded from coverage collection entirely. The four tool orchestrators are deeply coupled to SpreadsheetApp UI globals and are not unit-tested.
- `src/client/sidebar-entry.ts` — included in collection with lower per-file thresholds. The four exported functions (`showAIPanel`, `hideAIPanel`, `dispatchTool`, `runAI`) are fully tested. `init()` and its inner `addEventListener` arrow functions run at module load time before `beforeEach` sets up the DOM, so they are never invoked.

See `docs/plans/2026-02-18-testing-coverage-design.md` and `docs/plans/2026-02-24-sidebar-entry-testing-design.md` for full rationale.

**CI:** `.github/workflows/lint-typecheck-format-test.yml` runs on push to `main` and PRs targeting `main`: lint → typecheck → format check → test with coverage.

Expand Down
4 changes: 2 additions & 2 deletions __tests__/drive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe("extractTextUniversal", () => {

it("reads text directly from a Google Doc", () => {
(DriveApp.getFileById as jest.Mock).mockReturnValue({
getMimeType: () => MimeType.GOOGLE_DOCS,
getMimeType: () => "application/vnd.google-apps.document",
});
(DocumentApp.openById as jest.Mock).mockReturnValue({
getBody: () => ({ getText: () => "doc body text" }),
Expand All @@ -80,7 +80,7 @@ describe("extractTextUniversal", () => {
it("performs OCR and returns text for a PDF", () => {
const mockBlob = {};
(DriveApp.getFileById as jest.Mock).mockReturnValue({
getMimeType: () => MimeType.PDF,
getMimeType: () => "application/pdf",
getName: () => "report.pdf",
getBlob: () => mockBlob,
});
Expand Down
77 changes: 77 additions & 0 deletions __tests__/helpers/sidebar-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/// <reference types="node" />
/**
* Shared DOM fixtures and setup utilities for sidebar tests.
* Used by sidebar.test.ts and sidebar-entry.test.ts.
*
* FULL_SIDEBAR_HTML is read from src/Sidebar.html at test time so fixtures
* stay structurally in sync with the real template — no manual drift.
*/

import { readFileSync } from "fs";
import { resolve } from "path";
import { buildTagList, buildSingleTagList, applyPreset } from "../../src/client/sidebar";

/**
* The sidebar HTML template with {{STYLES}} and {{SCRIPTS}} placeholders
* stripped. Structurally identical to what the browser receives at runtime.
*/
export const FULL_SIDEBAR_HTML = readFileSync(resolve(__dirname, "../../src/Sidebar.html"), "utf-8")
.replace("{{STYLES}}", "")
.replace("{{SCRIPTS}}", "");

const DEFAULT_HEADERS = [
"col_a",
"col_b",
"col_c",
"source_drive",
"system_prompt",
"ai_inference",
];

/**
* Sets FULL_SIDEBAR_HTML on document.body and populates all four tag
* containers with the given headers.
*/
export function setupConfigPanel(headers: string[] = DEFAULT_HEADERS): void {
document.body.innerHTML = FULL_SIDEBAR_HTML;
buildTagList(document.getElementById("user-prompt-cols")!, headers);
buildTagList(document.getElementById("drive-file-cols")!, headers);
buildSingleTagList(document.getElementById("system-prompt-col")!, headers, false);
buildSingleTagList(document.getElementById("output-col")!, headers, true);
}

export interface SetupOpts {
headers?: string[];
userPrompt?: string[];
drive?: string[];
system?: string;
output?: string;
newOutputName?: string;
rowRange?: { start: number; end: number };
}

/**
* Calls setupConfigPanel, then uses applyPreset to pre-select values.
* Promoted from the local helper in assembleRunConfig tests.
*/
export function setupWithSelections({
headers,
userPrompt = [],
drive = [],
system,
output,
newOutputName,
rowRange,
}: SetupOpts = {}): void {
setupConfigPanel(headers);
if (userPrompt.length) applyPreset({ userPromptCols: userPrompt });
if (drive.length) applyPreset({ driveFileCols: drive });
if (system) applyPreset({ systemPromptCol: system });
if (output) applyPreset({ outputCol: output });
if (rowRange) applyPreset({ rowRange });
if (newOutputName !== undefined) {
const newBtn = document.querySelector<HTMLButtonElement>('#output-col [data-value="__new__"]')!;
newBtn.click();
(document.getElementById("new-col-input") as HTMLInputElement).value = newOutputName;
}
}
Loading