opensoma 0.5.0 → 0.6.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.
Files changed (133) hide show
  1. package/dist/package.json +5 -1
  2. package/dist/src/agent-browser-launcher.d.ts +43 -0
  3. package/dist/src/agent-browser-launcher.d.ts.map +1 -0
  4. package/dist/src/agent-browser-launcher.js +97 -0
  5. package/dist/src/agent-browser-launcher.js.map +1 -0
  6. package/dist/src/cli.d.ts.map +1 -1
  7. package/dist/src/cli.js +3 -2
  8. package/dist/src/cli.js.map +1 -1
  9. package/dist/src/client.d.ts +36 -7
  10. package/dist/src/client.d.ts.map +1 -1
  11. package/dist/src/client.js +231 -63
  12. package/dist/src/client.js.map +1 -1
  13. package/dist/src/commands/agent-browser.d.ts +3 -0
  14. package/dist/src/commands/agent-browser.d.ts.map +1 -0
  15. package/dist/src/commands/agent-browser.js +27 -0
  16. package/dist/src/commands/agent-browser.js.map +1 -0
  17. package/dist/src/commands/auth.d.ts +1 -1
  18. package/dist/src/commands/auth.d.ts.map +1 -1
  19. package/dist/src/commands/auth.js +4 -2
  20. package/dist/src/commands/auth.js.map +1 -1
  21. package/dist/src/commands/dashboard.d.ts +13 -0
  22. package/dist/src/commands/dashboard.d.ts.map +1 -1
  23. package/dist/src/commands/dashboard.js +10 -18
  24. package/dist/src/commands/dashboard.js.map +1 -1
  25. package/dist/src/commands/helpers.d.ts +1 -1
  26. package/dist/src/commands/helpers.d.ts.map +1 -1
  27. package/dist/src/commands/helpers.js +2 -2
  28. package/dist/src/commands/helpers.js.map +1 -1
  29. package/dist/src/commands/index.d.ts +2 -1
  30. package/dist/src/commands/index.d.ts.map +1 -1
  31. package/dist/src/commands/index.js +2 -1
  32. package/dist/src/commands/index.js.map +1 -1
  33. package/dist/src/commands/mentoring.d.ts.map +1 -1
  34. package/dist/src/commands/mentoring.js +54 -29
  35. package/dist/src/commands/mentoring.js.map +1 -1
  36. package/dist/src/commands/notice.d.ts.map +1 -1
  37. package/dist/src/commands/notice.js +2 -1
  38. package/dist/src/commands/notice.js.map +1 -1
  39. package/dist/src/commands/report.d.ts.map +1 -1
  40. package/dist/src/commands/report.js +4 -2
  41. package/dist/src/commands/report.js.map +1 -1
  42. package/dist/src/commands/room.d.ts.map +1 -1
  43. package/dist/src/commands/room.js +125 -2
  44. package/dist/src/commands/room.js.map +1 -1
  45. package/dist/src/commands/schedule.d.ts +3 -0
  46. package/dist/src/commands/schedule.d.ts.map +1 -0
  47. package/dist/src/commands/schedule.js +27 -0
  48. package/dist/src/commands/schedule.js.map +1 -0
  49. package/dist/src/commands/team.d.ts.map +1 -1
  50. package/dist/src/commands/team.js +55 -4
  51. package/dist/src/commands/team.js.map +1 -1
  52. package/dist/src/constants.d.ts +5 -5
  53. package/dist/src/constants.d.ts.map +1 -1
  54. package/dist/src/constants.js +20 -8
  55. package/dist/src/constants.js.map +1 -1
  56. package/dist/src/credential-manager.d.ts +9 -0
  57. package/dist/src/credential-manager.d.ts.map +1 -1
  58. package/dist/src/credential-manager.js +24 -0
  59. package/dist/src/credential-manager.js.map +1 -1
  60. package/dist/src/formatters.d.ts +11 -3
  61. package/dist/src/formatters.d.ts.map +1 -1
  62. package/dist/src/formatters.js +281 -52
  63. package/dist/src/formatters.js.map +1 -1
  64. package/dist/src/http.d.ts +8 -0
  65. package/dist/src/http.d.ts.map +1 -1
  66. package/dist/src/http.js +29 -1
  67. package/dist/src/http.js.map +1 -1
  68. package/dist/src/index.d.ts +4 -1
  69. package/dist/src/index.d.ts.map +1 -1
  70. package/dist/src/index.js +2 -1
  71. package/dist/src/index.js.map +1 -1
  72. package/dist/src/shared/utils/swmaestro.d.ts +34 -1
  73. package/dist/src/shared/utils/swmaestro.d.ts.map +1 -1
  74. package/dist/src/shared/utils/swmaestro.js +102 -43
  75. package/dist/src/shared/utils/swmaestro.js.map +1 -1
  76. package/dist/src/shared/utils/team-action-params.d.ts +3 -0
  77. package/dist/src/shared/utils/team-action-params.d.ts.map +1 -0
  78. package/dist/src/shared/utils/team-action-params.js +10 -0
  79. package/dist/src/shared/utils/team-action-params.js.map +1 -0
  80. package/dist/src/shared/utils/team-params.d.ts +12 -0
  81. package/dist/src/shared/utils/team-params.d.ts.map +1 -0
  82. package/dist/src/shared/utils/team-params.js +38 -0
  83. package/dist/src/shared/utils/team-params.js.map +1 -0
  84. package/dist/src/types.d.ts +147 -10
  85. package/dist/src/types.d.ts.map +1 -1
  86. package/dist/src/types.js +74 -6
  87. package/dist/src/types.js.map +1 -1
  88. package/package.json +5 -1
  89. package/src/agent-browser-launcher.test.ts +263 -0
  90. package/src/agent-browser-launcher.ts +159 -0
  91. package/src/cli.ts +4 -2
  92. package/src/client.test.ts +801 -140
  93. package/src/client.ts +293 -79
  94. package/src/commands/agent-browser.ts +33 -0
  95. package/src/commands/auth.test.ts +83 -32
  96. package/src/commands/auth.ts +5 -3
  97. package/src/commands/dashboard.test.ts +57 -0
  98. package/src/commands/dashboard.ts +22 -19
  99. package/src/commands/helpers.test.ts +79 -32
  100. package/src/commands/helpers.ts +3 -3
  101. package/src/commands/index.ts +2 -1
  102. package/src/commands/mentoring.ts +60 -29
  103. package/src/commands/notice.ts +2 -1
  104. package/src/commands/report.test.ts +7 -7
  105. package/src/commands/report.ts +4 -2
  106. package/src/commands/room.ts +160 -1
  107. package/src/commands/schedule.ts +32 -0
  108. package/src/commands/team.ts +73 -5
  109. package/src/constants.ts +20 -8
  110. package/src/credential-manager.test.ts +49 -5
  111. package/src/credential-manager.ts +27 -0
  112. package/src/formatters.test.ts +548 -53
  113. package/src/formatters.ts +309 -55
  114. package/src/http.test.ts +108 -39
  115. package/src/http.ts +41 -2
  116. package/src/index.ts +10 -1
  117. package/src/shared/utils/mentoring-params.test.ts +16 -16
  118. package/src/shared/utils/swmaestro.test.ts +326 -11
  119. package/src/shared/utils/swmaestro.ts +150 -52
  120. package/src/shared/utils/team-action-params.test.ts +32 -0
  121. package/src/shared/utils/team-action-params.ts +10 -0
  122. package/src/shared/utils/team-params.test.ts +141 -0
  123. package/src/shared/utils/team-params.ts +53 -0
  124. package/src/shared/utils/toz.test.ts +12 -7
  125. package/src/token-extractor.test.ts +12 -12
  126. package/src/toz-http.test.ts +11 -11
  127. package/src/types.test.ts +235 -206
  128. package/src/types.ts +87 -7
  129. package/dist/src/commands/event.d.ts +0 -3
  130. package/dist/src/commands/event.d.ts.map +0 -1
  131. package/dist/src/commands/event.js +0 -58
  132. package/dist/src/commands/event.js.map +0 -1
  133. package/src/commands/event.ts +0 -73
package/src/http.test.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { afterEach, describe, expect, mock, test } from 'bun:test'
1
+ import { afterEach, describe, expect, it, mock } from 'bun:test'
2
2
 
3
3
  import { MENU_NO } from './constants'
4
4
  import { AuthenticationError } from './errors'
5
- import { SomaHttp } from './http'
5
+ import { SomaHttp, UserGb } from './http'
6
6
 
7
7
  const originalFetch = globalThis.fetch
8
8
 
@@ -12,7 +12,7 @@ afterEach(() => {
12
12
  })
13
13
 
14
14
  describe('SomaHttp', () => {
15
- test('get sends query params and stores cookies', async () => {
15
+ it('sends query params on GET and stores returned cookies', async () => {
16
16
  const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
17
17
  expect(String(input)).toBe(`https://www.swmaestro.ai/sw/member/user/forLogin.do?menuNo=${MENU_NO.LOGIN}`)
18
18
  expect(init?.headers).toEqual({
@@ -32,7 +32,7 @@ describe('SomaHttp', () => {
32
32
  expect(fetchMock).toHaveBeenCalledTimes(1)
33
33
  })
34
34
 
35
- test('post encodes body and injects csrf token', async () => {
35
+ it('encodes the POST body and injects the CSRF token', async () => {
36
36
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
37
37
  expect(init?.method).toBe('POST')
38
38
  expect(init?.headers).toEqual({
@@ -53,7 +53,7 @@ describe('SomaHttp', () => {
53
53
  expect(html).toBe('<html>ok</html>')
54
54
  })
55
55
 
56
- test('post surfaces alert errors from script tags with attributes', async () => {
56
+ it('surfaces alert() errors from script tags that have attributes', async () => {
57
57
  const fetchMock = mock(async () =>
58
58
  createResponse(
59
59
  `<html><head><title>빈페이지</title></head><body><script type='text/javascript'>alert('아이디 혹은 비밀번호가 일치 하지 않습니다.');location.href='/login';</script></body></html>`,
@@ -68,7 +68,7 @@ describe('SomaHttp', () => {
68
68
  )
69
69
  })
70
70
 
71
- test('post surfaces alert errors followed by history.back()', async () => {
71
+ it('surfaces alert() errors that are followed by history.back()', async () => {
72
72
  const fetchMock = mock(async () =>
73
73
  createResponse(
74
74
  `<html><head></head><body><script language='JavaScript'>\nalert('잘못된 접근입니다.');\nhistory.back();\n</script></body></html>`,
@@ -81,7 +81,47 @@ describe('SomaHttp', () => {
81
81
  await expect(http.post('/mypage/test.do', {})).rejects.toThrow('잘못된 접근입니다.')
82
82
  })
83
83
 
84
- test('post ignores alert() inside function bodies (form validation scripts)', async () => {
84
+ it('treats success-indicator alerts (정상적으로 등록하였습니다) as non-errors', async () => {
85
+ const fetchMock = mock(async () =>
86
+ createResponse(
87
+ `<html><body><script type='text/javascript'>alert('정상적으로 등록하였습니다.');location.href='/sw/mypage/itemRent/list.do';</script></body></html>`,
88
+ ),
89
+ )
90
+ globalThis.fetch = fetchMock as typeof fetch
91
+
92
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
93
+
94
+ const html = await http.post('/mypage/itemRent/insert.do', {})
95
+ expect(html).toContain('정상적으로 등록하였습니다')
96
+ })
97
+
98
+ it('treats success-indicator alerts (완료되었습니다) as non-errors', async () => {
99
+ const fetchMock = mock(async () =>
100
+ createResponse(
101
+ `<html><body><script>alert('예약이 완료되었습니다.');location.href='/sw/mypage/itemRent/list.do';</script></body></html>`,
102
+ ),
103
+ )
104
+ globalThis.fetch = fetchMock as typeof fetch
105
+
106
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
107
+
108
+ await expect(http.post('/mypage/itemRent/insert.do', {})).resolves.toBeDefined()
109
+ })
110
+
111
+ it('still surfaces non-success alerts when followed by location.href', async () => {
112
+ const fetchMock = mock(async () =>
113
+ createResponse(
114
+ `<html><body><script>alert('이미 예약된 시간입니다.');location.href='/sw/mypage/itemRent/list.do';</script></body></html>`,
115
+ ),
116
+ )
117
+ globalThis.fetch = fetchMock as typeof fetch
118
+
119
+ const http = new SomaHttp({ sessionCookie: 'session-1', csrfToken: 'csrf-1' })
120
+
121
+ await expect(http.post('/mypage/itemRent/insert.do', {})).rejects.toThrow('이미 예약된 시간입니다.')
122
+ })
123
+
124
+ it('ignores alert() calls nested inside function bodies (form validation scripts)', async () => {
85
125
  const pageWithValidationScript = `<html><head><title>AI·SW마에스트로 서울</title></head><body>
86
126
  <ul class="bbs-reserve"><li class="item">room data</li></ul>
87
127
  <script>
@@ -106,7 +146,7 @@ describe('SomaHttp', () => {
106
146
  })
107
147
 
108
148
  describe('postMultipart', () => {
109
- test('passes FormData to fetch', async () => {
149
+ it('passes the FormData instance through to fetch', async () => {
110
150
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
111
151
  expect(init?.body).toBeInstanceOf(FormData)
112
152
  return createResponse('<html>ok</html>')
@@ -118,7 +158,7 @@ describe('SomaHttp', () => {
118
158
  await expect(http.postMultipart('/test', new FormData())).resolves.toBe('<html>ok</html>')
119
159
  })
120
160
 
121
- test('does not set Content-Type manually', async () => {
161
+ it('does not set the Content-Type header manually (lets fetch set the boundary)', async () => {
122
162
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
123
163
  expect(init?.headers).toEqual({
124
164
  'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
@@ -139,7 +179,7 @@ describe('SomaHttp', () => {
139
179
  await http.postMultipart('/test', new FormData())
140
180
  })
141
181
 
142
- test('appends csrf token to FormData', async () => {
182
+ it('appends the CSRF token to the FormData body', async () => {
143
183
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
144
184
  const body = init?.body
145
185
  expect(body).toBeInstanceOf(FormData)
@@ -153,7 +193,7 @@ describe('SomaHttp', () => {
153
193
  await http.postMultipart('/test', new FormData())
154
194
  })
155
195
 
156
- test('follows redirects manually', async () => {
196
+ it('follows redirects manually after a multipart POST', async () => {
157
197
  const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
158
198
  const url = String(input)
159
199
 
@@ -189,7 +229,7 @@ describe('SomaHttp', () => {
189
229
  expect(fetchMock).toHaveBeenCalledTimes(2)
190
230
  })
191
231
 
192
- test('keeps existing post() behavior unchanged', async () => {
232
+ it('keeps the existing post() behavior unchanged', async () => {
193
233
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
194
234
  expect(init?.method).toBe('POST')
195
235
  expect(init?.headers).toEqual({
@@ -211,7 +251,7 @@ describe('SomaHttp', () => {
211
251
  })
212
252
 
213
253
  describe('postForm', () => {
214
- test('converts Record to FormData and delegates to postMultipart', async () => {
254
+ it('converts a Record body to FormData and delegates to postMultipart', async () => {
215
255
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
216
256
  const body = init?.body
217
257
  expect(body).toBeInstanceOf(FormData)
@@ -230,7 +270,7 @@ describe('SomaHttp', () => {
230
270
  ).resolves.toBe('<html>ok</html>')
231
271
  })
232
272
 
233
- test('does not set Content-Type header (lets FormData set multipart boundary)', async () => {
273
+ it('does not set the Content-Type header so FormData can set the multipart boundary', async () => {
234
274
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
235
275
  const headers = init?.headers as Record<string, string> | undefined
236
276
  expect(headers?.['Content-Type']).toBeUndefined()
@@ -245,7 +285,7 @@ describe('SomaHttp', () => {
245
285
  })
246
286
  })
247
287
 
248
- test('postJson returns parsed json', async () => {
288
+ it('returns the parsed JSON body from postJson', async () => {
249
289
  const fetchMock = mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
250
290
  expect(init?.headers).toEqual({
251
291
  'Accept-Language': 'ko,en-US;q=0.9,en;q=0.8',
@@ -267,7 +307,7 @@ describe('SomaHttp', () => {
267
307
  expect(json).toEqual({ resultCode: 'SUCCESS' })
268
308
  })
269
309
 
270
- test('login fetches csrf token then posts credentials', async () => {
310
+ it('fetches the CSRF token before posting credentials during login', async () => {
271
311
  const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => {
272
312
  const url = String(input)
273
313
 
@@ -309,12 +349,17 @@ describe('SomaHttp', () => {
309
349
  expect(http.getCsrfToken()).toBe('csrf-login')
310
350
  })
311
351
 
312
- test('checkLogin returns user identity when logged in, null otherwise', async () => {
352
+ it('returns the user identity when logged in and null when not', async () => {
313
353
  const loggedInMock = mock(async (_input: RequestInfo | URL, _init?: RequestInit) =>
314
354
  createResponse(
315
355
  JSON.stringify({
316
356
  resultCode: 'fail',
317
- userVO: { userId: 'user@example.com', userNm: 'Test' },
357
+ userVO: {
358
+ userId: 'user@example.com',
359
+ userNm: 'Test',
360
+ userNo: 'a1b2c3',
361
+ userGb: 'T',
362
+ },
318
363
  }),
319
364
  [],
320
365
  'application/json',
@@ -325,6 +370,8 @@ describe('SomaHttp', () => {
325
370
  await expect(new SomaHttp().checkLogin()).resolves.toEqual({
326
371
  userId: 'user@example.com',
327
372
  userNm: 'Test',
373
+ userNo: 'a1b2c3',
374
+ userGb: UserGb.Mentor,
328
375
  })
329
376
  expect(loggedInMock).toHaveBeenCalledWith('https://www.swmaestro.ai/sw/member/user/checkLogin.json', {
330
377
  method: 'GET',
@@ -345,7 +392,29 @@ describe('SomaHttp', () => {
345
392
  await expect(new SomaHttp().checkLogin()).resolves.toBeNull()
346
393
  })
347
394
 
348
- test('checkLogin returns null when the server redirects to login', async () => {
395
+ it('maps trainee userGb responses to the UserGb enum', async () => {
396
+ globalThis.fetch = mock(async () =>
397
+ createResponse(
398
+ JSON.stringify({
399
+ resultCode: 'fail',
400
+ userVO: {
401
+ userId: 'trainee@example.com',
402
+ userNm: 'Trainee',
403
+ userNo: 'u-1',
404
+ userGb: 'C',
405
+ },
406
+ }),
407
+ [],
408
+ 'application/json',
409
+ ),
410
+ ) as typeof fetch
411
+
412
+ await expect(new SomaHttp().checkLogin()).resolves.toMatchObject({
413
+ userGb: UserGb.Trainee,
414
+ })
415
+ })
416
+
417
+ it('returns null from checkLogin when the server redirects to the login page', async () => {
349
418
  const fetchMock = mock(async () =>
350
419
  createResponse('', [], 'text/html', {
351
420
  status: 302,
@@ -357,7 +426,7 @@ describe('SomaHttp', () => {
357
426
  await expect(new SomaHttp({ sessionCookie: 'session-1' }).checkLogin()).resolves.toBeNull()
358
427
  })
359
428
 
360
- test('checkLogin returns null when the server serves login html instead of json', async () => {
429
+ it('returns null from checkLogin when the server serves login HTML instead of JSON', async () => {
361
430
  const fetchMock = mock(async () =>
362
431
  createResponse(
363
432
  '<html><head><title>AI·SW마에스트로</title></head><body><form><input name="username"><input name="password"></form></body></html>',
@@ -368,7 +437,7 @@ describe('SomaHttp', () => {
368
437
  await expect(new SomaHttp({ sessionCookie: 'session-1' }).checkLogin()).resolves.toBeNull()
369
438
  })
370
439
 
371
- test('logout calls logout endpoint', async () => {
440
+ it('calls the logout endpoint', async () => {
372
441
  const fetchMock = mock(async (input: RequestInfo | URL) => {
373
442
  expect(String(input)).toBe('https://www.swmaestro.ai/sw/member/user/logout.do')
374
443
  return createResponse('<html>bye</html>')
@@ -380,7 +449,7 @@ describe('SomaHttp', () => {
380
449
  expect(fetchMock).toHaveBeenCalledTimes(1)
381
450
  })
382
451
 
383
- test('extractCsrfToken reads hidden input', async () => {
452
+ it('reads the CSRF token from a hidden input field', async () => {
384
453
  const fetchMock = mock(async () => createResponse('<input type="hidden" name="csrfToken" value="csrf-token">'))
385
454
  globalThis.fetch = fetchMock as typeof fetch
386
455
 
@@ -397,7 +466,7 @@ describe('SomaHttp', () => {
397
466
 
398
467
  const expiredMessage = '잘못된 접근입니다. 해당 세션을 전체 초기화 하였습니다.'
399
468
 
400
- test('post throws AuthenticationError for session-expired alert', async () => {
469
+ it('throws AuthenticationError from post when alert indicates session expiry', async () => {
401
470
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert(expiredMessage)))
402
471
  globalThis.fetch = fetchMock as typeof fetch
403
472
 
@@ -406,7 +475,7 @@ describe('SomaHttp', () => {
406
475
  await expect(http.post('/mypage/officeMng/list.do', { menuNo: '200058' })).rejects.toThrow(AuthenticationError)
407
476
  })
408
477
 
409
- test('get throws AuthenticationError for session-expired alert', async () => {
478
+ it('throws AuthenticationError from get when alert indicates session expiry', async () => {
410
479
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert(expiredMessage)))
411
480
  globalThis.fetch = fetchMock as typeof fetch
412
481
 
@@ -415,7 +484,7 @@ describe('SomaHttp', () => {
415
484
  await expect(http.get('/mypage/officeMng/list.do')).rejects.toThrow(AuthenticationError)
416
485
  })
417
486
 
418
- test('postMultipart throws AuthenticationError for session-expired alert', async () => {
487
+ it('throws AuthenticationError from postMultipart when alert indicates session expiry', async () => {
419
488
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert(expiredMessage)))
420
489
  globalThis.fetch = fetchMock as typeof fetch
421
490
 
@@ -424,7 +493,7 @@ describe('SomaHttp', () => {
424
493
  await expect(http.postMultipart('/mypage/test.do', new FormData())).rejects.toThrow(AuthenticationError)
425
494
  })
426
495
 
427
- test('alert with 세션 + invalidation keyword variants are treated as auth error', async () => {
496
+ it('treats alerts containing "세션" plus an invalidation keyword as auth errors', async () => {
428
497
  const variants = ['세션이 만료되었습니다.', '해당 세션을 초기화하였습니다.', '세션 정보가 유효하지 않습니다.']
429
498
 
430
499
  for (const message of variants) {
@@ -436,7 +505,7 @@ describe('SomaHttp', () => {
436
505
  }
437
506
  })
438
507
 
439
- test('alert containing 세션 without invalidation keyword is not treated as auth error', async () => {
508
+ it('does not treat an alert containing "세션" without an invalidation keyword as an auth error', async () => {
440
509
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert('멘토링 세션이 이미 마감되었습니다.')))
441
510
  globalThis.fetch = fetchMock as typeof fetch
442
511
 
@@ -445,7 +514,7 @@ describe('SomaHttp', () => {
445
514
  await expect(http.post('/mypage/test.do', {})).rejects.toThrow('멘토링 세션이 이미 마감되었습니다.')
446
515
  })
447
516
 
448
- test('non-session alert still throws regular Error', async () => {
517
+ it('throws a regular Error from post when the alert is unrelated to the session', async () => {
449
518
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert('잘못된 접근입니다.')))
450
519
  globalThis.fetch = fetchMock as typeof fetch
451
520
 
@@ -454,7 +523,7 @@ describe('SomaHttp', () => {
454
523
  await expect(http.post('/mypage/test.do', {})).rejects.toThrow('잘못된 접근입니다.')
455
524
  })
456
525
 
457
- test('get throws regular Error for non-session alert', async () => {
526
+ it('throws a regular Error from get when the alert is unrelated to the session', async () => {
458
527
  const fetchMock = mock(async () => createResponse(sessionExpiredAlert('잘못된 접근입니다.')))
459
528
  globalThis.fetch = fetchMock as typeof fetch
460
529
 
@@ -463,7 +532,7 @@ describe('SomaHttp', () => {
463
532
  await expect(http.get('/mypage/test.do')).rejects.toThrow('잘못된 접근입니다.')
464
533
  })
465
534
 
466
- test('session-expired alert with double quotes is detected', async () => {
535
+ it('detects session-expired alerts that use double quotes', async () => {
467
536
  const doubleQuoteAlert = `<html><body><script language='JavaScript'>\nalert("${expiredMessage}");\nhistory.back();\n</script></body></html>`
468
537
  const fetchMock = mock(async () => createResponse(doubleQuoteAlert))
469
538
  globalThis.fetch = fetchMock as typeof fetch
@@ -480,7 +549,7 @@ describe('SomaHttp', () => {
480
549
  })
481
550
  const genericErrorJson = JSON.stringify({ error: '처리 중 오류가 발생했습니다.' })
482
551
 
483
- test('post throws AuthenticationError for session-expired JSON', async () => {
552
+ it('throws AuthenticationError from post when the JSON response indicates session expiry', async () => {
484
553
  const fetchMock = mock(async () => createResponse(sessionExpiredJson, [], 'application/json'))
485
554
  globalThis.fetch = fetchMock as typeof fetch
486
555
 
@@ -489,7 +558,7 @@ describe('SomaHttp', () => {
489
558
  await expect(http.post('/mypage/officeMng/list.do', { menuNo: '200058' })).rejects.toThrow(AuthenticationError)
490
559
  })
491
560
 
492
- test('post throws Error for non-session JSON error', async () => {
561
+ it('throws a regular Error from post for non-session JSON errors', async () => {
493
562
  const fetchMock = mock(async () => createResponse(genericErrorJson, [], 'application/json'))
494
563
  globalThis.fetch = fetchMock as typeof fetch
495
564
 
@@ -498,7 +567,7 @@ describe('SomaHttp', () => {
498
567
  await expect(http.post('/mypage/test.do', {})).rejects.toThrow('처리 중 오류가 발생했습니다.')
499
568
  })
500
569
 
501
- test('postJson throws AuthenticationError for session-expired JSON', async () => {
570
+ it('throws AuthenticationError from postJson when the JSON response indicates session expiry', async () => {
502
571
  const fetchMock = mock(async () => createResponse(sessionExpiredJson, [], 'application/json'))
503
572
  globalThis.fetch = fetchMock as typeof fetch
504
573
 
@@ -509,7 +578,7 @@ describe('SomaHttp', () => {
509
578
  )
510
579
  })
511
580
 
512
- test('postJson throws Error for non-session JSON error', async () => {
581
+ it('throws a regular Error from postJson for non-session JSON errors', async () => {
513
582
  const fetchMock = mock(async () => createResponse(genericErrorJson, [], 'application/json'))
514
583
  globalThis.fetch = fetchMock as typeof fetch
515
584
 
@@ -518,7 +587,7 @@ describe('SomaHttp', () => {
518
587
  await expect(http.postJson('/mypage/test.do', {})).rejects.toThrow('처리 중 오류가 발생했습니다.')
519
588
  })
520
589
 
521
- test('postJson still parses valid JSON when no error field', async () => {
590
+ it('still parses valid JSON from postJson when no error field is present', async () => {
522
591
  const fetchMock = mock(async () =>
523
592
  createResponse(JSON.stringify({ resultCode: 'SUCCESS' }), [], 'application/json'),
524
593
  )
@@ -529,7 +598,7 @@ describe('SomaHttp', () => {
529
598
  await expect(http.postJson('/mypage/test.do', {})).resolves.toEqual({ resultCode: 'SUCCESS' })
530
599
  })
531
600
 
532
- test('non-JSON body starting with { does not false-positive', async () => {
601
+ it('does not false-positive on non-JSON bodies that happen to start with "{"', async () => {
533
602
  const fetchMock = mock(async () => createResponse('{malformed json', [], 'text/html'))
534
603
  globalThis.fetch = fetchMock as typeof fetch
535
604
 
@@ -538,7 +607,7 @@ describe('SomaHttp', () => {
538
607
  await expect(http.get('/test')).resolves.toBe('{malformed json')
539
608
  })
540
609
 
541
- test('JSON error with leading whitespace is still detected', async () => {
610
+ it('still detects JSON error responses that have leading whitespace', async () => {
542
611
  const paddedJson = ` \n ${sessionExpiredJson}`
543
612
  const fetchMock = mock(async () => createResponse(paddedJson, [], 'application/json'))
544
613
  globalThis.fetch = fetchMock as typeof fetch
@@ -548,7 +617,7 @@ describe('SomaHttp', () => {
548
617
  await expect(http.post('/mypage/test.do', {})).rejects.toThrow(AuthenticationError)
549
618
  })
550
619
 
551
- test('get throws Error for non-session JSON error', async () => {
620
+ it('throws a regular Error from get for non-session JSON errors', async () => {
552
621
  const fetchMock = mock(async () => createResponse(genericErrorJson, [], 'application/json'))
553
622
  globalThis.fetch = fetchMock as typeof fetch
554
623
 
@@ -557,7 +626,7 @@ describe('SomaHttp', () => {
557
626
  await expect(http.get('/mypage/test.do')).rejects.toThrow('처리 중 오류가 발생했습니다.')
558
627
  })
559
628
 
560
- test('postJson throws AuthenticationError for HTML login page response', async () => {
629
+ it('throws AuthenticationError from postJson when the response is an HTML login page', async () => {
561
630
  const loginPageHtml =
562
631
  '<html><head><title>AI·SW마에스트로</title></head><body><form><input name="username"><input name="password"></form></body></html>'
563
632
  const fetchMock = mock(async () => createResponse(loginPageHtml))
package/src/http.ts CHANGED
@@ -15,12 +15,26 @@ interface RequestOptions {
15
15
 
16
16
  interface CheckLoginResponse {
17
17
  resultCode?: string
18
- userVO?: { userId?: string; userNm?: string; userSn?: number }
18
+ userVO?: {
19
+ userId?: string
20
+ userNm?: string
21
+ userSn?: number
22
+ userNo?: string
23
+ userGb?: string
24
+ }
19
25
  }
20
26
 
21
27
  export interface UserIdentity {
22
28
  userId: string
23
29
  userNm: string
30
+ userNo: string
31
+ userGb: UserGb
32
+ }
33
+
34
+ export enum UserGb {
35
+ Unknown = '',
36
+ Trainee = 'C',
37
+ Mentor = 'T',
24
38
  }
25
39
 
26
40
  interface HeadersWithCookieHelpers extends Omit<Headers, 'getSetCookie'> {
@@ -231,6 +245,17 @@ export class SomaHttp {
231
245
  return message.includes('세션') && /초기화|만료|유효하지/.test(message)
232
246
  }
233
247
 
248
+ private isSuccessAlert(message: string): boolean {
249
+ // SWMaestro returns alert() scripts for both errors and successes (e.g. after room
250
+ // reservation the server responds with alert('정상적으로 등록하였습니다.');location.href=...).
251
+ // These success alerts must not be surfaced as errors to callers. The server emits
252
+ // variants with or without whitespace between the verb and 되었습니다 (e.g. "수정 되었습니다"
253
+ // vs "수정되었습니다"), so match both spellings.
254
+ return /정상적으로|등록\s?하였습니다|등록\s?되었습니다|수정\s?되었습니다|저장\s?되었습니다|완료\s?되었습니다|삭제\s?되었습니다/.test(
255
+ message,
256
+ )
257
+ }
258
+
234
259
  private extractErrorFromResponse(body: string, location: string | null, path?: string): string | null {
235
260
  this.log(
236
261
  'extractErrorFromResponse',
@@ -250,6 +275,10 @@ export class SomaHttp {
250
275
  if (this.isSessionExpiredError(alertMatch[1])) {
251
276
  return '__AUTH_ERROR__'
252
277
  }
278
+ if (this.isSuccessAlert(alertMatch[1])) {
279
+ this.log('Alert is a success message, ignoring:', alertMatch[1])
280
+ return null
281
+ }
253
282
  return alertMatch[1]
254
283
  }
255
284
 
@@ -408,7 +437,12 @@ export class SomaHttp {
408
437
 
409
438
  const userId = json.userVO?.userId
410
439
  if (!userId) return null
411
- return { userId, userNm: json.userVO?.userNm ?? '' }
440
+ return {
441
+ userId,
442
+ userNm: json.userVO?.userNm ?? '',
443
+ userNo: json.userVO?.userNo ?? '',
444
+ userGb: parseUserGb(json.userVO?.userGb),
445
+ }
412
446
  }
413
447
 
414
448
  async logout(): Promise<void> {
@@ -532,3 +566,8 @@ export class SomaHttp {
532
566
  }
533
567
  }
534
568
  }
569
+
570
+ function parseUserGb(value: string | undefined): UserGb {
571
+ if (value === UserGb.Trainee || value === UserGb.Mentor) return value
572
+ return UserGb.Unknown
573
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,16 @@
1
+ export { AgentBrowserLauncher, assertSwmaestroUrl, buildStorageState } from './agent-browser-launcher'
2
+ export type {
3
+ AgentBrowserLauncherOptions,
4
+ AgentBrowserLaunchInput,
5
+ LaunchResult,
6
+ Spawner,
7
+ SpawnedProcess,
8
+ } from './agent-browser-launcher'
1
9
  export { SomaClient } from './client'
2
10
  export type { SomaClientOptions } from './client'
3
11
  export { AuthenticationError } from './errors'
4
- export { SomaHttp } from './http'
12
+ export { SomaHttp, UserGb } from './http'
13
+ export type { UserIdentity } from './http'
5
14
  export { CredentialManager } from './credential-manager'
6
15
  export { TozHttp } from './toz-http'
7
16
  export type { TozHttpOptions, TozHttpState } from './toz-http'
@@ -1,44 +1,44 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ import { describe, expect, it } from 'bun:test'
2
2
 
3
3
  import { MENU_NO, REPORT_CD } from '../../constants'
4
4
  import { buildMentoringListParams, parseSearchQuery } from './mentoring-params'
5
5
 
6
6
  describe('parseSearchQuery', () => {
7
- test('plain text defaults to title', () => {
7
+ it('defaults plain text to a title search', () => {
8
8
  expect(parseSearchQuery('OpenCode')).toEqual({ field: 'title', value: 'OpenCode' })
9
9
  })
10
10
 
11
- test('title: prefix', () => {
11
+ it('parses the "title:" prefix as a title search', () => {
12
12
  expect(parseSearchQuery('title:OpenCode')).toEqual({ field: 'title', value: 'OpenCode' })
13
13
  })
14
14
 
15
- test('author: prefix', () => {
15
+ it('parses the "author:" prefix as an author search', () => {
16
16
  expect(parseSearchQuery('author:전수열')).toEqual({ field: 'author', value: '전수열' })
17
17
  })
18
18
 
19
- test('author:@me sets me flag', () => {
19
+ it('sets the "me" flag when parsing "author:@me"', () => {
20
20
  expect(parseSearchQuery('author:@me')).toEqual({ field: 'author', value: '@me', me: true })
21
21
  })
22
22
 
23
- test('content: prefix', () => {
23
+ it('parses the "content:" prefix as a content search', () => {
24
24
  expect(parseSearchQuery('content:하네스')).toEqual({ field: 'content', value: '하네스' })
25
25
  })
26
26
 
27
- test('unknown prefix treated as plain title search', () => {
27
+ it('treats an unknown prefix as a plain title search', () => {
28
28
  expect(parseSearchQuery('foo:bar')).toEqual({ field: 'title', value: 'foo:bar' })
29
29
  })
30
30
 
31
- test('value with colons preserves everything after first colon', () => {
31
+ it('preserves everything after the first colon when the value contains colons', () => {
32
32
  expect(parseSearchQuery('title:foo:bar')).toEqual({ field: 'title', value: 'foo:bar' })
33
33
  })
34
34
  })
35
35
 
36
36
  describe('buildMentoringListParams', () => {
37
- test('no options returns only menuNo', () => {
37
+ it('returns only menuNo when no options are passed', () => {
38
38
  expect(buildMentoringListParams()).toEqual({ menuNo: MENU_NO.MENTORING })
39
39
  })
40
40
 
41
- test('status maps open to A, closed to C', () => {
41
+ it('maps status open to "A" and closed to "C"', () => {
42
42
  expect(buildMentoringListParams({ status: 'open' })).toEqual({
43
43
  menuNo: MENU_NO.MENTORING,
44
44
  searchStatMentolec: 'A',
@@ -49,7 +49,7 @@ describe('buildMentoringListParams', () => {
49
49
  })
50
50
  })
51
51
 
52
- test('type maps public and lecture to report codes', () => {
52
+ it('maps type public and lecture to their report codes', () => {
53
53
  expect(buildMentoringListParams({ type: 'public' })).toEqual({
54
54
  menuNo: MENU_NO.MENTORING,
55
55
  searchGubunMentolec: REPORT_CD.PUBLIC_MENTORING,
@@ -60,7 +60,7 @@ describe('buildMentoringListParams', () => {
60
60
  })
61
61
  })
62
62
 
63
- test('title search sets searchCnd=1', () => {
63
+ it('sets searchCnd=1 for a title search', () => {
64
64
  expect(buildMentoringListParams({ search: { field: 'title', value: 'OpenCode' } })).toEqual({
65
65
  menuNo: MENU_NO.MENTORING,
66
66
  searchCnd: '1',
@@ -68,7 +68,7 @@ describe('buildMentoringListParams', () => {
68
68
  })
69
69
  })
70
70
 
71
- test('author search sets searchCnd=2', () => {
71
+ it('sets searchCnd=2 for an author search', () => {
72
72
  expect(buildMentoringListParams({ search: { field: 'author', value: '전수열' } })).toEqual({
73
73
  menuNo: MENU_NO.MENTORING,
74
74
  searchCnd: '2',
@@ -76,7 +76,7 @@ describe('buildMentoringListParams', () => {
76
76
  })
77
77
  })
78
78
 
79
- test('author:@me with user sets searchId and userNm', () => {
79
+ it('sets searchId and searchWrd from the current user when "author:@me" is used', () => {
80
80
  expect(
81
81
  buildMentoringListParams({
82
82
  search: { field: 'author', value: '@me', me: true },
@@ -90,7 +90,7 @@ describe('buildMentoringListParams', () => {
90
90
  })
91
91
  })
92
92
 
93
- test('search composes with status and type', () => {
93
+ it('composes search with status and type filters', () => {
94
94
  expect(
95
95
  buildMentoringListParams({
96
96
  status: 'open',
@@ -108,7 +108,7 @@ describe('buildMentoringListParams', () => {
108
108
  })
109
109
  })
110
110
 
111
- test('page sets pageIndex', () => {
111
+ it('sets pageIndex from the page option', () => {
112
112
  expect(buildMentoringListParams({ page: 3 })).toEqual({
113
113
  menuNo: MENU_NO.MENTORING,
114
114
  pageIndex: '3',