gas-digital-twin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # gas-digital-twin
2
+
3
+ Test Google Apps Script locally. Zero dependencies.
4
+
5
+ 14 in-memory mocks of GAS APIs — SpreadsheetApp, GmailApp, DriveApp, HtmlService, ScriptApp, and more — so you can run `.gs` files in Node.js with real test assertions.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npm install gas-digital-twin
11
+ ```
12
+
13
+ ```js
14
+ import { setup, teardown, SpreadsheetApp, loadGsFile } from 'gas-digital-twin';
15
+
16
+ // Set up mock environment with fixture data
17
+ setup({
18
+ spreadsheets: {
19
+ 'sheet-123': {
20
+ name: 'My Sheet',
21
+ sheets: {
22
+ 'Data': [['Name', 'Score'], ['Alice', 95], ['Bob', 87]]
23
+ }
24
+ }
25
+ }
26
+ });
27
+
28
+ // Load and run a .gs file
29
+ const { exports: gs } = loadGsFile('./my-script.gs');
30
+ gs.processData();
31
+
32
+ // Assert against mock state
33
+ const sheet = SpreadsheetApp.openById('sheet-123').getSheetByName('Data');
34
+ assert.equal(sheet.getRange(1, 2).getValue(), 'Score');
35
+
36
+ teardown();
37
+ ```
38
+
39
+ ## Multi-File Projects
40
+
41
+ Real GAS projects have multiple `.gs` files sharing a global scope. gas-digital-twin handles this:
42
+
43
+ ```js
44
+ import { setup, teardown, loadGsProject } from 'gas-digital-twin';
45
+
46
+ setup({ /* fixtures */ });
47
+
48
+ // Load all .gs files in a directory — shared scope, just like GAS
49
+ const { exports: gs } = loadGsProject('./gas/');
50
+ gs.anyFunctionFromAnyFile();
51
+
52
+ teardown();
53
+ ```
54
+
55
+ ## CLI
56
+
57
+ ```bash
58
+ # Run a function from a .gs file
59
+ gas-twin run script.gs myFunction
60
+
61
+ # Run a function from a project directory
62
+ gas-twin run ./gas/ myFunction --fixture data.json
63
+
64
+ # Watch mode — rerun on file changes
65
+ gas-twin run script.gs myFunction --watch
66
+
67
+ # List all functions in a file or project
68
+ gas-twin list ./gas/
69
+
70
+ # Generate a test skeleton
71
+ gas-twin init ./gas/ > tests/test-project.js
72
+ ```
73
+
74
+ ## Fixture Files
75
+
76
+ Load test data from JSON:
77
+
78
+ ```json
79
+ {
80
+ "spreadsheets": {
81
+ "sheet-id": {
82
+ "name": "Test Sheet",
83
+ "sheets": { "Sheet1": [["Header"], ["Value"]] }
84
+ }
85
+ },
86
+ "threads": [
87
+ { "from": "sender@example.com", "subject": "Hello", "body": "World" }
88
+ ],
89
+ "folders": [
90
+ { "id": "folder-1", "name": "Documents" }
91
+ ],
92
+ "documents": { "doc-1": "Document text content" },
93
+ "htmlFiles": { "sidebar": "<div>HTML</div>" },
94
+ "calendars": {
95
+ "cal-1": {
96
+ "name": "Work",
97
+ "events": [{ "title": "Standup", "startTime": "2026-04-04T09:00:00", "endTime": "2026-04-04T09:30:00" }]
98
+ }
99
+ },
100
+ "activeUser": "user@example.com"
101
+ }
102
+ ```
103
+
104
+ Thread shorthand: `{ from, subject, body }` auto-wraps into a single-message thread.
105
+
106
+ ## Snapshot Testing
107
+
108
+ Compare spreadsheet state against stored snapshots:
109
+
110
+ ```js
111
+ import { assertSnapshot } from 'gas-digital-twin';
112
+
113
+ // First run: writes the snapshot file
114
+ // Subsequent runs: compares and throws on mismatch
115
+ assertSnapshot(import.meta.url, sheet, 'after-processing');
116
+
117
+ // Force update a snapshot
118
+ assertSnapshot(import.meta.url, sheet, 'after-processing', { update: true });
119
+ ```
120
+
121
+ Works with spreadsheets, individual sheets, Logger output, or any JSON-serializable value.
122
+
123
+ ## Available Mocks
124
+
125
+ | Mock | Key Methods |
126
+ |------|-------------|
127
+ | **SpreadsheetApp** | `openById`, `getActiveSpreadsheet`, `flush`, `getUi` |
128
+ | **GmailApp** | `search` (from:, label:, -label:, keywords), `sendEmail`, `getInboxThreads` |
129
+ | **DriveApp** | `getFolderById`, `getFileById`, `createFolder`, `createFile`, folder traversal |
130
+ | **UrlFetchApp** | `fetch` with registered URL responses |
131
+ | **DocumentApp** | `openById`, `getBody().getText()` |
132
+ | **Drive** (Advanced) | `Files.insert` (OCR bridge to DocumentApp), `Files.trash` |
133
+ | **HtmlService** | `createHtmlOutput`, `createHtmlOutputFromFile`, `createTemplate`, enums |
134
+ | **ScriptApp** | `newTrigger` (builder chain), `getProjectTriggers`, `deleteTrigger`, `getService` |
135
+ | **MailApp** | `sendEmail` (positional + object), `getRemainingDailyQuota` |
136
+ | **CalendarApp** | `getDefaultCalendar`, `createEvent`, `getEvents` (date range), `deleteEvent` |
137
+ | **Logger** | `log`, `getLog`, `clear` |
138
+ | **Utilities** | `formatDate`, `base64Encode/Decode`, `newBlob`, `getUuid` |
139
+ | **PropertiesService** | `getScriptProperties`, `getUserProperties`, `getDocumentProperties` |
140
+ | **Session** | `getActiveUser`, `getEffectiveUser` |
141
+
142
+ Every mock follows the same pattern: module-level state, `_addX()` helpers for setup, `_reset()` for teardown.
143
+
144
+ ## CI Integration
145
+
146
+ ```bash
147
+ # JUnit XML for GitHub Actions
148
+ npm run test:junit > results.xml
149
+
150
+ # Coverage (built into Node 20+)
151
+ npm run test:coverage
152
+
153
+ # Watch mode for TDD
154
+ npm run test:watch
155
+ ```
156
+
157
+ ## Capture Fixtures from Live Sheets
158
+
159
+ ```bash
160
+ # With googleapis installed
161
+ gas-twin-capture <spreadsheet-id> -o fixtures/data.json
162
+
163
+ # From piped JSON (works with any data source)
164
+ echo '{"Sheet1": [["A","B"],[1,2]]}' | gas-twin-capture --stdin -o fixtures/data.json
165
+ ```
166
+
167
+ ## Inline Editor
168
+
169
+ Split-screen terminal editor with live test feedback:
170
+
171
+ ```bash
172
+ gas-twin-edit script.gs myFunction --fixture data.json
173
+ ```
174
+
175
+ Ctrl+S saves and reruns, Ctrl+Z undo, Ctrl+Q quit.
176
+
177
+ ## Requirements
178
+
179
+ - Node.js >= 20.0.0
180
+ - Zero npm dependencies
181
+
182
+ ## License
183
+
184
+ MIT
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * GAS Digital Twin — Fixture Capture
5
+ *
6
+ * Reads a live Google Spreadsheet and outputs fixture JSON.
7
+ * Requires GOOGLE_APPLICATION_CREDENTIALS or gcloud auth.
8
+ *
9
+ * Usage:
10
+ * gas-twin capture <spreadsheet-id> [-o output.json] [--sheets Sheet1,Sheet2]
11
+ *
12
+ * This uses the Google Sheets API directly (no MCP dependency).
13
+ * Auth: uses Application Default Credentials or a service account key.
14
+ *
15
+ * If no Google credentials are available, it can also accept piped JSON:
16
+ * echo '{"Sheet1": [["A","B"],[1,2]]}' | gas-twin capture --stdin -o fixture.json
17
+ */
18
+
19
+ import { writeFileSync } from 'node:fs';
20
+ import { resolve } from 'node:path';
21
+
22
+ const HELP = `
23
+ gas-twin capture — Generate fixture JSON from a live Google Spreadsheet
24
+
25
+ Usage:
26
+ gas-twin capture <spreadsheet-id> [-o output.json] [--sheets Sheet1,Sheet2]
27
+ gas-twin capture --stdin [-o output.json]
28
+
29
+ Options:
30
+ -o, --output Write to file instead of stdout
31
+ --sheets Comma-separated list of sheet names to capture (default: all)
32
+ --stdin Read sheet data as JSON from stdin
33
+ --help, -h Show this help
34
+
35
+ Examples:
36
+ gas-twin capture 1oCMqgT4lzUg78Fdh-L-XNRAmckxZgN0tLGjxrFaWres
37
+ gas-twin capture 1oCMqgT4... -o fixtures/invoice-tracker.json --sheets Invoices,Counters
38
+ `;
39
+
40
+ async function main() {
41
+ const args = process.argv.slice(2);
42
+
43
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
44
+ console.log(HELP);
45
+ process.exit(0);
46
+ }
47
+
48
+ // Parse args
49
+ const outputIdx = Math.max(args.indexOf('-o'), args.indexOf('--output'));
50
+ const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : null;
51
+
52
+ const sheetsIdx = args.indexOf('--sheets');
53
+ const sheetFilter = sheetsIdx >= 0 ? args[sheetsIdx + 1].split(',') : null;
54
+
55
+ if (args.includes('--stdin')) {
56
+ await captureFromStdin(outputFile, sheetFilter);
57
+ return;
58
+ }
59
+
60
+ const spreadsheetId = args.find(a => !a.startsWith('-') && a !== outputFile && (sheetsIdx < 0 || a !== args[sheetsIdx + 1]));
61
+
62
+ if (!spreadsheetId) {
63
+ console.error('Error: spreadsheet ID required');
64
+ process.exit(1);
65
+ }
66
+
67
+ await captureFromApi(spreadsheetId, outputFile, sheetFilter);
68
+ }
69
+
70
+ async function captureFromApi(spreadsheetId, outputFile, sheetFilter) {
71
+ // Try to use Google Sheets API via googleapis
72
+ let sheets;
73
+ try {
74
+ const { google } = await import('googleapis');
75
+ const auth = new google.auth.GoogleAuth({
76
+ scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
77
+ });
78
+ sheets = google.sheets({ version: 'v4', auth });
79
+ } catch {
80
+ // googleapis not available — provide instructions
81
+ console.error('Google Sheets API not available.');
82
+ console.error('');
83
+ console.error('Options:');
84
+ console.error(' 1. Install googleapis: npm install googleapis');
85
+ console.error(' 2. Use MCP: Use the Google Sheets MCP to read the sheet,');
86
+ console.error(' then pipe the data: echo \'{"Sheet1": [...]}\' | gas-twin capture --stdin');
87
+ console.error(' 3. Use the manual approach: copy sheet data to JSON manually');
88
+ console.error('');
89
+ console.error('For MCP users (Claude Code):');
90
+ console.error(` Read the spreadsheet with sheets_read_range, then format as fixture JSON.`);
91
+ process.exit(1);
92
+ }
93
+
94
+ try {
95
+ // Get spreadsheet metadata
96
+ const meta = await sheets.spreadsheets.get({ spreadsheetId });
97
+ const name = meta.data.properties.title;
98
+ const allSheets = meta.data.sheets.map(s => s.properties.title);
99
+ const targetSheets = sheetFilter || allSheets;
100
+
101
+ console.error(`Capturing: "${name}" (${targetSheets.length} sheets)`);
102
+
103
+ // Read each sheet
104
+ const sheetData = {};
105
+ for (const sheetName of targetSheets) {
106
+ console.error(` Reading: ${sheetName}...`);
107
+ const res = await sheets.spreadsheets.values.get({
108
+ spreadsheetId,
109
+ range: sheetName,
110
+ });
111
+ sheetData[sheetName] = res.data.values || [];
112
+ }
113
+
114
+ const fixture = {
115
+ spreadsheets: {
116
+ [spreadsheetId]: {
117
+ name,
118
+ sheets: sheetData,
119
+ },
120
+ },
121
+ };
122
+
123
+ outputFixture(fixture, outputFile);
124
+ } catch (e) {
125
+ console.error(`Error: ${e.message}`);
126
+ process.exit(1);
127
+ }
128
+ }
129
+
130
+ async function captureFromStdin(outputFile, sheetFilter) {
131
+ const chunks = [];
132
+ for await (const chunk of process.stdin) {
133
+ chunks.push(chunk);
134
+ }
135
+
136
+ const raw = Buffer.concat(chunks).toString('utf-8');
137
+ let data;
138
+ try {
139
+ data = JSON.parse(raw);
140
+ } catch {
141
+ console.error('Error: invalid JSON on stdin');
142
+ process.exit(1);
143
+ }
144
+
145
+ // If data is already in fixture format, pass through
146
+ if (data.spreadsheets) {
147
+ outputFixture(data, outputFile);
148
+ return;
149
+ }
150
+
151
+ // Otherwise, assume it's { "SheetName": [[row], [row]] } format
152
+ const sheets = {};
153
+ for (const [name, rows] of Object.entries(data)) {
154
+ if (sheetFilter && !sheetFilter.includes(name)) continue;
155
+ sheets[name] = rows;
156
+ }
157
+
158
+ const fixture = {
159
+ spreadsheets: {
160
+ 'captured-sheet': {
161
+ name: 'Captured Sheet',
162
+ sheets,
163
+ },
164
+ },
165
+ };
166
+
167
+ outputFixture(fixture, outputFile);
168
+ }
169
+
170
+ function outputFixture(fixture, outputFile) {
171
+ const json = JSON.stringify(fixture, null, 2) + '\n';
172
+
173
+ if (outputFile) {
174
+ writeFileSync(resolve(outputFile), json);
175
+ console.error(`Written to ${outputFile}`);
176
+ } else {
177
+ process.stdout.write(json);
178
+ }
179
+ }
180
+
181
+ main();