@mcpher/gas-fakes 2.0.6 → 2.0.8

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/debug_form.js ADDED
@@ -0,0 +1,43 @@
1
+ import './main.js';
2
+ import { Syncit } from './src/support/syncit.js';
3
+
4
+ async function debug() {
5
+ Syncit.fxInit();
6
+
7
+ try {
8
+ const resource = Forms.Form.create({
9
+ info: {
10
+ title: "Debug Form with Items"
11
+ }
12
+ });
13
+ const formId = resource.formId;
14
+
15
+ // Add a text item
16
+ const updateRequest = {
17
+ requests: [{
18
+ createItem: {
19
+ item: {
20
+ title: "Question 1",
21
+ questionItem: {
22
+ question: {
23
+ textQuestion: { paragraph: false }
24
+ }
25
+ }
26
+ },
27
+ location: { index: 0 }
28
+ }
29
+ }]
30
+ };
31
+
32
+ const updateResponse = Forms.Form.batchUpdate(updateRequest, formId);
33
+ console.log("Update Response:", JSON.stringify(updateResponse, null, 2));
34
+
35
+ const fullForm = Forms.Form.get(formId);
36
+ console.log("Full Form Resource:", JSON.stringify(fullForm, null, 2));
37
+
38
+ } catch (e) {
39
+ console.error("Error:", e);
40
+ }
41
+ }
42
+
43
+ debug();
package/package.json CHANGED
@@ -4,21 +4,21 @@
4
4
  },
5
5
  "dependencies": {
6
6
  "@mcpher/fake-gasenum": "^1.0.6",
7
- "@mcpher/gas-flex-cache": "^1.1.4",
8
- "@modelcontextprotocol/sdk": "^1.20.2",
9
- "@sindresorhus/is": "^7.0.1",
7
+ "@mcpher/gas-flex-cache": "^1.1.5",
8
+ "@modelcontextprotocol/sdk": "^1.26.0",
9
+ "@sindresorhus/is": "^7.2.0",
10
10
  "acorn": "^8.15.0",
11
11
  "archiver": "^7.0.1",
12
- "commander": "^14.0.1",
13
- "dotenv": "^17.2.3",
14
- "fast-xml-parser": "^5.3.0",
12
+ "commander": "^14.0.3",
13
+ "dotenv": "^17.3.1",
14
+ "fast-xml-parser": "^5.3.6",
15
15
  "get-stream": "^9.0.1",
16
16
  "googleapis": "^170.1.0",
17
- "got": "^14.4.7",
17
+ "got": "^14.6.6",
18
18
  "into-stream": "^8.0.1",
19
- "keyv": "^5.5.0",
20
- "keyv-file": "^5.1.3",
21
- "mime": "^4.0.7",
19
+ "keyv": "^5.6.0",
20
+ "keyv-file": "^5.3.3",
21
+ "mime": "^4.1.0",
22
22
  "prompts": "^2.4.2",
23
23
  "sleep-synchronously": "^2.0.0",
24
24
  "unzipper": "^0.12.3",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "name": "@mcpher/gas-fakes",
35
35
  "author": "bruce mcpherson",
36
- "version": "2.0.6",
36
+ "version": "2.0.8",
37
37
  "license": "MIT",
38
38
  "main": "main.js",
39
39
  "description": "An implementation of the Google Workspace Apps Script runtime: Run native App Script Code on Node and Cloud Run",
@@ -42,4 +42,4 @@
42
42
  "bin": {
43
43
  "gas-fakes": "gas-fakes.js"
44
44
  }
45
- }
45
+ }
@@ -31,6 +31,21 @@ class FakeAdvDrivePermissions extends FakeAdvResource {
31
31
  return data;
32
32
  }
33
33
 
34
+ update(resource, fileId, permissionId, optionalArgs) {
35
+ const { nargs, matchThrow } = signatureArgs(arguments, "Drive.Permissions.update");
36
+ if (nargs < 3 || nargs > 4) matchThrow();
37
+
38
+ ScriptApp.__behavior.isAccessible(fileId, 'Drive', 'write');
39
+ const params = {
40
+ resource,
41
+ fileId,
42
+ permissionId,
43
+ ...(optionalArgs || {})
44
+ };
45
+ const { data } = this._call('update', params);
46
+ return data;
47
+ }
48
+
34
49
  delete(fileId, permissionId, optionalArgs) {
35
50
  const { nargs, matchThrow } = signatureArgs(arguments, "Drive.Permissions.delete");
36
51
  if (nargs < 2 || nargs > 3) matchThrow();
@@ -4,6 +4,7 @@ import { newFakeFolderApp } from './fakefolderapp.js'
4
4
  import { notYetImplemented, isFolder } from '../../support/helpers.js'
5
5
  import { Proxies } from '../../support/proxies.js'
6
6
  import { Utils } from '../../support/utils.js'
7
+ import { Access, Permission } from '../enums/driveenums.js'
7
8
  const { is } = Utils
8
9
 
9
10
 
@@ -157,10 +158,10 @@ export class FakeDriveApp {
157
158
  return notYetImplemented('enforceSingleParent')
158
159
  }
159
160
  get Access() {
160
- return notYetImplemented('Access')
161
+ return Access
161
162
  }
162
163
  get Permission() {
163
- return notYetImplemented('Permission')
164
+ return Permission
164
165
  }
165
166
 
166
167
  }
@@ -10,10 +10,12 @@
10
10
 
11
11
  import is from '@sindresorhus/is';
12
12
  import { isFolder, notYetImplemented, isFakeFolder, signatureArgs } from '../../support/helpers.js'
13
+ import { Access, Permission } from '../enums/driveenums.js'
13
14
  import { getParentsIterator } from './driveiterators.js';
15
+ import { getPermissionIterator } from '../../support/fileiterators.js';
14
16
  import { improveFileCache } from "../../support/filecache.js"
15
17
  import { getSharers } from '../../support/filesharers.js';
16
- import {slogger } from "../../support/slogger.js";
18
+ import { slogger } from "../../support/slogger.js";
17
19
  /**
18
20
  * basic fake File meta data
19
21
  * these are shared between folders and files
@@ -34,13 +36,13 @@ export class FakeDriveMeta {
34
36
 
35
37
  __preventRootDamage = (operation) => {
36
38
  if (this.__isRoot) {
37
- slogger.error (`Can't do ${operation} on root folder`)
39
+ slogger.error(`Can't do ${operation} on root folder`)
38
40
  throw new Error("Access denied: DriveApp")
39
41
  }
40
42
  }
41
- get __isRoot () {
43
+ get __isRoot() {
42
44
  const parents = this.__getDecorated("parents")
43
- return is.null (parents)
45
+ return is.null(parents)
44
46
  }
45
47
  /**
46
48
  * for enhancing the file with fields not retrieved by default
@@ -60,7 +62,7 @@ export class FakeDriveMeta {
60
62
  const newMeta = Drive.Files.get(this.getId(), { fields }, { allow404: false })
61
63
  // need to merge this with already known fields
62
64
  this.meta = { ...this.meta, ...newMeta }
63
- improveFileCache (this.getId(), this.meta, fields)
65
+ improveFileCache(this.getId(), this.meta, fields)
64
66
  return this
65
67
  }
66
68
 
@@ -89,7 +91,7 @@ export class FakeDriveMeta {
89
91
  __updateMeta(prop, value, type, ...args) {
90
92
 
91
93
  // cant update any meta on root folder
92
- this.__preventRootDamage (`set ${prop}`)
94
+ this.__preventRootDamage(`set ${prop}`)
93
95
  const { matchThrow } = signatureArgs(arguments, "update")
94
96
 
95
97
  if (!is[type](value)) {
@@ -99,9 +101,9 @@ export class FakeDriveMeta {
99
101
  file[prop] = value
100
102
 
101
103
  const data = Drive.Files.update(file, this.getId(), null, prop)
102
- this.meta = {...this.meta, ...data}
103
- improveFileCache (this.getId(), data)
104
-
104
+ this.meta = { ...this.meta, ...data }
105
+ improveFileCache(this.getId(), data)
106
+
105
107
  return this
106
108
  }
107
109
 
@@ -206,14 +208,14 @@ export class FakeDriveMeta {
206
208
  moveTo(destination) {
207
209
  // prepare for any arg errors
208
210
  const { matchThrow } = signatureArgs(arguments, "moveTo", "DriveApp.Folder")
209
-
211
+
210
212
  if (!isFakeFolder(destination)) {
211
213
  matchThrow()
212
214
  }
213
215
  // pick up parents for destination if not already known
214
216
  const newParent = destination.getId()
215
217
  if (!is.nonEmptyString(newParent)) {
216
- throw new Error (`expected to find destination id as a string but got ${newParent}`)
218
+ throw new Error(`expected to find destination id as a string but got ${newParent}`)
217
219
  }
218
220
 
219
221
  // cant move the root folder
@@ -262,6 +264,84 @@ export class FakeDriveMeta {
262
264
  return this.__updateMeta("writersCanShare", value, "boolean", arguments)
263
265
  }
264
266
 
267
+ /**
268
+ * Sets the sharing permission and access for the Folder/File.
269
+ * @param {import('../enums/driveenums.js').Access} access the access level
270
+ * @param {import('../enums/driveenums.js').Permission} permission the permission level
271
+ * @returns {FakeDriveFile|FakeDriveFolder} this self
272
+ */
273
+ setSharing(access, permission) {
274
+ const { nargs, matchThrow } = signatureArgs(arguments, "setSharing");
275
+ if (nargs !== 2) matchThrow();
276
+
277
+ // Mapping Appsscript Access/Permission to Drive API role/type
278
+ // This is a simplified version, as setSharing usually affects "anyone" or "domain" permissions.
279
+ // In Drive API v3, it involves managing permissions with types 'anyone' or 'domain'.
280
+
281
+ // 1. Determine type based on access
282
+ let type;
283
+ let role;
284
+ let allowFileDiscovery = false;
285
+
286
+ if (access === Access.ANYONE || access === Access.ANYONE_WITH_LINK) {
287
+ type = 'anyone';
288
+ allowFileDiscovery = (access === Access.ANYONE);
289
+ } else if (access === Access.DOMAIN || access === Access.DOMAIN_WITH_LINK) {
290
+ type = 'domain';
291
+ allowFileDiscovery = (access === Access.DOMAIN);
292
+ } else if (access === Access.PRIVATE) {
293
+ // For PRIVATE, we typically remove any 'anyone' or 'domain' permissions.
294
+ const { permissions } = Drive.Permissions.list(this.getId());
295
+ permissions.forEach(p => {
296
+ if (p.type === 'anyone' || p.type === 'domain') {
297
+ Drive.Permissions.delete(this.getId(), p.id);
298
+ }
299
+ });
300
+ return this;
301
+ }
302
+
303
+ // 2. Determine role based on permission
304
+ if (permission === Permission.VIEW || permission === Permission.READ) {
305
+ role = 'reader';
306
+ } else if (permission === Permission.COMMENT) {
307
+ role = 'commenter';
308
+ } else if (permission === Permission.EDIT) {
309
+ role = 'writer';
310
+ } else {
311
+ throw new Error(`Unsupported permission level for setSharing: ${permission}`);
312
+ }
313
+
314
+ // 3. Find existing permission of this type or create new
315
+ const { permissions } = Drive.Permissions.list(this.getId(), {
316
+ fields: "permissions(id,role,type,allowFileDiscovery,domain)"
317
+ });
318
+ const existing = permissions.find(p => p.type === type);
319
+
320
+ if (existing) {
321
+ // If the identity fields (type, allowFileDiscovery, domain) have changed, we must delete and recreate
322
+ const domain = type === 'domain' ? Session.getActiveUser().getDomain() : undefined;
323
+ const identityChanged = existing.allowFileDiscovery !== allowFileDiscovery ||
324
+ (type === 'domain' && existing.domain !== domain);
325
+
326
+ if (identityChanged) {
327
+ Drive.Permissions.delete(this.getId(), existing.id);
328
+ const resource = { role, type, allowFileDiscovery };
329
+ if (type === 'domain') resource.domain = domain;
330
+ Drive.Permissions.create(resource, this.getId());
331
+ } else {
332
+ // Only role is writable in update
333
+ Drive.Permissions.update({ role }, this.getId(), existing.id);
334
+ }
335
+ } else {
336
+ const resource = { role, type, allowFileDiscovery };
337
+ if (type === 'domain') resource.domain = Session.getActiveUser().getDomain();
338
+ Drive.Permissions.create(resource, this.getId());
339
+ }
340
+
341
+ improveFileCache(this.getId(), null);
342
+ return this;
343
+ }
344
+
265
345
  /**
266
346
  * Determines whether users with edit permissions to the Folder/File are allowed to share with other users or change the permissions
267
347
  * @returns {Boolean}
@@ -320,12 +400,60 @@ export class FakeDriveMeta {
320
400
  // TODO-----------
321
401
 
322
402
  getSharingPermission() {
323
- return notYetImplemented('getSharingPermission')
324
- }
403
+ const pit = getPermissionIterator({ id: this.getId() });
404
+ let highest = Permission.NONE;
405
+
406
+ const rank = (p) => {
407
+ if (p === Permission.OWNER) return 6;
408
+ if (p === Permission.EDIT) return 5;
409
+ if (p === Permission.COMMENT) return 4;
410
+ if (p === Permission.VIEW || p === Permission.READ) return 3;
411
+ return 0;
412
+ }
325
413
 
414
+ while (pit.hasNext()) {
415
+ const p = pit.next();
416
+ let current = Permission.NONE;
417
+ if (p.type === 'anyone' || p.type === 'domain') {
418
+ if (p.role === 'owner') current = Permission.OWNER;
419
+ else if (p.role === 'writer') current = Permission.EDIT;
420
+ else if (p.role === 'commenter') current = Permission.COMMENT;
421
+ else if (p.role === 'reader') current = Permission.VIEW;
422
+
423
+ if (rank(current) > rank(highest)) {
424
+ highest = current;
425
+ }
426
+ }
427
+ }
428
+ return highest;
429
+ }
326
430
 
327
431
  getSharingAccess() {
328
- return notYetImplemented('getSharingAccess')
432
+ const pit = getPermissionIterator({ id: this.getId() });
433
+ let highest = Access.PRIVATE;
434
+
435
+ const rank = (a) => {
436
+ if (a === Access.ANYONE) return 4;
437
+ if (a === Access.ANYONE_WITH_LINK) return 3;
438
+ if (a === Access.DOMAIN) return 2;
439
+ if (a === Access.DOMAIN_WITH_LINK) return 1;
440
+ return 0;
441
+ }
442
+
443
+ while (pit.hasNext()) {
444
+ const p = pit.next();
445
+ let current = Access.PRIVATE;
446
+ if (p.type === 'anyone') {
447
+ current = p.allowFileDiscovery ? Access.ANYONE : Access.ANYONE_WITH_LINK;
448
+ } else if (p.type === 'domain') {
449
+ current = p.allowFileDiscovery ? Access.DOMAIN : Access.DOMAIN_WITH_LINK;
450
+ }
451
+
452
+ if (rank(current) > rank(highest)) {
453
+ highest = current;
454
+ }
455
+ }
456
+ return highest;
329
457
  }
330
458
 
331
459
 
@@ -333,9 +461,6 @@ export class FakeDriveMeta {
333
461
  return notYetImplemented('getResourceKey')
334
462
  }
335
463
 
336
- setSharing() {
337
- return notYetImplemented('setSharing')
338
- }
339
464
  getSecurityUpdateEligible() {
340
465
  return notYetImplemented('getSecurityUpdateEligible')
341
466
  }
@@ -0,0 +1,19 @@
1
+ import { newFakeGasenum } from "@mcpher/fake-gasenum";
2
+
3
+ export const Access = newFakeGasenum([
4
+ "ANYONE",
5
+ "ANYONE_WITH_LINK",
6
+ "DOMAIN",
7
+ "DOMAIN_WITH_LINK",
8
+ "PRIVATE"
9
+ ])
10
+
11
+ export const Permission = newFakeGasenum([
12
+ "COMMENT",
13
+ "EDIT",
14
+ "NONE",
15
+ "ORGANIZER",
16
+ "OWNER",
17
+ "READ",
18
+ "VIEW"
19
+ ])
@@ -627,8 +627,73 @@ export class FakeForm {
627
627
  * @returns {string} The form URL.
628
628
  */
629
629
  getPublishedUrl() {
630
- return `https://docs.google.com/forms/d/e/${this.getId()}/viewform`;
630
+ return `https://docs.google.com/forms/d/${this.getId()}/viewform`;
631
631
  }
632
+
633
+ /**
634
+ * Internal method to get the correct responder URI from the API resource.
635
+ * @returns {string} The responder URI.
636
+ */
637
+ __getResponderUri() {
638
+ return this.__resource.responderUri;
639
+ }
640
+
641
+ /**
642
+ * Internal method to get scraped metadata from the published form with caching.
643
+ * @returns {object} The scraped metadata or null.
644
+ */
645
+ __getScrapedMetadata() {
646
+ if (this.__scrapedMetadata) return this.__scrapedMetadata;
647
+
648
+ const formFile = this.__file;
649
+
650
+ // 1. Capture original state
651
+ const originalAccess = formFile.getSharingAccess();
652
+ const originalPermission = formFile.getSharingPermission();
653
+
654
+ try {
655
+ // 2. Open access temporarily
656
+ formFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
657
+
658
+ // 3. Fetch the HTML
659
+ const publishUrl = this.getPublishedUrl();
660
+ const formHtml = UrlFetchApp.fetch(publishUrl).getContentText();
661
+
662
+ const metadata = { entryMap: {} };
663
+ const fbzxMatch = formHtml.match(/name="fbzx" value="([^"]+)"/);
664
+ if (fbzxMatch) metadata.fbzx = fbzxMatch[1];
665
+
666
+ const historyMatch = formHtml.match(/name="pageHistory" value="([^"]+)"/);
667
+ if (historyMatch) metadata.pageHistory = historyMatch[1];
668
+
669
+ // Scrape entry IDs. They are usually in a script block like FB_PUBLIC_LOAD_DATA_
670
+ // or just search for entry.ID in the HTML.
671
+ // A common pattern is [123456789, "Question Title", ...]
672
+ const entryMatches = formHtml.matchAll(/entry\.(\d+)/g);
673
+ for (const match of entryMatches) {
674
+ // This is a bit crude but might help identify all available entry IDs
675
+ metadata.entryMap[match[1]] = true;
676
+ }
677
+
678
+ this.__scrapedMetadata = metadata;
679
+ return metadata;
680
+
681
+ } catch (e) {
682
+ console.warn(`[gas-fakes] Failed to fetch form metadata: ${e.message}`);
683
+ return null;
684
+ } finally {
685
+ // 4. Reset to original state
686
+ formFile.setSharing(originalAccess, originalPermission);
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Internal method to clear the scraped metadata cache.
692
+ */
693
+ __clearScrapedMetadata() {
694
+ this.__scrapedMetadata = null;
695
+ }
696
+
632
697
  /**
633
698
  * Gets the URL to respond to the form
634
699
  * https://github.com/brucemcpherson/gas-fakes/issues/111
@@ -645,6 +710,22 @@ export class FakeForm {
645
710
  return this.getPublishedUrl()
646
711
  }
647
712
 
713
+ /**
714
+ * Creates a new response to the form.
715
+ * @returns {import('./fakeformresponse.js').FakeFormResponse} The newly created form response.
716
+ */
717
+ createResponse() {
718
+ return newFakeFormResponse(this, { answers: {} });
719
+ }
720
+
721
+ /**
722
+ * Deletes all responses from the form.
723
+ * @returns {FakeForm} The form, for chaining.
724
+ */
725
+ deleteAllResponses() {
726
+ throw new Error('deleteAllResponses is not yet implemented in the fake environment because the Google Forms API v1 does not support deleting responses.');
727
+ }
728
+
648
729
  toString() {
649
730
  return 'Form';
650
731
  }
@@ -114,7 +114,9 @@ export class FakeFormItem {
114
114
  }
115
115
 
116
116
  getId() {
117
- return Utils.fromHex(this.__itemId);
117
+ const resource = this.__resource;
118
+ const hexId = resource.questionItem?.question?.questionId || this.__itemId;
119
+ return Utils.fromHex(hexId);
118
120
  }
119
121
 
120
122
  getIndex() {
@@ -1,6 +1,7 @@
1
1
  import { Proxies } from '../../support/proxies.js';
2
2
  import { newFakeItemResponse } from './fakeitemresponse.js';
3
3
  import { Utils } from '../../support/utils.js';
4
+ import { formsCacher } from '../../support/formscacher.js';
4
5
 
5
6
  export const newFakeFormResponse = (...args) => {
6
7
  return Proxies.guard(new FakeFormResponse(...args));
@@ -20,7 +21,9 @@ export class FakeFormResponse {
20
21
  this.__form = form;
21
22
  this.__resource = resource;
22
23
  }
23
-
24
+ toString() {
25
+ return 'FormResponse';
26
+ }
24
27
  /**
25
28
  * Gets the email address of the respondent.
26
29
  * @returns {string} the respondent's email
@@ -83,4 +86,122 @@ export class FakeFormResponse {
83
86
  // Finally, sort the responses based on the item's index in the form.
84
87
  return itemResponses.sort((a, b) => a.getItem().getIndex() - b.getItem().getIndex());
85
88
  }
86
- }
89
+
90
+ /**
91
+ * Adds an item response to this form response.
92
+ * @param {import('./fakeitemresponse.js').FakeItemResponse} itemResponse The item response to add.
93
+ * @returns {FakeFormResponse} This form response, for chaining.
94
+ */
95
+ withItemResponse(itemResponse) {
96
+ if (!this.__resource.answers) {
97
+ this.__resource.answers = {};
98
+ }
99
+
100
+ // itemResponse.__answers is the internal array of answers.
101
+ // In the resource, answers are keyed by questionId.
102
+ itemResponse.__answers.forEach(answer => {
103
+ this.__resource.answers[answer.questionId] = answer;
104
+ });
105
+
106
+ return this;
107
+ }
108
+
109
+ /**
110
+ * Submits the response.
111
+ * @returns {FakeFormResponse} This form response, for chaining.
112
+ */
113
+ submit() {
114
+ const formId = this.__form.getId();
115
+ const responderUri = this.__form.__getResponderUri();
116
+ const url = responderUri.replace('/viewform', '/formResponse');
117
+ const payload = {};
118
+
119
+ this.getItemResponses().forEach(itemResponse => {
120
+ const item = itemResponse.getItem();
121
+ const response = itemResponse.getResponse();
122
+ const itemType = item.getType().toString();
123
+
124
+ if ((itemType === 'CHECKBOX_GRID' || itemType === 'GRID') && Array.isArray(response)) {
125
+ const gridRows = item.__resource.questionGroupItem?.questions || [];
126
+
127
+ response.forEach((rowResponse, rowIndex) => {
128
+ if (rowResponse && (Array.isArray(rowResponse) ? rowResponse.length > 0 : true)) {
129
+ const rowQuestionIdHex = gridRows[rowIndex]?.questionId;
130
+ if (rowQuestionIdHex) {
131
+ const rowQuestionIdDecimal = Utils.fromHex(rowQuestionIdHex);
132
+ payload[`entry.${rowQuestionIdDecimal}`] = rowResponse;
133
+ }
134
+ }
135
+ });
136
+ } else {
137
+ const questionIdHex = item.__resource.questionItem?.question?.questionId;
138
+ if (questionIdHex) {
139
+ const questionIdDecimal = Utils.fromHex(questionIdHex);
140
+ payload[`entry.${questionIdDecimal}`] = response;
141
+ }
142
+ }
143
+ });
144
+
145
+ if (Object.keys(payload).length > 0) {
146
+ // Dynamic page history based on actual form structure
147
+ const pageCount = this.__form.getItems(FormApp.ItemType.PAGE_BREAK).length + 1;
148
+ payload.pageHistory = Array.from({length: pageCount}, (_, i) => i).join(',');
149
+ payload.fvv = '1';
150
+
151
+ // Use cached metadata if available
152
+ const metadata = this.__form.__getScrapedMetadata();
153
+ if (metadata?.fbzx) payload.fbzx = metadata.fbzx;
154
+
155
+ // Build the payload string manually to handle multiple values for the same key (checkboxes)
156
+ const payloadParts = [];
157
+ Object.keys(payload).forEach(key => {
158
+ const value = payload[key];
159
+ if (Array.isArray(value)) {
160
+ value.forEach(v => payloadParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`));
161
+ } else {
162
+ payloadParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
163
+ }
164
+ });
165
+ const payloadString = payloadParts.join('&');
166
+
167
+ // we need to do many things here to allowe access to the form as there are no formapp methods to add reponses.
168
+ // first save the current file permissions
169
+ const formFile = this.__form.__file;
170
+
171
+ // 1. Capture the original state
172
+ const originalAccess = formFile.getSharingAccess();
173
+ const originalPermission = formFile.getSharingPermission();
174
+ // temporarily make the form public so we can submit a response
175
+ // 2. Open access: Anyone with the link can view (needed for submission)
176
+ formFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
177
+
178
+ try {
179
+
180
+ // --- SUBMISSION LOGIC ---
181
+ const response = UrlFetchApp.fetch(url, {
182
+ method: 'post',
183
+ payload: payloadString,
184
+ contentType: 'application/x-www-form-urlencoded',
185
+ muteHttpExceptions: true
186
+ });
187
+
188
+ if (response?.getResponseCode() !== 200) {
189
+ throw new Error(`Failed to submit form response: ${response.getResponseCode()}`);
190
+ }
191
+
192
+ // Successful submission, clear the scraped metadata cache to get a fresh fbzx next time
193
+ this.__form.__clearScrapedMetadata();
194
+
195
+ } finally {
196
+ // 3. Reset to exactly how it was before
197
+ formFile.setSharing(originalAccess, originalPermission);
198
+ }
199
+ }
200
+
201
+ // Invalidate the form cache because a new response has been submitted
202
+ formsCacher.clear(formId);
203
+
204
+
205
+ return this;
206
+ }
207
+ }
@@ -13,12 +13,23 @@ import { Proxies } from '../../support/proxies.js'
13
13
  * @return {FakeHTTPResponse} UrlFetchApp flavor
14
14
  */
15
15
  const responsify = (response) => {
16
+ if (!response) {
17
+ // Return a dummy response that identifies as an error
18
+ return {
19
+ getAllHeaders: () => ({}),
20
+ getResponseCode: () => 500,
21
+ getContentText: () => "UrlFetchApp: No response data available.",
22
+ getHeaders: () => ({}),
23
+ getBlob: () => null,
24
+ getContent: () => []
25
+ }
26
+ }
16
27
 
17
28
  // getAllHeaders() Object Returns an attribute/value map of headers for the HTTP response, with headers that have multiple values returned as arrays.
18
29
  const getAllHeaders = () => fixHeaders(response)
19
30
 
20
31
  // getResponseCode() Integer Get the HTTP status code (200 for OK, etc.) of an HTTP response
21
- const getResponseCode = () => response.statusCode
32
+ const getResponseCode = () => response.statusCode || response.status || 500
22
33
 
23
34
  // getContentText() String Gets the content of an HTTP response encoded as a string.
24
35
  const getContentText = () => blobify(response).getDataAsString()
@@ -197,6 +197,8 @@ const setAuth = async (scopes = [], mcpLoading = false) => {
197
197
 
198
198
  if (!tokenResponse.ok) {
199
199
  const errorText = await tokenResponse.text()
200
+ console.log ('... it looks like you forgot to enable domain-wide delegation for the service account')
201
+ console.log ('... rerun gas-fakes auth and check the instructions about enabling domain-wide delegation')
200
202
  throw new Error(`Failed to exchange JWT for token: ${errorText}`)
201
203
  }
202
204
 
@@ -333,6 +335,7 @@ export const responseSyncify = (result) => {
333
335
  if (!result) {
334
336
  return {
335
337
  status: 503, // Service Unavailable, a good representation for a worker-level failure
338
+ statusCode: 503,
336
339
  statusText: "Worker Error: No response object received from API call",
337
340
  error: {
338
341
  message: "Worker Error: No response object received from API call",
@@ -340,10 +343,15 @@ export const responseSyncify = (result) => {
340
343
  };
341
344
  }
342
345
  return {
343
- status: result.status,
346
+ status: result.status || result.statusCode,
347
+ statusCode: result.status || result.statusCode,
344
348
  statusText: result.statusText,
345
- responseUrl: result.request?.responseURL,
349
+ responseUrl: result.request?.responseURL || result.url,
346
350
  error: result.data?.error,
351
+ rawHeaders: result.rawHeaders,
352
+ headers: result.headers,
353
+ body: result.body,
354
+ rawBody: result.rawBody
347
355
  };
348
356
  };
349
357
 
@@ -24,7 +24,7 @@ export const getPermissionIterator = ({
24
24
  do {
25
25
  // if nothing in the tank, fill it upFdrive
26
26
  if (!tank.length) {
27
- const data = Drive.Permissions.list(id, { fields: "nextPageToken,permissions(role,type,emailAddress,photoLink,domain,displayName)" })
27
+ const data = Drive.Permissions.list(id, { fields: "nextPageToken,permissions(id,role,type,emailAddress,photoLink,domain,displayName,allowFileDiscovery)" })
28
28
  const { permissions, nextPageToken } = data
29
29
 
30
30
  // the presence of a nextPageToken is the signal that there's more to come
@@ -12,8 +12,9 @@ const fixOptions = (options) => {
12
12
  if (options) {
13
13
  fixedOptions = { ...options }
14
14
  Object.keys(fixedOptions).forEach(k => {
15
- if (k.match(/Content-Type/i)) {
16
- fixedOptions.contentType = fixedOptions[k]
15
+ if (k.match(/Content-Type|contentType/i)) {
16
+ fixedOptions.headers = fixedOptions.headers || {}
17
+ fixedOptions.headers['Content-Type'] = fixedOptions[k]
17
18
  delete fixedOptions[k]
18
19
  }
19
20
  if (k.match(/payload/i)) {
@@ -25,6 +26,23 @@ const fixOptions = (options) => {
25
26
  delete fixedOptions[k]
26
27
  }
27
28
  })
29
+
30
+ // Apps Script UrlFetchApp behavior:
31
+ // If the payload is an object and no content type is specified,
32
+ // it defaults to application/x-www-form-urlencoded.
33
+ if (fixedOptions.body && typeof fixedOptions.body === 'object' && !fixedOptions.contentType) {
34
+ // If it's a Buffer or Stream, we shouldn't convert it.
35
+ // But here we check for plain objects.
36
+ if (!(fixedOptions.body instanceof Buffer)) {
37
+ const params = new URLSearchParams();
38
+ for (const [key, value] of Object.entries(fixedOptions.body)) {
39
+ params.append(key, value);
40
+ }
41
+ fixedOptions.body = params.toString();
42
+ fixedOptions.headers = fixedOptions.headers || {};
43
+ fixedOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded';
44
+ }
45
+ }
28
46
  }
29
47
  return fixedOptions
30
48
  }
@@ -52,6 +70,17 @@ export const sxFetch = async (Auth, url, options, responseFields) => {
52
70
  const response = await got(url, {
53
71
  ...fixedOptions,
54
72
  responseType: 'buffer'
73
+ }).catch(err => {
74
+ if (err.response) {
75
+ const data = responseFields.reduce((p, c) => {
76
+ p[c] = err.response[c]
77
+ return p
78
+ }, {})
79
+ if (data.rawBody) data.rawBody = Array.from(data.rawBody);
80
+ if (data.body && Buffer.isBuffer(data.body)) data.body = Array.from(data.body);
81
+ err.data = data;
82
+ }
83
+ throw err;
55
84
  })
56
85
 
57
86
  // we cant return the response from this as it cant be serialized
@@ -93,4 +122,4 @@ export const sxFetchAll = async (Auth, requests, responseFields) => {
93
122
  if (!isString) delete options.url
94
123
  return sxFetch(Auth, url, options, responseFields)
95
124
  }))
96
- }
125
+ }
@@ -36,31 +36,47 @@ export const sxRetry = async (Auth, tag, func, options = {}) => {
36
36
  response = err.response;
37
37
  }
38
38
 
39
- const redoCodes = [429, 500, 503, 408, 401]
40
- const isRetryable = redoCodes.includes(error?.code) ||
41
- redoCodes.includes(response?.status) ||
42
- error?.code === 'ETIMEDOUT' ||
43
- error?.code === 'ECONNRESET' ||
44
- error?.cause?.code === 'ETIMEDOUT' ||
45
- error?.cause?.code === 'ECONNRESET' ||
46
- error?.message?.includes('ETIMEDOUT') ||
47
- error?.message?.includes('ECONNRESET') ||
48
- (response?.status === 403 && (
49
- error?.message?.toLowerCase().includes('usage limit') ||
50
- error?.message?.toLowerCase().includes('rate limit') ||
51
- error?.errors?.some(e => ['rateLimitExceeded', 'userRateLimitExceeded', 'calendarUsageLimitsExceeded'].includes(e.reason))
52
- )) ||
53
- extraRetryCheck(error, response);
39
+ const redoCodes = [429, 500, 503, 408, 401];
40
+ const networkErrorCodes = [
41
+ 'ETIMEDOUT',
42
+ 'ECONNRESET',
43
+ 'ENETDOWN',
44
+ 'ENETUNREACH',
45
+ 'ECONNREFUSED',
46
+ 'EPIPE',
47
+ 'EAI_AGAIN',
48
+ 'EHOSTUNREACH'
49
+ ];
50
+ const status = response?.status || response?.statusCode;
51
+
52
+ let retryReason = redoCodes.includes(error?.code) ? error.code :
53
+ redoCodes.includes(status) ? status :
54
+ networkErrorCodes.includes(error?.code) ? error.code :
55
+ networkErrorCodes.includes(error?.cause?.code) ? error.cause.code :
56
+ networkErrorCodes.find(code => error?.message?.includes(code));
57
+
58
+ if (!retryReason && status === 403 && (
59
+ error?.message?.toLowerCase().includes('usage limit') ||
60
+ error?.message?.toLowerCase().includes('rate limit') ||
61
+ error?.errors?.some(e => ['rateLimitExceeded', 'userRateLimitExceeded', 'calendarUsageLimitsExceeded'].includes(e.reason))
62
+ )) {
63
+ retryReason = 'Rate Limit';
64
+ }
65
+
66
+ const isRetryable = !!retryReason || extraRetryCheck(error, response);
67
+ if (isRetryable && !retryReason) retryReason = 'Extra Check';
54
68
 
55
69
  if (isRetryable && i < maxRetries - 1) {
56
- const isAuthError = error?.code === 401 || response?.status === 401;
70
+ const isAuthError = error?.code === 401 || status === 401;
57
71
  if (isAuthError) {
72
+ // Only retry auth error once
73
+ if (i > 0) break;
58
74
  Auth.invalidateToken();
59
75
  syncWarn(`Authentication error (401) on ${tag}. Invalidated token and retrying immediately...`);
60
76
  } else {
61
77
  const jitter = Math.floor(Math.random() * 1000);
62
78
  const totalDelay = delay + jitter;
63
- syncWarn(`Retryable error on ${tag} (status: ${response?.status || error?.code}). Retrying in ${totalDelay}ms...`);
79
+ syncWarn(`Retryable error on ${tag} (status: ${status || error?.code}, reason: ${retryReason}). Retrying in ${totalDelay}ms...`);
64
80
  await sleep(totalDelay);
65
81
  delay *= 2;
66
82
  }
@@ -68,10 +84,26 @@ export const sxRetry = async (Auth, tag, func, options = {}) => {
68
84
  }
69
85
 
70
86
  if (error) {
87
+ if (isRetryable && i === maxRetries - 1) {
88
+ // We've exhausted retries. Mark the error message to indicate this.
89
+ const msg = `Max retries reached (${maxRetries}) for reason ${retryReason}: ${error.message}`;
90
+ if (!response) {
91
+ response = {
92
+ status: 504,
93
+ statusText: msg,
94
+ data: { error: { message: msg } }
95
+ };
96
+ } else {
97
+ response.data = response.data || {};
98
+ response.data.error = response.data.error || { message: error.message };
99
+ response.data.error.message = `Max retries reached (${maxRetries}) for reason ${retryReason}: ${response.data.error.message}`;
100
+ }
101
+ }
102
+
71
103
  if (!skipLog(error, response)) {
72
104
  syncError(`Failed in ${tag}`, error);
73
105
  }
74
- return { data: null, response: responseSyncify(response) };
106
+ return { data: error.data || null, response: responseSyncify(response) };
75
107
  }
76
108
 
77
109
  return { data: response.data, response: responseSyncify(response) };