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 +184 -0
- package/bin/gas-capture.js +181 -0
- package/bin/gas-edit.js +378 -0
- package/bin/gas-test.js +298 -0
- package/fixtures/FIXTURE_SCHEMA.md +61 -0
- package/package.json +60 -0
- package/src/helpers/in-memory-sheet.js +603 -0
- package/src/index.js +206 -0
- package/src/loader.js +139 -0
- package/src/mocks/cache-service.js +81 -0
- package/src/mocks/calendar-app.js +122 -0
- package/src/mocks/content-service.js +39 -0
- package/src/mocks/document-app.js +48 -0
- package/src/mocks/drive-advanced.js +68 -0
- package/src/mocks/drive-app.js +338 -0
- package/src/mocks/gmail-app.js +240 -0
- package/src/mocks/html-service.js +164 -0
- package/src/mocks/logger.js +29 -0
- package/src/mocks/mail-app.js +46 -0
- package/src/mocks/properties-service.js +65 -0
- package/src/mocks/script-app.js +233 -0
- package/src/mocks/session.js +40 -0
- package/src/mocks/spreadsheet-app.js +339 -0
- package/src/mocks/url-fetch-app.js +73 -0
- package/src/mocks/utilities.js +123 -0
- package/src/reporters/junit.js +121 -0
- package/src/snapshot.js +133 -0
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();
|