@sprucelabs/spruce-cli 24.1.1 → 24.1.3

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.
Files changed (25) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/build/__tests__/behavioral/tests/migrationToInstance/StaticToInstanceTestFileMigrator.test.d.ts +2 -0
  3. package/build/__tests__/behavioral/tests/migrationToInstance/StaticToInstanceTestFileMigrator.test.js +16 -0
  4. package/build/__tests__/behavioral/tests/migrationToInstance/StaticToInstanceTestFileMigrator.test.js.map +1 -1
  5. package/build/__tests__/behavioral/tests/migrationToInstance/support/AbstractInstanceTest2.txt +24 -0
  6. package/build/__tests__/behavioral/tests/migrationToInstance/support/AbstractStaticTest2.txt +24 -0
  7. package/build/__tests__/behavioral/tests/migrationToInstance/support/InstanceTest.txt +8 -0
  8. package/build/__tests__/behavioral/tests/migrationToInstance/support/InstanceTest3.txt +1 -1
  9. package/build/__tests__/behavioral/tests/migrationToInstance/support/InstanceTest4.txt +520 -0
  10. package/build/__tests__/behavioral/tests/migrationToInstance/support/StaticTest.txt +8 -0
  11. package/build/__tests__/behavioral/tests/migrationToInstance/support/StaticTest3.txt +1 -1
  12. package/build/__tests__/behavioral/tests/migrationToInstance/support/StaticTest4.txt +519 -0
  13. package/build/tests/staticToInstanceMigration/StaticToInstanceTestFileMigrator.js +17 -13
  14. package/build/tests/staticToInstanceMigration/StaticToInstanceTestFileMigrator.js.map +1 -1
  15. package/package.json +26 -26
  16. package/src/__tests__/behavioral/tests/migrationToInstance/StaticToInstanceTestFileMigrator.test.ts +16 -0
  17. package/src/__tests__/behavioral/tests/migrationToInstance/support/AbstractInstanceTest2.txt +24 -0
  18. package/src/__tests__/behavioral/tests/migrationToInstance/support/AbstractStaticTest2.txt +24 -0
  19. package/src/__tests__/behavioral/tests/migrationToInstance/support/InstanceTest.txt +8 -0
  20. package/src/__tests__/behavioral/tests/migrationToInstance/support/InstanceTest3.txt +1 -1
  21. package/src/__tests__/behavioral/tests/migrationToInstance/support/InstanceTest4.txt +520 -0
  22. package/src/__tests__/behavioral/tests/migrationToInstance/support/StaticTest.txt +8 -0
  23. package/src/__tests__/behavioral/tests/migrationToInstance/support/StaticTest3.txt +1 -1
  24. package/src/__tests__/behavioral/tests/migrationToInstance/support/StaticTest4.txt +519 -0
  25. package/src/tests/staticToInstanceMigration/StaticToInstanceTestFileMigrator.ts +23 -16
@@ -0,0 +1,519 @@
1
+ import {
2
+ FormViewController,
3
+ SkillViewControllerId,
4
+ buttonAssert,
5
+ formAssert,
6
+ interactor,
7
+ navigationAssert,
8
+ vcAssert,
9
+ } from '@sprucelabs/heartwood-view-controllers'
10
+ import { selectAssert } from '@sprucelabs/schema'
11
+ import { SelectChoice } from '@sprucelabs/spruce-core-schemas'
12
+ import { FormCardViewController } from '@sprucelabs/spruce-form-utils'
13
+ import { eventFaker, fake, seed } from '@sprucelabs/spruce-test-fixtures'
14
+ import { assert, generateId, test } from '@sprucelabs/test-utils'
15
+ import GenerateSkillViewController, {
16
+ CurrentChallengeSchema,
17
+ GenerateStorySchema,
18
+ } from '../../../generation/Generate.svc'
19
+ import { storyElements } from '../../../generation/storyElements'
20
+ import AbstractEightBitTest from '../../support/AbstractEightBitTest'
21
+ import {
22
+ GenerateStoryTargetAndPayload,
23
+ GetStoryStatusTargetAndPayload,
24
+ } from '../../support/EventFaker'
25
+
26
+ @fake.login()
27
+ export default class GenerateSkillViewTest extends AbstractEightBitTest {
28
+ private static vc: SpyGenerateSkillView
29
+ private static checkStatusIntervalCb: undefined | (() => Promise<void>)
30
+ private static checkStatusIntervalMs: number | undefined
31
+ private static intervalId: string
32
+ private static passedIntervalIdToClear?: string
33
+
34
+ @seed('familyMembers', 3)
35
+ protected static async beforeEach(): Promise<void> {
36
+ await super.beforeEach()
37
+
38
+ this.views.setController(
39
+ 'eightbitstories.generate',
40
+ SpyGenerateSkillView
41
+ )
42
+ this.views.setController('forms.card', SpyFormCard)
43
+
44
+ this.vc = this.Vc()
45
+
46
+ await this.eventFaker.fakeListFamilyMembers(() => this.members.find({}))
47
+ await this.loadVc()
48
+
49
+ delete this.checkStatusIntervalCb
50
+ delete this.checkStatusIntervalMs
51
+ delete this.passedIntervalIdToClear
52
+
53
+ this.intervalId = generateId()
54
+
55
+ //@ts-ignore
56
+ GenerateSkillViewController.setInterval = (
57
+ cb: () => Promise<void>,
58
+ intervalMs: number
59
+ ) => {
60
+ this.checkStatusIntervalMs = intervalMs
61
+ this.checkStatusIntervalCb = cb
62
+ return this.intervalId
63
+ }
64
+
65
+ //@ts-ignore
66
+ GenerateSkillViewController.clearInterval = (id: string) => {
67
+ this.passedIntervalIdToClear = id
68
+ }
69
+ }
70
+
71
+ @test()
72
+ protected static async requiresLogin() {
73
+ await vcAssert.assertLoginIsRequired(this.vc)
74
+ }
75
+
76
+ @test()
77
+ protected static async rendersExpectedCards() {
78
+ vcAssert.assertSkillViewRendersCards(this.vc, [
79
+ 'elements',
80
+ 'members',
81
+ 'currentChallenge',
82
+ 'controls',
83
+ ])
84
+ }
85
+
86
+ @test()
87
+ protected static async controlsCardRendersExpectedButtons() {
88
+ buttonAssert.cardRendersButtons(this.controlsVc, ['back', 'generate'])
89
+ }
90
+
91
+ @test()
92
+ protected static async rendersAlertAndRedirectsIfNoMembers() {
93
+ await this.eventFaker.fakeListFamilyMembers(() => [])
94
+ this.vc = this.Vc()
95
+ await vcAssert.assertRendersAlertThenRedirects({
96
+ vc: this.vc,
97
+ router: this.views.getRouter(),
98
+ destination: {
99
+ id: 'eightbitstories.root',
100
+ },
101
+ action: () => this.loadVc(),
102
+ })
103
+ }
104
+
105
+ @test()
106
+ protected static async clickingBackGoesBackToRoot() {
107
+ await vcAssert.assertActionRedirects({
108
+ action: () => interactor.clickButton(this.controlsVc, 'back'),
109
+ destination: {
110
+ id: 'eightbitstories.root',
111
+ },
112
+ router: this.views.getRouter(),
113
+ })
114
+ }
115
+
116
+ @test()
117
+ protected static elementsAndMembersCardsRendersForms() {
118
+ formAssert.cardRendersForm(this.elementsVc)
119
+ formAssert.cardRendersForm(this.membersVc)
120
+ formAssert.cardRendersForm(this.currentChallengeVc)
121
+ }
122
+
123
+ @test()
124
+ protected static async formCardsDoNotRenderButtons() {
125
+ assert.isFalse(this.elementsFormVc.getShouldRenderSubmitControls())
126
+ assert.isFalse(this.membersFormVc.getShouldRenderSubmitControls())
127
+ assert.isFalse(
128
+ this.currentChallengeFormVc.getShouldRenderSubmitControls()
129
+ )
130
+ }
131
+
132
+ @test()
133
+ protected static async elementsFormRendersExpectedFields() {
134
+ formAssert.formRendersFields(this.elementsFormVc, ['elements'])
135
+ }
136
+
137
+ @test()
138
+ protected static async elementsFormRendersExpectedChoices() {
139
+ const schema = this.elementsFormVc.getSchema()
140
+ selectAssert.assertSelectChoicesMatch(
141
+ schema.fields.elements.options.choices,
142
+ storyElements.map((element) => element.id)
143
+ )
144
+ }
145
+
146
+ @test()
147
+ protected static async rendersElementsAsTags() {
148
+ formAssert.formFieldRendersAs(this.elementsFormVc, 'elements', 'tags')
149
+ }
150
+
151
+ @test()
152
+ protected static async membersFormRendersExpectedFields() {
153
+ formAssert.formRendersFields(this.membersFormVc, ['members'])
154
+ }
155
+
156
+ @test()
157
+ protected static async membersFormRendersAsTags() {
158
+ formAssert.formFieldRendersAs(this.membersFormVc, 'members', 'tags')
159
+ }
160
+
161
+ @test()
162
+ protected static async membersRendersExpectedChoices() {
163
+ const members = await this.getAllMembers()
164
+ const expected = members.map((member) => member.id)
165
+
166
+ const schema = this.membersFormVc.getSchema()
167
+ selectAssert.assertSelectChoicesMatch(
168
+ schema.fields.members.options.choices as SelectChoice[],
169
+ expected
170
+ )
171
+ }
172
+
173
+ @test()
174
+ protected static async currentChallengeFormRendersAsExpected() {
175
+ formAssert.formRendersField(
176
+ this.currentChallengeFormVc,
177
+ 'currentChallenge'
178
+ )
179
+ formAssert.formFieldRendersAs(
180
+ this.currentChallengeFormVc,
181
+ 'currentChallenge',
182
+ 'textarea'
183
+ )
184
+ }
185
+
186
+ @test()
187
+ protected static async clickingGenerateSetsControlsToBusy() {
188
+ await this.eventFaker.fakeGenerateStory(() => {})
189
+
190
+ await this.selectFirstMember()
191
+ await this.selectFirstElement()
192
+
193
+ const promise = this.clickGenerateAndAssertRedirect()
194
+ this.assertFooterIsBusy()
195
+
196
+ await promise
197
+ }
198
+
199
+ @test()
200
+ protected static async rendersAlertIfFailsToGenerateStory() {
201
+ await eventFaker.makeEventThrow(
202
+ 'eightbitstories.generate-story::v2023_09_05'
203
+ )
204
+
205
+ const alertVc = await vcAssert.assertRendersAlert(this.vc, () =>
206
+ this.clickGenerate()
207
+ )
208
+
209
+ this.assertFooterIsBusy()
210
+
211
+ await alertVc.hide()
212
+
213
+ this.assertFooterIsNotBusy()
214
+ }
215
+
216
+ @test('submits selected members and elements 1', [0], [0])
217
+ @test('submits selected members and elements 2', [1], [2])
218
+ @test('submits selected members and elements 3', [0, 1], [2, 3])
219
+ protected static async generatePassesSelectedMembersAndElements(
220
+ memberIdxs: number[],
221
+ elementIdxs: number[]
222
+ ) {
223
+ let passedPayload: GenerateStoryTargetAndPayload['payload'] | undefined
224
+
225
+ await this.eventFaker.fakeGenerateStory(({ payload }) => {
226
+ passedPayload = payload
227
+ })
228
+
229
+ const selectedMembers = await this.selectMembers(memberIdxs)
230
+ const selectedElements = await this.selectElements(elementIdxs)
231
+
232
+ const currentChallenge = generateId()
233
+ await this.currentChallengeFormVc.setValue(
234
+ 'currentChallenge',
235
+ currentChallenge
236
+ )
237
+
238
+ await this.clickGenerateAndAssertRedirect()
239
+
240
+ assert.isEqualDeep(passedPayload, {
241
+ familyMembers: selectedMembers,
242
+ storyElements: selectedElements,
243
+ currentChallenge,
244
+ storyHash: this.vc.getHash(),
245
+ })
246
+ }
247
+
248
+ @test()
249
+ protected static async generatingStoryRedirectsToStoryWithArgs() {
250
+ await this.eventFaker.fakeGenerateStory()
251
+
252
+ await this.selectFirstElement()
253
+ await this.selectFirstMember()
254
+
255
+ const destination = {
256
+ id: 'eightbitstories.story' as SkillViewControllerId,
257
+ args: {
258
+ story: generateId(),
259
+ },
260
+ }
261
+
262
+ await this.clickGenerateAndAssertRedirect(destination)
263
+ }
264
+
265
+ @test()
266
+ protected static async callingDestroyRemovesDidGenerateListener() {
267
+ await this.vc.destroy()
268
+
269
+ await eventFaker.handleReactiveEvent(
270
+ 'eightbitstories.did-generate-story::v2023_09_05'
271
+ )
272
+
273
+ await this.emitDidGenerate()
274
+ }
275
+
276
+ @test()
277
+ protected static async rendersNullNavigation() {
278
+ navigationAssert.skillViewDoesNotRenderNavigation(this.vc)
279
+ }
280
+
281
+ @test()
282
+ protected static async checksForGeneratedStoryAfterSubmitting() {
283
+ let passedTarget: GetStoryStatusTargetAndPayload['target'] | undefined
284
+ await this.eventFaker.fakeGetStoryGenerationStatus(({ target }) => {
285
+ passedTarget = target
286
+ })
287
+
288
+ await this.fakeGenerateSelectEverythingClickGenerateAndInvokeIntervalCb()
289
+
290
+ assert.isEqualDeep(passedTarget, {
291
+ storyHash: this.vc.getHash(),
292
+ })
293
+ }
294
+
295
+ @test()
296
+ protected static async passesExpectedIntervalToChecksAfterSubmit() {
297
+ await this.eventFaker.fakeGenerateStory()
298
+ await this.selectElementFamilyMemberAndClickGenerate()
299
+ assert.isEqual(this.checkStatusIntervalMs, 1000 * 10)
300
+ }
301
+
302
+ @test()
303
+ protected static async doesNotSetIntervalIfGenerateThrows() {
304
+ await eventFaker.makeEventThrow(
305
+ 'eightbitstories.generate-story::v2023_09_05'
306
+ )
307
+
308
+ await vcAssert.assertRendersAlert(this.vc, () =>
309
+ this.selectElementFamilyMemberAndClickGenerate()
310
+ )
311
+ assert.isUndefined(
312
+ this.checkStatusIntervalCb,
313
+ 'should not have been set'
314
+ )
315
+ }
316
+
317
+ @test()
318
+ protected static async redirectsIfResponseIsStoryGenerated() {
319
+ const storyId = generateId()
320
+ await this.eventFaker.fakeGetStoryGenerationStatus(() => {
321
+ return {
322
+ status: 'ready',
323
+ storyId,
324
+ }
325
+ })
326
+
327
+ await this.fakeGenerateSelectEverythingAndClickGenerate()
328
+ await vcAssert.assertActionRedirects({
329
+ action: () => this.checkStatusIntervalCb?.(),
330
+ destination: {
331
+ id: 'eightbitstories.story',
332
+ args: {
333
+ story: storyId,
334
+ },
335
+ },
336
+ router: this.views.getRouter(),
337
+ })
338
+ }
339
+
340
+ @test()
341
+ protected static async clearsTimeoutOnBlur() {
342
+ await this.fakeGenerateSelectEverythingAndClickGenerate()
343
+ assert.isFalsy(this.passedIntervalIdToClear)
344
+ await interactor.blur(this.vc)
345
+ assert.isEqual(
346
+ this.passedIntervalIdToClear,
347
+ this.intervalId,
348
+ 'did not pass response to setInterval to clearInterval'
349
+ )
350
+ }
351
+
352
+ private static async fakeGenerateSelectEverythingClickGenerateAndInvokeIntervalCb() {
353
+ await this.fakeGenerateSelectEverythingAndClickGenerate()
354
+ await this.checkStatusIntervalCb?.()
355
+ }
356
+
357
+ private static async fakeGenerateSelectEverythingAndClickGenerate() {
358
+ await this.eventFaker.fakeGenerateStory()
359
+ await this.selectElementFamilyMemberAndClickGenerate()
360
+ }
361
+
362
+ private static async selectElementFamilyMemberAndClickGenerate() {
363
+ await this.selectFirstElement()
364
+ await this.selectFirstMember()
365
+ await this.clickGenerate()
366
+ }
367
+
368
+ private static async clickGenerateAndAssertRedirect(destination?: {
369
+ id: SkillViewControllerId
370
+ args: { story: string }
371
+ }) {
372
+ await vcAssert.assertActionRedirects({
373
+ action: async () => {
374
+ await this.clickGenerate()
375
+ await this.emitDidGenerate(destination?.args?.story)
376
+ },
377
+ router: this.views.getRouter(),
378
+ destination,
379
+ })
380
+ }
381
+
382
+ private static async emitDidGenerate(storyId?: string) {
383
+ await this.fakedClient.emitAndFlattenResponses(
384
+ 'eightbitstories.did-generate-story::v2023_09_05',
385
+ {
386
+ target: {
387
+ personId: generateId(),
388
+ },
389
+ payload: {
390
+ storyId: storyId ?? generateId(),
391
+ },
392
+ }
393
+ )
394
+ }
395
+
396
+ private static async selectFirstElement() {
397
+ const selectedElement = await this.selectElement(0)
398
+ return selectedElement
399
+ }
400
+
401
+ private static async selectElement(idx: number) {
402
+ const selectedElements = await this.selectElements([idx])
403
+ return selectedElements[0]
404
+ }
405
+
406
+ private static async selectElements(allIdxs: number[]) {
407
+ const selectedElements = allIdxs.map((idx) => storyElements[idx].id)
408
+ await this.elementsFormVc.setValue('elements', selectedElements)
409
+ return selectedElements
410
+ }
411
+
412
+ private static async selectFirstMember() {
413
+ const selectedMember = await this.selectMember(0)
414
+ return selectedMember
415
+ }
416
+
417
+ private static async selectMember(idx: number) {
418
+ const selectedMembers = await this.selectMembers([idx])
419
+ return selectedMembers[0]
420
+ }
421
+
422
+ private static async selectMembers(allIdxs: number[]) {
423
+ const members = await this.getAllMembers()
424
+ const selectedMembers = allIdxs.map((idx) => members[idx].id)
425
+ await this.membersFormVc.setValue('members', selectedMembers as any)
426
+ return selectedMembers
427
+ }
428
+
429
+ private static async getAllMembers() {
430
+ return await this.members.find({})
431
+ }
432
+
433
+ private static assertFooterIsNotBusy() {
434
+ assert.isFalse(this.getIsFooterBusy())
435
+ }
436
+
437
+ private static assertFooterIsBusy() {
438
+ assert.isTrue(this.getIsFooterBusy())
439
+ }
440
+
441
+ private static getIsFooterBusy(): boolean | null | undefined {
442
+ return this.controlsVc.getFooter()?.isBusy
443
+ }
444
+
445
+ private static async clickGenerate() {
446
+ await interactor.clickButton(this.controlsVc, 'generate')
447
+ }
448
+
449
+ private static get membersFormVc() {
450
+ return this.vc.getMembersFormVc()
451
+ }
452
+
453
+ private static get elementsFormVc() {
454
+ return this.vc.getElementsFormVc()
455
+ }
456
+
457
+ private static async loadVc() {
458
+ await this.views.load(this.vc)
459
+ }
460
+
461
+ private static get membersVc() {
462
+ return this.vc.getMembersVc()
463
+ }
464
+
465
+ private static get currentChallengeVc() {
466
+ return this.vc.getCurrentChallengeVc()
467
+ }
468
+
469
+ private static get currentChallengeFormVc() {
470
+ return this.currentChallengeVc.getFormVc() as FormViewController<CurrentChallengeSchema>
471
+ }
472
+
473
+ private static get elementsVc() {
474
+ return this.vc.getElementsVc()
475
+ }
476
+
477
+ private static get controlsVc() {
478
+ return this.vc.getControlsCardVc()
479
+ }
480
+
481
+ private static Vc(): SpyGenerateSkillView {
482
+ return this.views.Controller(
483
+ 'eightbitstories.generate',
484
+ {}
485
+ ) as SpyGenerateSkillView
486
+ }
487
+ }
488
+
489
+ class SpyGenerateSkillView extends GenerateSkillViewController {
490
+ public getHash() {
491
+ return this.storyHash!
492
+ }
493
+
494
+ public getCurrentChallengeVc() {
495
+ return this.currentChallengeVc
496
+ }
497
+
498
+ public getMembersFormVc() {
499
+ return this.getMembersVc().getFormVc() as FormViewController<GenerateStorySchema>
500
+ }
501
+ public getElementsFormVc() {
502
+ return this.getElementsVc().getFormVc() as FormViewController<GenerateStorySchema>
503
+ }
504
+ public getElementsVc() {
505
+ return this.elementsVc as SpyFormCard
506
+ }
507
+ public getMembersVc() {
508
+ return this.membersVc as SpyFormCard
509
+ }
510
+ public getControlsCardVc() {
511
+ return this.controlsVc
512
+ }
513
+ }
514
+
515
+ class SpyFormCard extends FormCardViewController {
516
+ public getFormVc() {
517
+ return this.formVc
518
+ }
519
+ }
@@ -16,19 +16,21 @@ export default class StaticToInstanceTestFileMigratorImpl
16
16
  // that has the `@test()` decorator
17
17
  // 1b. If the contents include `export default abstract class`,
18
18
  // remove `static` from all methods
19
- const includesAbstractExport = contents.includes(
20
- 'export default abstract class'
21
- )
22
- let cleanedUp = includesAbstractExport
19
+ const isAbstractTest =
20
+ /export\s+default\s+(?:abstract\s+class\b|class\s+(Abstract\w*))/m.test(
21
+ contents
22
+ )
23
+
24
+ let cleanedUp = isAbstractTest
23
25
  ? contents.replaceAll(' static ', ' ')
24
26
  : contents.replace(
25
27
  // Matches @test() or @seed(...) followed (on next line) by optional visibility and `static`.
26
- /(@(?:test\(\)|seed\([^)]*\))\s*\n\s*(?:public|protected)\s+)static\s+/g,
28
+ /(@(?:(?:test|seed)\([\s\S]*?\))\s*\n\s*(?:public|protected)\s+)static\s+/g,
27
29
  '$1'
28
30
  )
29
31
 
30
32
  // 2. Add `@suite()` above `export default class` if it's not already present
31
- if (!cleanedUp.includes('@suite')) {
33
+ if (!isAbstractTest && !cleanedUp.includes('@suite')) {
32
34
  cleanedUp = cleanedUp.replace(
33
35
  /export default class/,
34
36
  '@suite()\nexport default class'
@@ -55,13 +57,16 @@ export default class StaticToInstanceTestFileMigratorImpl
55
57
  const methods = ['beforeEach', 'afterEach']
56
58
  for (const method of methods) {
57
59
  cleanedUp = cleanedUp.replace(
58
- `protected static async ${method}()`,
59
- `protected async ${method}()`
60
+ `static async ${method}()`,
61
+ `async ${method}()`
60
62
  )
61
63
  }
62
64
 
63
65
  cleanedUp = this.fixNonNullAssertions(cleanedUp)
64
66
 
67
+ cleanedUp = cleanedUp.replaceAll('= >', '=>')
68
+ cleanedUp = cleanedUp.replaceAll('! =', ' =')
69
+
65
70
  return cleanedUp
66
71
  }
67
72
 
@@ -73,8 +78,9 @@ export default class StaticToInstanceTestFileMigratorImpl
73
78
  }
74
79
 
75
80
  private findThisCalls(contents: string): string[] {
76
- // Matches `this.myProp` if followed by space, punctuation, parentheses, or end of string
77
- const thisPropertyRegex = /this\.(\w+)(?=[\s.(),;]|$)/g
81
+ // Matches either `this.myProp` or `delete this.myProp`
82
+ // if followed by space, punctuation, parentheses, or end of string
83
+ const thisPropertyRegex = /(?:delete\s+)?this\.(\w+)(?=[\s.(),;]|$)/g
78
84
  const names: string[] = []
79
85
  let match: RegExpExecArray | null
80
86
 
@@ -117,21 +123,22 @@ export default class StaticToInstanceTestFileMigratorImpl
117
123
  )
118
124
 
119
125
  /**
120
- * 2) Remove `static` from property declarations and add a non-null assertion.
126
+ * 2) Remove `static` from property declarations and add a non-null assertion
127
+ * if the property is not optional.
121
128
  * e.g.
122
129
  * private static myProp: Type => private myProp!: Type
130
+ * private static passedIntervalIdToClear?: string => private passedIntervalIdToClear?: string
123
131
  */
124
132
  const propertyPattern = new RegExp(
125
133
  `((?:public|protected|private)?\\s+)?` + // group 1: optional visibility
126
134
  `static\\s+` + // literal "static "
127
- `(${name})` + // group 2: the property name
135
+ `(${name})(\\?)?` + // group 2: property name, group 3: optional "?"
128
136
  `(?=[\\s=:\\[;]|$)`, // lookahead: space, '=', ':', '[', ';', or end-of-string
129
137
  'g'
130
138
  )
131
- updated = updated.replace(propertyPattern, (match, g1, g2) => {
132
- // g1 = "private " / "public " / "protected " or empty
133
- // g2 = property name
134
- return `${g1 ?? ''}${g2}!`
139
+ updated = updated.replace(propertyPattern, (match, g1, g2, g3) => {
140
+ // If the property is optional (g3 is "?"), leave it. Otherwise, add a non-null assertion.
141
+ return `${g1 ?? ''}${g2}${g3 !== undefined ? g3 : '!'}`
135
142
  })
136
143
 
137
144
  return updated