pal-crawl 1.7.0 → 2.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/dist/pal.test.js CHANGED
@@ -2,6 +2,13 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const pal_1 = require("./pal");
4
4
  const parser_1 = require("./parser");
5
+ const DEFAULT_TEST_TIMEOUT_MS = 120000;
6
+ const configuredTimeout = Number.parseInt(process.env.PAL_CRAWL_TEST_TIMEOUT_MS ?? String(DEFAULT_TEST_TIMEOUT_MS), 10);
7
+ const testTimeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0
8
+ ? configuredTimeout
9
+ : DEFAULT_TEST_TIMEOUT_MS;
10
+ // Integration tests call external services and can be slow on unstable networks.
11
+ jest.setTimeout(testTimeoutMs);
5
12
  const FIXED_ONGOING_CONTENT_ID = 'PRC_W2W6V0D4D0B9C1B4B4Z6V2W0U7V2T9';
6
13
  const FIXED_DONE_CONTENT_ID = 'PRC_S2R6N0M3K2J3K1J5K3S8R3P4O3P5X6';
7
14
  const SHOULD_DEBUG = process.env.PAL_CRAWL_TEST_DEBUG === '1';
@@ -141,6 +148,107 @@ const SEARCH_RESULT_HTML = `
141
148
  </tbody>
142
149
  </table>
143
150
  `;
151
+ // ─── NSM HTML Fixtures ────────────────────────────────────────────────────────
152
+ /** 위원회에 배속된 법안 한 건 + 페이지네이션 */
153
+ const NSM_LIST_HTML = `
154
+ <p class="numBuild">총 <span>3,142</span>건</p>
155
+ <p class="numBuild">현재 <em>2</em>/<span>315</span>쪽</p>
156
+ <table>
157
+ <tbody>
158
+ <tr>
159
+ <td data-th="의안명">
160
+ <a href="/gcom/nsmLmSts/out/2219088/detailRP">인공지능 기본법 일부개정법률안</a>
161
+ </td>
162
+ <td data-th="제안자(제안일자)">
163
+ <p>홍길동의원 등 10인</p>
164
+ <p>(2026.05.01.)</p>
165
+ </td>
166
+ <td data-th="상임위원회(소관부처)">
167
+ <p>과학기술정보방송통신위원회</p>
168
+ <p>(과학기술정보통신부)</p>
169
+ </td>
170
+ <td data-th="국회현황(추진일자)">
171
+ <p>위원회 회부</p>
172
+ <p>(2026.05.03.)</p>
173
+ </td>
174
+ <td data-th="의결현황(의결일자)">
175
+ <p>원안가결</p>
176
+ <p>(2026.05.20.)</p>
177
+ </td>
178
+ </tr>
179
+ </tbody>
180
+ </table>
181
+ `;
182
+ /** 위원회 회부 이전 발의 상태 법안 (committee 빈 문자열) */
183
+ const NSM_LIST_HTML_PENDING = `
184
+ <p class="numBuild">총 <span>87</span>건</p>
185
+ <p class="numBuild">현재 <em>1</em>/<span>9</span>쪽</p>
186
+ <table>
187
+ <tbody>
188
+ <tr>
189
+ <td data-th="의안명">
190
+ <a href="/gcom/nsmLmSts/out/2219200/detailRP">개인정보 보호법 일부개정법률안</a>
191
+ </td>
192
+ <td data-th="제안자(제안일자)">
193
+ <p>김철수의원 등 5인</p>
194
+ <p>(2026.05.28.)</p>
195
+ </td>
196
+ <td data-th="상임위원회(소관부처)">
197
+ <p>(개인정보보호위원회)</p>
198
+ </td>
199
+ <td data-th="국회현황(추진일자)">
200
+ <p>발의</p>
201
+ <p>(2026.05.28.)</p>
202
+ </td>
203
+ <td data-th="의결현황(의결일자)"></td>
204
+ </tr>
205
+ </tbody>
206
+ </table>
207
+ `;
208
+ /** 상세 페이지 */
209
+ const NSM_DETAIL_HTML = `
210
+ <h2></h2>
211
+ <h2>인공지능 기본법 일부개정법률안</h2>
212
+ <h3>기본정보</h3>
213
+ <h3>국회입법현황</h3>
214
+ <table>
215
+ <tbody>
216
+ <tr>
217
+ <th>발의정보</th>
218
+ <td>홍길동의원 등 10인, 제2219088호(2026. 5. 1.). 제435회 국회(임시회)</td>
219
+ </tr>
220
+ <tr>
221
+ <th>의안원문</th>
222
+ <td>
223
+ <button onclick="fnDownload(99001)">인공지능기본법안.hwp<span class="a11y_hidden">다운로드</span></button>
224
+ <button onclick="fnDownload(99002)">인공지능기본법안검토보고서.pdf<span class="a11y_hidden">다운로드</span></button>
225
+ </td>
226
+ </tr>
227
+ <tr>
228
+ <th>제안이유 및 주요내용</th>
229
+ <td>
230
+ <pre>인공지능 기술의 발전에 따라<br/>규제 체계를 정비하고자 함</pre>
231
+ </td>
232
+ </tr>
233
+ </tbody>
234
+ </table>
235
+ `;
236
+ /** 상세 페이지 - 제안이유 없음 (pre 태그 없는 경우) */
237
+ const NSM_DETAIL_HTML_NO_REASON = `
238
+ <h2>최소 법률안</h2>
239
+ <table>
240
+ <tbody>
241
+ <tr>
242
+ <th>발의정보</th>
243
+ <td>이영희의원, 제2200001호(2026. 1. 5.). 제433회 국회(임시회)</td>
244
+ </tr>
245
+ <tr>
246
+ <th>의안원문</th>
247
+ <td></td>
248
+ </tr>
249
+ </tbody>
250
+ </table>
251
+ `;
144
252
  // ─── PalParser ──────────────────────────────────────────────────────────────────────────────
145
253
  describe('PalParser', () => {
146
254
  const parser = new parser_1.PalParser();
@@ -191,7 +299,7 @@ describe('PalParser', () => {
191
299
  });
192
300
  test('strips the repeated heading line from proposal reason', () => {
193
301
  const content = parser.parseContent(CONTENT_HTML);
194
- // The desc starts with "제안이유 및 주요내용" which matches the heading — it must be stripped.
302
+ // The desc starts with "제안이유 및 주요내용" which matches the heading - it must be stripped.
195
303
  expect(content.proposalReason).not.toMatch(/제안이유/);
196
304
  });
197
305
  test('returns empty title and all nulls for empty html', () => {
@@ -354,9 +462,10 @@ describe('PalCrawl', () => {
354
462
  expect(listHtml.length).toBeGreaterThan(0);
355
463
  expect(listHtml).not.toContain('�');
356
464
  });
357
- test('get: returns 10 items per page', async () => {
465
+ test('get: returns items (up to 10 per page)', async () => {
358
466
  const result = await palCrawl.get();
359
- expect(result).toHaveLength(10);
467
+ expect(result.length).toBeGreaterThan(0);
468
+ expect(result.length).toBeLessThanOrEqual(10);
360
469
  });
361
470
  test('get: each item has valid structure and no garbled text', () => {
362
471
  ongoingTable.forEach((e) => {
@@ -459,10 +568,10 @@ describe('PalCrawl', () => {
459
568
  expect(result.items.length).toBeGreaterThan(0);
460
569
  });
461
570
  test('search: billName filter returns matching items', async () => {
462
- const result = await palCrawl.search({ billName: '도로교통' });
571
+ const result = await palCrawl.search({ billName: '개정법률' });
463
572
  expect(result.items.length).toBeGreaterThan(0);
464
573
  result.items.forEach((item) => {
465
- expect(item.subject).toMatch(/도로교통/);
574
+ expect(item.subject).toMatch(/개정법률/);
466
575
  });
467
576
  });
468
577
  test('search: sortCol=BILL_NAME sortGbn=ASC returns alphabetically sorted items', async () => {
@@ -503,17 +612,17 @@ describe('PalCrawl', () => {
503
612
  expect(page1).toHaveLength(legacy.length);
504
613
  expect(page1[0].subject).toBe(legacy[0].subject);
505
614
  });
506
- test('getPage(2): returns different items from page 1', async () => {
615
+ test('getDonePage(2): returns different items from page 1', async () => {
507
616
  const [page1, page2] = await Promise.all([
508
- palCrawl.getPage(1),
509
- palCrawl.getPage(2),
617
+ palCrawl.getDonePage(1),
618
+ palCrawl.getDonePage(2),
510
619
  ]);
511
620
  expect(page1.length).toBeGreaterThan(0);
512
621
  expect(page2.length).toBeGreaterThan(0);
513
622
  expect(page1[0].subject).not.toBe(page2[0].subject);
514
623
  });
515
- test('getPage: pageUnit=20 returns up to 20 items', async () => {
516
- const items = await palCrawl.getPage(1, 20);
624
+ test('getDonePage: pageUnit=20 returns up to 20 items', async () => {
625
+ const items = await palCrawl.getDonePage(1, 20);
517
626
  expect(items.length).toBeLessThanOrEqual(20);
518
627
  expect(items.length).toBeGreaterThan(10);
519
628
  });
@@ -536,18 +645,18 @@ describe('PalCrawl', () => {
536
645
  expect(pages[0].items.length).toBeGreaterThan(0);
537
646
  expect(pages[1].items.length).toBeGreaterThan(0);
538
647
  });
539
- test('getAllPages: currentPage increments correctly', async () => {
648
+ test('getAllDonePages: currentPage increments correctly', async () => {
540
649
  const pages = [];
541
- for await (const page of palCrawl.getAllPages({ pageUnit: 5 }, { maxPages: 3, delayMs: 0 })) {
650
+ for await (const page of palCrawl.getAllDonePages({ pageUnit: 5 }, { maxPages: 3, delayMs: 0 })) {
542
651
  pages.push(page);
543
652
  }
544
653
  expect(pages[0].currentPage).toBe(1);
545
654
  expect(pages[1].currentPage).toBe(2);
546
655
  expect(pages[2].currentPage).toBe(3);
547
656
  });
548
- test('getAllPages: total and totalPages are consistent across pages', async () => {
657
+ test('getAllDonePages: total and totalPages are consistent across pages', async () => {
549
658
  const pages = [];
550
- for await (const page of palCrawl.getAllPages({}, { maxPages: 2, delayMs: 0 })) {
659
+ for await (const page of palCrawl.getAllDonePages({}, { maxPages: 2, delayMs: 0 })) {
551
660
  pages.push(page);
552
661
  }
553
662
  expect(pages[0].total).toBe(pages[1].total);
@@ -560,9 +669,9 @@ describe('PalCrawl', () => {
560
669
  }
561
670
  expect(pages[0].items[0].subject).not.toBe(pages[1].items[0].subject);
562
671
  });
563
- test('getAllPages: concurrency > 1 yields pages in order', async () => {
672
+ test('getAllDonePages: concurrency > 1 yields pages in order', async () => {
564
673
  const pages = [];
565
- for await (const page of palCrawl.getAllPages({ pageUnit: 5 }, { maxPages: 4, delayMs: 0, concurrency: 2 })) {
674
+ for await (const page of palCrawl.getAllDonePages({ pageUnit: 5 }, { maxPages: 4, delayMs: 0, concurrency: 2 })) {
566
675
  pages.push(page);
567
676
  }
568
677
  expect(pages).toHaveLength(4);
@@ -580,5 +689,466 @@ describe('PalCrawl', () => {
580
689
  expect(pages[0].items[0].subject).not.toBe(pages[1].items[0].subject);
581
690
  });
582
691
  });
692
+ // ── Screenshot functionality ─────────────────────────────────────────────────
693
+ describe('Screenshot functionality', () => {
694
+ test('creates instance with screenshot config', () => {
695
+ const config = {
696
+ screenshot: {
697
+ enabled: true,
698
+ fullPage: true,
699
+ width: 1920,
700
+ height: 1080,
701
+ format: 'png',
702
+ },
703
+ };
704
+ expect(new pal_1.PalCrawl(config)).toBeInstanceOf(pal_1.PalCrawl);
705
+ });
706
+ test('throws error when screenshot is disabled', async () => {
707
+ const crawler = new pal_1.PalCrawl({ screenshot: { enabled: false } });
708
+ await expect(crawler.takeScreenshot('https://example.com')).rejects.toThrow('Screenshot feature is not enabled');
709
+ });
710
+ test('accepts jpeg format with quality option', () => {
711
+ const config = {
712
+ screenshot: {
713
+ enabled: true,
714
+ format: 'jpeg',
715
+ quality: 90,
716
+ },
717
+ };
718
+ expect(new pal_1.PalCrawl(config)).toBeInstanceOf(pal_1.PalCrawl);
719
+ });
720
+ test('updateScreenshotConfig modifies screenshot settings', () => {
721
+ const crawler = new pal_1.PalCrawl({
722
+ screenshot: { enabled: true, format: 'png' },
723
+ });
724
+ crawler.updateScreenshotConfig({ format: 'jpeg', quality: 75 });
725
+ expect(crawler).toBeInstanceOf(pal_1.PalCrawl);
726
+ });
727
+ test('initBrowser and closeBrowser can be called', async () => {
728
+ const crawler = new pal_1.PalCrawl({
729
+ screenshot: { enabled: true },
730
+ });
731
+ // Note: These tests don't actually require a browser to pass.
732
+ // In a real scenario with HEADLESS_BROWSER env set, these would work.
733
+ expect(crawler).toBeInstanceOf(pal_1.PalCrawl);
734
+ });
735
+ });
736
+ });
737
+ // ─── NsmLmStsParser ──────────────────────────────────────────────────────────
738
+ describe('NsmLmStsParser', () => {
739
+ const parser = new parser_1.NsmLmStsParser();
740
+ // ── parseList ──────────────────────────────────────────────────────────────
741
+ describe('parseList', () => {
742
+ test('returns empty result for empty string', () => {
743
+ const result = parser.parseList('');
744
+ expect(result.total).toBe(0);
745
+ expect(result.totalPages).toBe(0);
746
+ expect(result.currentPage).toBe(1);
747
+ expect(result.items).toHaveLength(0);
748
+ });
749
+ test('returns empty result for invalid html', () => {
750
+ const result = parser.parseList('not html at all');
751
+ expect(result.items).toHaveLength(0);
752
+ });
753
+ test('parses pagination: total, totalPages, currentPage', () => {
754
+ const result = parser.parseList(NSM_LIST_HTML);
755
+ expect(result.total).toBe(3142);
756
+ expect(result.totalPages).toBe(315);
757
+ expect(result.currentPage).toBe(2);
758
+ });
759
+ test('parses bill name and link', () => {
760
+ const result = parser.parseList(NSM_LIST_HTML);
761
+ expect(result.items).toHaveLength(1);
762
+ const item = result.items[0];
763
+ expect(item.billName).toBe('인공지능 기본법 일부개정법률안');
764
+ expect(item.billNo).toBe('2219088');
765
+ expect(item.link).toContain('2219088/detailRP');
766
+ });
767
+ test('parses proposer and proposalDate (strips parentheses)', () => {
768
+ const item = parser.parseList(NSM_LIST_HTML).items[0];
769
+ expect(item.proposer).toBe('홍길동의원 등 10인');
770
+ expect(item.proposalDate).toBe('2026.05.01.');
771
+ });
772
+ test('parses committee and ministry', () => {
773
+ const item = parser.parseList(NSM_LIST_HTML).items[0];
774
+ expect(item.committee).toBe('과학기술정보방송통신위원회');
775
+ expect(item.ministry).toBe('과학기술정보통신부');
776
+ });
777
+ test('parses progressStatus and progressDate', () => {
778
+ const item = parser.parseList(NSM_LIST_HTML).items[0];
779
+ expect(item.progressStatus).toBe('위원회 회부');
780
+ expect(item.progressDate).toBe('2026.05.03.');
781
+ });
782
+ test('parses resolutionStatus and resolutionDate', () => {
783
+ const item = parser.parseList(NSM_LIST_HTML).items[0];
784
+ expect(item.resolutionStatus).toBe('원안가결');
785
+ expect(item.resolutionDate).toBe('2026.05.20.');
786
+ });
787
+ test('pending bill: committee is empty string, ministry is set', () => {
788
+ const item = parser.parseList(NSM_LIST_HTML_PENDING).items[0];
789
+ expect(item.committee).toBe('');
790
+ expect(item.ministry).toBe('개인정보보호위원회');
791
+ });
792
+ test('pending bill: resolutionStatus and resolutionDate are empty strings', () => {
793
+ const item = parser.parseList(NSM_LIST_HTML_PENDING).items[0];
794
+ expect(item.resolutionStatus).toBe('');
795
+ expect(item.resolutionDate).toBe('');
796
+ });
797
+ test('pending bill: progressStatus is "발의"', () => {
798
+ const item = parser.parseList(NSM_LIST_HTML_PENDING).items[0];
799
+ expect(item.progressStatus).toBe('발의');
800
+ });
801
+ test('falls back to items.length when pagination markup is absent', () => {
802
+ const minimalHtml = `
803
+ <table><tbody>
804
+ <tr>
805
+ <td data-th="의안명"><a href="/gcom/nsmLmSts/out/1/detailRP">A</a></td>
806
+ <td data-th="제안자(제안일자)"><p>홍길동</p><p>(2026.01.01.)</p></td>
807
+ <td data-th="상임위원회(소관부처)"></td>
808
+ <td data-th="국회현황(추진일자)"><p>발의</p></td>
809
+ <td data-th="의결현황(의결일자)"></td>
810
+ </tr>
811
+ </tbody></table>`;
812
+ const result = parser.parseList(minimalHtml);
813
+ expect(result.totalPages).toBe(1);
814
+ expect(result.total).toBe(1);
815
+ expect(result.currentPage).toBe(1);
816
+ });
817
+ test('skips rows without a bill name', () => {
818
+ const htmlNoName = `
819
+ <table><tbody>
820
+ <tr>
821
+ <td data-th="의안명"><a href="/gcom/nsmLmSts/out/1/detailRP"></a></td>
822
+ <td data-th="제안자(제안일자)"></td>
823
+ <td data-th="상임위원회(소관부처)"></td>
824
+ <td data-th="국회현황(추진일자)"></td>
825
+ <td data-th="의결현황(의결일자)"></td>
826
+ </tr>
827
+ </tbody></table>`;
828
+ expect(parser.parseList(htmlNoName).items).toHaveLength(0);
829
+ });
830
+ });
831
+ // ── parseDetail ────────────────────────────────────────────────────────────
832
+ describe('parseDetail', () => {
833
+ test('returns default empty object for empty string', () => {
834
+ const detail = parser.parseDetail('');
835
+ expect(detail.title).toBe('');
836
+ expect(detail.billNo).toBe('');
837
+ expect(detail.proposer).toBe('');
838
+ expect(detail.proposalDate).toBe('');
839
+ expect(detail.session).toBe('');
840
+ expect(detail.proposalInfo).toBe('');
841
+ expect(detail.proposalReason).toBeNull();
842
+ expect(detail.attachments).toHaveLength(0);
843
+ });
844
+ test('picks title h2 (skips empty h2; h3 elements are section headers)', () => {
845
+ const detail = parser.parseDetail(NSM_DETAIL_HTML);
846
+ expect(detail.title).toBe('인공지능 기본법 일부개정법률안');
847
+ });
848
+ test('parses proposalInfo raw text', () => {
849
+ const detail = parser.parseDetail(NSM_DETAIL_HTML);
850
+ expect(detail.proposalInfo).toContain('제2219088호');
851
+ expect(detail.proposalInfo).toContain('제435회 국회(임시회)');
852
+ });
853
+ test('extracts billNo from proposalInfo', () => {
854
+ const detail = parser.parseDetail(NSM_DETAIL_HTML);
855
+ expect(detail.billNo).toBe('2219088');
856
+ });
857
+ test('extracts proposer from proposalInfo', () => {
858
+ const detail = parser.parseDetail(NSM_DETAIL_HTML);
859
+ expect(detail.proposer).toBe('홍길동의원 등 10인');
860
+ });
861
+ test('extracts proposalDate from proposalInfo', () => {
862
+ const detail = parser.parseDetail(NSM_DETAIL_HTML);
863
+ expect(detail.proposalDate).toBe('2026. 5. 1.');
864
+ });
865
+ test('extracts session from proposalInfo', () => {
866
+ const detail = parser.parseDetail(NSM_DETAIL_HTML);
867
+ expect(detail.session).toBe('제435회 국회(임시회)');
868
+ });
869
+ test('parses attachments: fileId and filename (strips a11y_hidden span)', () => {
870
+ const detail = parser.parseDetail(NSM_DETAIL_HTML);
871
+ expect(detail.attachments).toHaveLength(2);
872
+ expect(detail.attachments[0].fileId).toBe('99001');
873
+ expect(detail.attachments[0].filename).toBe('인공지능기본법안.hwp');
874
+ expect(detail.attachments[1].fileId).toBe('99002');
875
+ expect(detail.attachments[1].filename).toBe('인공지능기본법안검토보고서.pdf');
876
+ });
877
+ test('parses proposalReason from pre tag (br → newline)', () => {
878
+ const detail = parser.parseDetail(NSM_DETAIL_HTML);
879
+ expect(detail.proposalReason).toBe('인공지능 기술의 발전에 따라\n규제 체계를 정비하고자 함');
880
+ });
881
+ test('returns null proposalReason when pre tag is absent', () => {
882
+ const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
883
+ expect(detail.proposalReason).toBeNull();
884
+ });
885
+ test('returns empty attachments when no buttons are present', () => {
886
+ const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
887
+ expect(detail.attachments).toHaveLength(0);
888
+ });
889
+ test('single-proposer format parses without crash', () => {
890
+ const detail = parser.parseDetail(NSM_DETAIL_HTML_NO_REASON);
891
+ expect(detail.proposer).toBe('이영희의원');
892
+ expect(detail.billNo).toBe('2200001');
893
+ expect(detail.session).toBe('제433회 국회(임시회)');
894
+ });
895
+ });
896
+ });
897
+ // ─── NsmLmSts ─────────────────────────────────────────────────────────────────
898
+ describe('NsmLmSts', () => {
899
+ const nsm = new pal_1.NsmLmSts();
900
+ // ── constructor ────────────────────────────────────────────────────────────
901
+ describe('constructor', () => {
902
+ test('creates instance without config', () => {
903
+ expect(new pal_1.NsmLmSts()).toBeInstanceOf(pal_1.NsmLmSts);
904
+ });
905
+ test('creates instance with empty config object', () => {
906
+ expect(new pal_1.NsmLmSts({})).toBeInstanceOf(pal_1.NsmLmSts);
907
+ });
908
+ test('accepts custom userAgent', () => {
909
+ expect(new pal_1.NsmLmSts({ userAgent: 'Custom Agent' })).toBeInstanceOf(pal_1.NsmLmSts);
910
+ });
911
+ test('accepts custom timeout and retryCount', () => {
912
+ expect(new pal_1.NsmLmSts({ timeout: 5000, retryCount: 2 })).toBeInstanceOf(pal_1.NsmLmSts);
913
+ });
914
+ test('accepts custom headers', () => {
915
+ expect(new pal_1.NsmLmSts({ customHeaders: { 'Accept-Language': 'ko-KR' } })).toBeInstanceOf(pal_1.NsmLmSts);
916
+ });
917
+ });
918
+ // ── parseList / parseDetail (delegation) ──────────────────────────────────
919
+ describe('parseList (delegation)', () => {
920
+ test('delegates to NsmLmStsParser and returns INsmSearchResult', () => {
921
+ const result = nsm.parseList(NSM_LIST_HTML);
922
+ expect(result.total).toBe(3142);
923
+ expect(result.items).toHaveLength(1);
924
+ expect(result.items[0].billName).toBe('인공지능 기본법 일부개정법률안');
925
+ });
926
+ test('returns empty result for empty html', () => {
927
+ const result = nsm.parseList('');
928
+ expect(result.items).toHaveLength(0);
929
+ });
930
+ });
931
+ describe('parseDetail (delegation)', () => {
932
+ test('delegates to NsmLmStsParser and returns INsmBillDetail', () => {
933
+ const detail = nsm.parseDetail(NSM_DETAIL_HTML);
934
+ expect(detail.title).toBe('인공지능 기본법 일부개정법률안');
935
+ expect(detail.billNo).toBe('2219088');
936
+ expect(detail.attachments).toHaveLength(2);
937
+ });
938
+ });
939
+ // ── getDetailHTML validation ───────────────────────────────────────────────
940
+ describe('getDetailHTML input validation', () => {
941
+ test('throws when billNo is empty string', async () => {
942
+ await expect(nsm.getDetailHTML('')).rejects.toThrow('billNo is required');
943
+ });
944
+ test('throws when billNo is whitespace only', async () => {
945
+ await expect(nsm.getDetailHTML(' ')).rejects.toThrow('billNo is required');
946
+ });
947
+ });
948
+ // ── search integration ────────────────────────────────────────────────────
949
+ describe('search (integration)', () => {
950
+ let searchResult;
951
+ beforeAll(async () => {
952
+ searchResult = await nsm.search({ pageSize: 5 });
953
+ debugLog('nsm.search', searchResult);
954
+ });
955
+ test('returns non-garbled html string (getListHTML)', async () => {
956
+ const html = await nsm.getListHTML({ pageSize: 5 });
957
+ expect(typeof html).toBe('string');
958
+ expect(html.length).toBeGreaterThan(0);
959
+ expect(html).not.toContain('\ufffd');
960
+ });
961
+ test('returns INsmSearchResult with total > 0', () => {
962
+ expect(searchResult.total).toBeGreaterThan(0);
963
+ expect(searchResult.totalPages).toBeGreaterThan(0);
964
+ expect(searchResult.currentPage).toBe(1);
965
+ });
966
+ test('returns items with valid structure and no garbled text', () => {
967
+ expect(searchResult.items.length).toBeGreaterThan(0);
968
+ for (const item of searchResult.items) {
969
+ expect(item.billName).not.toContain('\ufffd');
970
+ expect(item.billNo).toMatch(/^\d+$/);
971
+ expect(item.link).toContain('/gcom/nsmLmSts/out/');
972
+ expect(item.proposer).not.toContain('\ufffd');
973
+ expect(item.proposer.length).toBeGreaterThan(0);
974
+ expect(item.proposalDate.length).toBeGreaterThan(0);
975
+ expect(item.progressStatus.length).toBeGreaterThan(0);
976
+ }
977
+ });
978
+ test('pageSize filter limits returned items', async () => {
979
+ const r5 = await nsm.search({ pageSize: 5 });
980
+ const r10 = await nsm.search({ pageSize: 10 });
981
+ expect(r5.items.length).toBeLessThanOrEqual(5);
982
+ expect(r10.items.length).toBeLessThanOrEqual(10);
983
+ });
984
+ test('pageIndex=2 returns different items from page 1', async () => {
985
+ const page1 = await nsm.search({ pageSize: 5, pageIndex: 1 });
986
+ const page2 = await nsm.search({ pageSize: 5, pageIndex: 2 });
987
+ expect(page1.items.length).toBeGreaterThan(0);
988
+ expect(page2.items.length).toBeGreaterThan(0);
989
+ expect(page1.items[0].billNo).not.toBe(page2.items[0].billNo);
990
+ });
991
+ });
992
+ // ── searchPending integration ─────────────────────────────────────────────
993
+ describe('searchPending (integration)', () => {
994
+ let pendingResult;
995
+ beforeAll(async () => {
996
+ pendingResult = await nsm.searchPending({ pageSize: 5 });
997
+ debugLog('nsm.searchPending', pendingResult);
998
+ });
999
+ test('returns INsmSearchResult with valid structure', () => {
1000
+ expect(typeof pendingResult.total).toBe('number');
1001
+ expect(typeof pendingResult.totalPages).toBe('number');
1002
+ expect(pendingResult.currentPage).toBe(1);
1003
+ });
1004
+ test('all returned bills have progressStatus "발의"', () => {
1005
+ for (const item of pendingResult.items) {
1006
+ expect(item.progressStatus).toBe('발의');
1007
+ }
1008
+ });
1009
+ test('all pending bills have no resolutionStatus', () => {
1010
+ for (const item of pendingResult.items) {
1011
+ expect(item.resolutionStatus).toBe('');
1012
+ }
1013
+ });
1014
+ test('no garbled text in pending bill names', () => {
1015
+ for (const item of pendingResult.items) {
1016
+ expect(item.billName).not.toContain('\ufffd');
1017
+ }
1018
+ });
1019
+ });
1020
+ // ── getPage / getDetail integration ───────────────────────────────────────
1021
+ describe('getPage / getDetail (integration)', () => {
1022
+ let firstBillNo;
1023
+ let pageItems;
1024
+ beforeAll(async () => {
1025
+ pageItems = await nsm.getPage(1, { pageSize: 5 });
1026
+ firstBillNo = pageItems[0]?.billNo ?? '';
1027
+ debugLog('nsm.getPage(1)', pageItems);
1028
+ });
1029
+ test('getPage(1): returns INsmBillItem array', () => {
1030
+ expect(Array.isArray(pageItems)).toBe(true);
1031
+ expect(pageItems.length).toBeGreaterThan(0);
1032
+ });
1033
+ test('getPage(2): returns different items from page 1', async () => {
1034
+ const page2 = await nsm.getPage(2, { pageSize: 5 });
1035
+ expect(page2.length).toBeGreaterThan(0);
1036
+ expect(page2[0].billNo).not.toBe(pageItems[0].billNo);
1037
+ });
1038
+ test('getDetailHTML: returns non-empty, non-garbled html', async () => {
1039
+ if (!firstBillNo)
1040
+ return;
1041
+ const html = await nsm.getDetailHTML(firstBillNo);
1042
+ expect(typeof html).toBe('string');
1043
+ expect(html.length).toBeGreaterThan(0);
1044
+ expect(html).not.toContain('\ufffd');
1045
+ });
1046
+ test('getDetail: returns INsmBillDetail with valid fields', async () => {
1047
+ if (!firstBillNo)
1048
+ return;
1049
+ const detail = await nsm.getDetail(firstBillNo);
1050
+ debugLog('nsm.getDetail', detail);
1051
+ expect(detail.title.length).toBeGreaterThan(0);
1052
+ expect(detail.title).not.toContain('\ufffd');
1053
+ expect(detail.billNo).toMatch(/^\d+$/);
1054
+ expect(detail.proposer.length).toBeGreaterThan(0);
1055
+ expect(detail.proposalDate.length).toBeGreaterThan(0);
1056
+ });
1057
+ });
1058
+ // ── getAllPages / getAllPendingPages integration ───────────────────────────
1059
+ describe('getAllPages / getAllPendingPages (integration)', () => {
1060
+ test('getAllPages: yields exactly maxPages results', async () => {
1061
+ const pages = [];
1062
+ for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
1063
+ pages.push(page);
1064
+ }
1065
+ expect(pages).toHaveLength(2);
1066
+ expect(pages[0].items.length).toBeGreaterThan(0);
1067
+ expect(pages[1].items.length).toBeGreaterThan(0);
1068
+ });
1069
+ test('getAllPages: currentPage increments correctly', async () => {
1070
+ const pages = [];
1071
+ for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 3, delayMs: 0 })) {
1072
+ pages.push(page);
1073
+ }
1074
+ expect(pages[0].currentPage).toBe(1);
1075
+ expect(pages[1].currentPage).toBe(2);
1076
+ expect(pages[2].currentPage).toBe(3);
1077
+ });
1078
+ test('getAllPages: total is consistent across pages', async () => {
1079
+ const pages = [];
1080
+ for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
1081
+ pages.push(page);
1082
+ }
1083
+ expect(pages[0].total).toBe(pages[1].total);
1084
+ expect(pages[0].totalPages).toBe(pages[1].totalPages);
1085
+ });
1086
+ test('getAllPages: page 2 items differ from page 1', async () => {
1087
+ const pages = [];
1088
+ for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 2, delayMs: 0 })) {
1089
+ pages.push(page);
1090
+ }
1091
+ expect(pages[0].items[0].billNo).not.toBe(pages[1].items[0].billNo);
1092
+ });
1093
+ test('getAllPages: concurrency > 1 yields pages in order', async () => {
1094
+ const pages = [];
1095
+ for await (const page of nsm.getAllPages({ pageSize: 5 }, { maxPages: 4, delayMs: 0, concurrency: 2 })) {
1096
+ pages.push(page);
1097
+ }
1098
+ expect(pages).toHaveLength(4);
1099
+ for (let i = 0; i < pages.length; i++) {
1100
+ expect(pages[i].currentPage).toBe(i + 1);
1101
+ }
1102
+ });
1103
+ test('getAllPendingPages: yields at least 1 page of pending bills', async () => {
1104
+ const pages = [];
1105
+ for await (const page of nsm.getAllPendingPages({ pageSize: 5 }, { maxPages: 1, delayMs: 0 })) {
1106
+ pages.push(page);
1107
+ }
1108
+ expect(pages.length).toBeGreaterThanOrEqual(1);
1109
+ // Every item must be in 발의 상태
1110
+ for (const page of pages) {
1111
+ for (const item of page.items) {
1112
+ expect(item.progressStatus).toBe('발의');
1113
+ }
1114
+ }
1115
+ });
1116
+ });
1117
+ // ── Screenshot functionality ──────────────────────────────────────────────
1118
+ describe('Screenshot functionality', () => {
1119
+ test('creates instance with screenshot config', () => {
1120
+ const instance = new pal_1.NsmLmSts({
1121
+ screenshot: {
1122
+ enabled: true,
1123
+ fullPage: true,
1124
+ width: 1280,
1125
+ height: 800,
1126
+ format: 'png',
1127
+ },
1128
+ });
1129
+ expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
1130
+ });
1131
+ test('throws error when screenshot is disabled', async () => {
1132
+ const instance = new pal_1.NsmLmSts({ screenshot: { enabled: false } });
1133
+ await expect(instance.takeScreenshot('https://example.com')).rejects.toThrow('Screenshot feature is not enabled');
1134
+ });
1135
+ test('accepts jpeg format with quality option', () => {
1136
+ const instance = new pal_1.NsmLmSts({
1137
+ screenshot: { enabled: true, format: 'jpeg', quality: 85 },
1138
+ });
1139
+ expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
1140
+ });
1141
+ test('updateScreenshotConfig modifies screenshot settings', () => {
1142
+ const instance = new pal_1.NsmLmSts({
1143
+ screenshot: { enabled: true, format: 'png' },
1144
+ });
1145
+ instance.updateScreenshotConfig({ format: 'jpeg', quality: 70 });
1146
+ expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
1147
+ });
1148
+ test('initBrowser and closeBrowser can be called', async () => {
1149
+ const instance = new pal_1.NsmLmSts({ screenshot: { enabled: true } });
1150
+ expect(instance).toBeInstanceOf(pal_1.NsmLmSts);
1151
+ });
1152
+ });
583
1153
  });
584
1154
  //# sourceMappingURL=pal.test.js.map