@witchpot/gmc 0.6.0 → 0.9.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.
@@ -3,6 +3,8 @@ import test from "node:test";
3
3
 
4
4
  import { parseArgs } from "./args.js";
5
5
  import { buildRequest } from "./requests.js";
6
+ import { printHelp } from "./help.js";
7
+ import { PROVISIONAL_BETA_COMMANDS } from "./constants.js";
6
8
 
7
9
  function buildRequestFromArgv(argv) {
8
10
  const { command, options, positional } = parseArgs(argv);
@@ -136,6 +138,48 @@ test("games count forwards coverage for cohort pre-estimation", () => {
136
138
  assert.equal(request.query.coverage, "full");
137
139
  });
138
140
 
141
+ /**
142
+ * NAK-247. Before this, --has-demo had no mapping in buildGameCountQuery: the
143
+ * generic arg parser (args.js) accepts any --flag with no allowlist for
144
+ * games search/count/aggregate, so a caller's --has-demo silently vanished
145
+ * rather than reaching the server — an accepted-then-dropped request that
146
+ * still gets charged unfiltered. Covers all three commands that share
147
+ * buildGameCountQuery, and both true/false (false is a real filter value,
148
+ * not "unset").
149
+ */
150
+ test("(NAK-247) games search/count/aggregate forward --has-demo as the hasDemo query param", () => {
151
+ const search = buildRequestFromArgv(["games", "search", "--source", "steam", "--has-demo", "true", "--json"]);
152
+ assert.equal(search.query.hasDemo, "true");
153
+
154
+ const count = buildRequestFromArgv(["games", "count", "--source", "steam", "--has-demo", "false", "--json"]);
155
+ assert.equal(count.query.hasDemo, "false");
156
+
157
+ const aggregate = buildRequestFromArgv([
158
+ "games",
159
+ "aggregate",
160
+ "--source",
161
+ "steam",
162
+ "--metric",
163
+ "count",
164
+ "--has-demo",
165
+ "true",
166
+ "--json"
167
+ ]);
168
+ assert.equal(aggregate.query.hasDemo, "true");
169
+ });
170
+
171
+ test("(NAK-247) --has-demo normalizes case and rejects a non-boolean value with a usage error rather than silently dropping the filter", () => {
172
+ const mixedCase = buildRequestFromArgv(["games", "count", "--source", "steam", "--has-demo", "FALSE", "--json"]);
173
+ assert.equal(mixedCase.query.hasDemo, "false");
174
+
175
+ const { output, exitCode } = captureUsageFailure(() =>
176
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--has-demo", "maybe", "--json"])
177
+ );
178
+ assert.equal(exitCode, 2);
179
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
180
+ assert.match(output.error.message, /--has-demo must be true or false/);
181
+ });
182
+
139
183
  test("reviews patterns forwards coverage into the cohort search request", () => {
140
184
  const request = buildRequestFromArgv([
141
185
  "reviews",
@@ -173,6 +217,69 @@ test("lists create builds a POST request body", () => {
173
217
  });
174
218
  });
175
219
 
220
+ test("lists games forwards limit, offset and cursor (NAK-525)", () => {
221
+ const request = buildRequestFromArgv(["lists", "games", "list-1", "--limit", "50", "--cursor", "c1", "--json"]);
222
+
223
+ assert.equal(request.path, "/api/v1/lists/list-1/games");
224
+ assert.equal(request.method, undefined);
225
+ // A dropped --cursor walks the list by offset while the caller believes it is
226
+ // following the cursor, which silently reorders a resolution-backed page.
227
+ assert.deepEqual(request.query, { limit: "50", cursor: "c1" });
228
+ });
229
+
230
+ test("lists resolve posts to the resolve endpoint with no body (NAK-525)", () => {
231
+ const request = buildRequestFromArgv(["lists", "resolve", "list-1", "--json"]);
232
+
233
+ assert.equal(request.path, "/api/v1/lists/list-1/resolve");
234
+ assert.equal(request.method, "POST");
235
+ assert.deepEqual(request.query, {});
236
+ assert.equal(request.body, undefined);
237
+ });
238
+
239
+ test("lists materialize posts with --confirm, and an empty body means the current revision (NAK-525)", () => {
240
+ const request = buildRequestFromArgv(["lists", "materialize", "list-1", "--confirm", "--json"]);
241
+
242
+ assert.equal(request.path, "/api/v1/lists/list-1/materialize");
243
+ assert.equal(request.method, "POST");
244
+ assert.deepEqual(request.query, {});
245
+ assert.equal(request.body, undefined);
246
+ });
247
+
248
+ test("lists materialize sends --expected-revision as the compare-and-swap body (NAK-525)", () => {
249
+ const request = buildRequestFromArgv([
250
+ "lists",
251
+ "materialize",
252
+ "list-1",
253
+ "--confirm",
254
+ "--expected-revision",
255
+ "4",
256
+ "--json"
257
+ ]);
258
+
259
+ assert.deepEqual(request.body, { expectedDefinitionRevision: 4 });
260
+ });
261
+
262
+ test("lists materialize and lists resolve are real command tokens, not positionals (NAK-525)", () => {
263
+ // A token missing from the args.js whitelist falls through to positional and
264
+ // silently runs the bare `gmc lists` index with exit 0 — the worst possible
265
+ // failure mode for a destructive-adjacent command.
266
+ for (const [subcommand, path] of [
267
+ ["materialize", "/api/v1/lists/list-1/materialize"],
268
+ ["resolve", "/api/v1/lists/list-1/resolve"]
269
+ ]) {
270
+ const { command, positional } = parseArgs(["lists", subcommand, "list-1", "--confirm", "--json"]);
271
+ assert.deepEqual(command, ["lists", subcommand]);
272
+ assert.deepEqual(positional, ["list-1"]);
273
+ assert.equal(buildRequest(command, { confirm: true, json: true }, positional).path, path);
274
+ }
275
+ });
276
+
277
+ test("--confirm is a boolean flag, so it does not swallow the flag after it (NAK-525)", () => {
278
+ const { options } = parseArgs(["lists", "materialize", "list-1", "--confirm", "--json"]);
279
+ assert.equal(options.confirm, true);
280
+ assert.equal(options.json, true);
281
+ });
282
+
176
283
  test("lists add maps comma-separated app ids to item mutation body", () => {
177
284
  const request = buildRequestFromArgv([
178
285
  "lists",
@@ -232,3 +339,961 @@ test("sampled workflows keep exact totals in a separate count request", () => {
232
339
  releaseTo: "2025-12-31"
233
340
  });
234
341
  });
342
+
343
+ test("showcases history builds a showcaseHistory request plan requesting the showcases analysis section", () => {
344
+ const request = buildRequestFromArgv([
345
+ "showcases",
346
+ "history",
347
+ "--source",
348
+ "steam",
349
+ "730",
350
+ "--json"
351
+ ]);
352
+
353
+ assert.equal(request.kind, "showcaseHistory");
354
+ assert.equal(request.source, "steam");
355
+ assert.equal(request.externalId, "730");
356
+ assert.deepEqual(request.detailQuery, { sections: "showcases" });
357
+ });
358
+
359
+ test("showcases history forwards locale", () => {
360
+ const request = buildRequestFromArgv([
361
+ "showcases",
362
+ "history",
363
+ "--source",
364
+ "steam",
365
+ "730",
366
+ "--locale",
367
+ "ja",
368
+ "--json"
369
+ ]);
370
+
371
+ assert.deepEqual(request.detailQuery, { sections: "showcases", locale: "ja" });
372
+ });
373
+
374
+ test("games more-like-this targets the canonical route with no country by default", () => {
375
+ const request = buildRequestFromArgv([
376
+ "games",
377
+ "more-like-this",
378
+ "--source",
379
+ "steam",
380
+ "730",
381
+ "--json"
382
+ ]);
383
+
384
+ assert.equal(request.path, "/api/v1/steam/games/730/more-like-this");
385
+ // The server owns the us default; the CLI must not send a country of its
386
+ // own invention, or the two defaults could drift apart.
387
+ assert.deepEqual(request.query, {});
388
+ });
389
+
390
+ test("games more-like-this forwards an explicit country, normalized", () => {
391
+ const request = buildRequestFromArgv([
392
+ "games",
393
+ "more-like-this",
394
+ "--source",
395
+ "steam",
396
+ "730",
397
+ "--country",
398
+ "JP",
399
+ "--json"
400
+ ]);
401
+
402
+ assert.equal(request.path, "/api/v1/steam/games/730/more-like-this");
403
+ assert.deepEqual(request.query, { country: "jp" });
404
+ });
405
+
406
+ // --- gmc games ai-disclosure (NAK-804) ------------------------------------
407
+ //
408
+ // A different route shape from every other `games` sub-path tested above:
409
+ // there is no --source flag (the endpoint is keyed on the plain numeric
410
+ // appid, not a --source-qualified externalId) and no query parameters at
411
+ // all, the review-history sibling's shape.
412
+
413
+ test("games ai-disclosure targets the canonical route", () => {
414
+ const request = buildRequestFromArgv(["games", "ai-disclosure", "730", "--json"]);
415
+
416
+ assert.equal(request.path, "/api/v1/games/730/ai-disclosure");
417
+ assert.deepEqual(request.query, {});
418
+ });
419
+
420
+ test("games ai-disclosure rejects a missing appid", () => {
421
+ const { output, exitCode } = captureUsageFailure(() =>
422
+ buildRequestFromArgv(["games", "ai-disclosure", "--json"])
423
+ );
424
+
425
+ assert.equal(exitCode, 2);
426
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
427
+ assert.match(output.error.message, /gmc games ai-disclosure <appid> --json/);
428
+ });
429
+
430
+ /**
431
+ * The appid is a plain positive decimal integer, matching the strict
432
+ * path-parameter check the app applies to every `/api/v1/.../games/{appid}`
433
+ * route. Each rejected spelling is asserted individually: a string like
434
+ * "7e2" or "0x2da" round-trips through `Number()` into a valid-looking
435
+ * integer, so a looser check here would let a value through that the API
436
+ * answers 400 for.
437
+ */
438
+ test("games ai-disclosure rejects non-numeric and malformed appid spellings", () => {
439
+ for (const badAppid of ["not-an-appid", "7e2", "0x2da", "730.5", "-730", "0", " 730", "730 "]) {
440
+ const { output, exitCode } = captureUsageFailure(() =>
441
+ buildRequestFromArgv(["games", "ai-disclosure", badAppid, "--json"])
442
+ );
443
+
444
+ assert.equal(exitCode, 2, `expected "${badAppid}" to be rejected`);
445
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
446
+ assert.match(output.error.message, /Missing or invalid appid/);
447
+ }
448
+ });
449
+
450
+ test("games ai-disclosure rejects request-shaping flags the endpoint does not have", () => {
451
+ for (const [flag, value] of [
452
+ ["--source", "steam"],
453
+ ["--locale", "ja"],
454
+ ["--limit", "50"]
455
+ ]) {
456
+ const { output, exitCode } = captureUsageFailure(() =>
457
+ buildRequestFromArgv(["games", "ai-disclosure", "730", flag, value, "--json"])
458
+ );
459
+
460
+ assert.equal(exitCode, 2, `expected ${flag} to be rejected`);
461
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
462
+ assert.match(
463
+ output.error.message,
464
+ new RegExp(`\\${flag} is not supported by gmc games ai-disclosure`)
465
+ );
466
+ }
467
+ });
468
+
469
+ /**
470
+ * Output shaping never reaches the wire, so it must not trip the allowlist.
471
+ */
472
+ test("games ai-disclosure still accepts the global output flags", () => {
473
+ const request = buildRequestFromArgv(["games", "ai-disclosure", "730", "--fields", "state", "--json"]);
474
+
475
+ assert.equal(request.path, "/api/v1/games/730/ai-disclosure");
476
+ assert.deepEqual(request.query, {});
477
+ });
478
+
479
+ test("games ai-disclosure rejects an extra positional argument", () => {
480
+ const { output, exitCode } = captureUsageFailure(() =>
481
+ buildRequestFromArgv(["games", "ai-disclosure", "730", "440", "--json"])
482
+ );
483
+
484
+ assert.equal(exitCode, 2);
485
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
486
+ assert.match(output.error.message, /Unexpected extra argument "440"/);
487
+ });
488
+
489
+ test("help documents gmc games ai-disclosure (NAK-804)", () => {
490
+ const chunks = [];
491
+ const original = process.stdout.write;
492
+ process.stdout.write = (chunk) => {
493
+ chunks.push(String(chunk));
494
+ return true;
495
+ };
496
+ try {
497
+ printHelp();
498
+ } finally {
499
+ process.stdout.write = original;
500
+ }
501
+ const help = chunks.join("");
502
+
503
+ assert.ok(help.includes("gmc games ai-disclosure <appid> --json"));
504
+ assert.ok(help.includes("never that the title\n uses no generative AI"));
505
+ });
506
+
507
+ test("games ai-disclosure is listed as Provisional (NAK-804)", () => {
508
+ // CHANGELOG, api.md and cli-use-cases.md all state that this command is
509
+ // Provisional in the beta command set. The help-output substring check
510
+ // above cannot pin this: "gmc games ai-disclosure <appid> --json" also
511
+ // appears verbatim in the Usage section on its own, so deleting this entry
512
+ // from PROVISIONAL_BETA_COMMANDS would leave every existing test green
513
+ // while the docs and reality diverge silently.
514
+ assert.ok(PROVISIONAL_BETA_COMMANDS.includes("gmc games ai-disclosure <appid> --json"));
515
+ });
516
+
517
+ // --- gmc games language-topics (NAK-455) ---------------------------------
518
+
519
+ test("games language-topics targets the canonical route and sends no invented parameters", () => {
520
+ const request = buildRequestFromArgv([
521
+ "games",
522
+ "language-topics",
523
+ "--source",
524
+ "steam",
525
+ "730",
526
+ "--json"
527
+ ]);
528
+
529
+ assert.equal(request.path, "/api/v1/steam/games/730/review-topics/languages");
530
+ // The server owns the min_reviews default (30) and the include_insufficient
531
+ // default (false). The CLI must not send a copy of either, or the two
532
+ // defaults could drift apart.
533
+ assert.deepEqual(request.query, {});
534
+ });
535
+
536
+ test("games language-topics forwards min_reviews and include_insufficient", () => {
537
+ const request = buildRequestFromArgv([
538
+ "games",
539
+ "language-topics",
540
+ "--source",
541
+ "steam",
542
+ "730",
543
+ "--min-reviews",
544
+ "50",
545
+ "--include-insufficient",
546
+ "--json"
547
+ ]);
548
+
549
+ assert.equal(request.path, "/api/v1/steam/games/730/review-topics/languages");
550
+ assert.deepEqual(request.query, { min_reviews: "50", include_insufficient: "true" });
551
+ });
552
+
553
+ test("games language-topics accepts both ends of the documented min_reviews range", () => {
554
+ for (const value of ["10", "200"]) {
555
+ const request = buildRequestFromArgv([
556
+ "games",
557
+ "language-topics",
558
+ "--source",
559
+ "steam",
560
+ "730",
561
+ "--min-reviews",
562
+ value,
563
+ "--json"
564
+ ]);
565
+
566
+ assert.deepEqual(request.query, { min_reviews: value });
567
+ }
568
+ });
569
+
570
+ test("games language-topics rejects an out-of-range min_reviews before any request is built", () => {
571
+ for (const value of ["9", "201", "30.5", "abc"]) {
572
+ const { output, exitCode } = captureUsageFailure(() =>
573
+ buildRequestFromArgv([
574
+ "games",
575
+ "language-topics",
576
+ "--source",
577
+ "steam",
578
+ "730",
579
+ "--min-reviews",
580
+ value,
581
+ "--json"
582
+ ])
583
+ );
584
+
585
+ assert.equal(exitCode, 2);
586
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
587
+ // Same bound, same wording direction as the REST 400 (param min_reviews).
588
+ assert.match(output.error.message, /--min-reviews must be an integer between 10 and 200/);
589
+ }
590
+ });
591
+
592
+ /**
593
+ * `Number("1e2")` and `Number("0x64")` are both 100, so a `Number`-based bounds
594
+ * check would forward these spellings to an endpoint that rejects them:
595
+ * `parseIntParam` (in the app's API validation module) tests `/^-?\d+$/` and
596
+ * answers 400 `min_reviews must be an integer`. The CLI must not turn a string
597
+ * the API refuses into a successful request.
598
+ */
599
+ test("games language-topics rejects the exponent and hex spellings REST rejects", () => {
600
+ for (const value of ["1e2", "0x64", "1_0", " 50", "+50", "50 "]) {
601
+ const { output, exitCode } = captureUsageFailure(() =>
602
+ buildRequestFromArgv([
603
+ "games",
604
+ "language-topics",
605
+ "--source",
606
+ "steam",
607
+ "730",
608
+ "--min-reviews",
609
+ value,
610
+ "--json"
611
+ ])
612
+ );
613
+
614
+ assert.equal(exitCode, 2, `expected --min-reviews ${JSON.stringify(value)} to be rejected`);
615
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
616
+ assert.match(output.error.message, /--min-reviews must be an integer between 10 and 200/);
617
+ }
618
+ });
619
+
620
+ /**
621
+ * Boolean flags are presence-only. `--include-insufficient=false` previously
622
+ * parsed to TRUE (the inline value was discarded), which inverted exactly the
623
+ * intent the caller spelled out. A usage error is the only reading that cannot
624
+ * silently do the opposite of what was asked.
625
+ */
626
+ test("a boolean flag rejects the --flag=value spelling instead of silently reading it as true", () => {
627
+ for (const flag of ["--include-insufficient=false", "--include-insufficient=true", "--json=false"]) {
628
+ const { output, exitCode } = captureUsageFailure(() =>
629
+ buildRequestFromArgv([
630
+ "games",
631
+ "language-topics",
632
+ "--source",
633
+ "steam",
634
+ "730",
635
+ flag,
636
+ "--json"
637
+ ])
638
+ );
639
+
640
+ assert.equal(exitCode, 2, `expected ${flag} to be rejected`);
641
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
642
+ assert.match(output.error.message, /is a flag and takes no value/);
643
+ }
644
+ });
645
+
646
+ /**
647
+ * NAK-428 found the same silent-ignore gap here: this endpoint's whole
648
+ * parameter surface is `min_reviews` + `include_insufficient`, so a --limit or
649
+ * --locale was accepted and dropped, and the caller's narrowing vanished with
650
+ * exit 0.
651
+ */
652
+ test("games language-topics rejects request-shaping flags the endpoint does not have", () => {
653
+ for (const [flag, value] of [
654
+ ["--limit", "50"],
655
+ ["--locale", "ja"],
656
+ ["--offset", "10"],
657
+ ["--language", "japanese"]
658
+ ]) {
659
+ const { output, exitCode } = captureUsageFailure(() =>
660
+ buildRequestFromArgv([
661
+ "games",
662
+ "language-topics",
663
+ "--source",
664
+ "steam",
665
+ "730",
666
+ flag,
667
+ value,
668
+ "--json"
669
+ ])
670
+ );
671
+
672
+ assert.equal(exitCode, 2, `expected ${flag} to be rejected`);
673
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
674
+ assert.match(
675
+ output.error.message,
676
+ new RegExp(`\\${flag} is not supported by gmc games language-topics`)
677
+ );
678
+ }
679
+ });
680
+
681
+ test("games language-topics rejects an extra positional argument", () => {
682
+ const { output, exitCode } = captureUsageFailure(() =>
683
+ buildRequestFromArgv(["games", "language-topics", "--source", "steam", "730", "440", "--json"])
684
+ );
685
+
686
+ assert.equal(exitCode, 2);
687
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
688
+ assert.match(output.error.message, /Unexpected extra argument "440"/);
689
+ });
690
+
691
+ test("the bare boolean flag still enables the option", () => {
692
+ const request = buildRequestFromArgv([
693
+ "games",
694
+ "language-topics",
695
+ "--source",
696
+ "steam",
697
+ "730",
698
+ "--include-insufficient",
699
+ "--json"
700
+ ]);
701
+
702
+ assert.deepEqual(request.query, { include_insufficient: "true" });
703
+ });
704
+
705
+ // --- gmc games review-history (NAK-428) ----------------------------------
706
+
707
+ test("games review-history targets the canonical route", () => {
708
+ const request = buildRequestFromArgv([
709
+ "games",
710
+ "review-history",
711
+ "--source",
712
+ "steam",
713
+ "730",
714
+ "--json"
715
+ ]);
716
+
717
+ assert.equal(request.path, "/api/v1/steam/games/730/review-history");
718
+ assert.deepEqual(request.query, {});
719
+ });
720
+
721
+ /**
722
+ * The endpoint takes NO query parameters. Range presets are a display concern
723
+ * the web UI applies client-side over the full series, so a --range/--from/
724
+ * --to/--limit here would make the CLI, rather than the service, decide which
725
+ * observations a caller sees.
726
+ *
727
+ * Not supporting them is not the same as tolerating them. Accepting the flag
728
+ * and dropping it returns the FULL series with exit 0, so the caller who asked
729
+ * for a window reads the unwindowed answer as the windowed one — the failure is
730
+ * invisible at exactly the moment it matters. Each rejection is asserted
731
+ * individually so a partial allowlist cannot pass on the strength of its
732
+ * neighbours.
733
+ */
734
+ test("games review-history rejects every request-shaping flag by name", () => {
735
+ for (const [flag, value] of [
736
+ ["--range", "1y"],
737
+ ["--from", "2026-01-01"],
738
+ ["--to", "2026-08-01"],
739
+ ["--limit", "50"],
740
+ ["--locale", "ja"],
741
+ ["--offset", "10"]
742
+ ]) {
743
+ const { output, exitCode } = captureUsageFailure(() =>
744
+ buildRequestFromArgv([
745
+ "games",
746
+ "review-history",
747
+ "--source",
748
+ "steam",
749
+ "730",
750
+ flag,
751
+ value,
752
+ "--json"
753
+ ])
754
+ );
755
+
756
+ assert.equal(exitCode, 2, `expected ${flag} to be rejected`);
757
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
758
+ // The message has to name the flag the caller typed: "unsupported option"
759
+ // alone leaves them guessing which of several it was.
760
+ assert.match(
761
+ output.error.message,
762
+ new RegExp(`\\${flag} is not supported by gmc games review-history`)
763
+ );
764
+ }
765
+ });
766
+
767
+ /**
768
+ * Output shaping never reaches the wire, so it must not trip the allowlist.
769
+ */
770
+ test("games review-history still accepts the global output flags", () => {
771
+ const request = buildRequestFromArgv([
772
+ "games",
773
+ "review-history",
774
+ "--source",
775
+ "steam",
776
+ "730",
777
+ "--fields",
778
+ "series",
779
+ "--json"
780
+ ]);
781
+
782
+ assert.equal(request.path, "/api/v1/steam/games/730/review-history");
783
+ assert.deepEqual(request.query, {});
784
+ });
785
+
786
+ /**
787
+ * A second bare argument is either a typo or a second appid the caller expected
788
+ * to be fetched. Both readings are worse when the first app's history comes
789
+ * back as if nothing were wrong.
790
+ */
791
+ test("games review-history rejects an extra positional argument", () => {
792
+ const { output, exitCode } = captureUsageFailure(() =>
793
+ buildRequestFromArgv(["games", "review-history", "--source", "steam", "730", "440", "--json"])
794
+ );
795
+
796
+ assert.equal(exitCode, 2);
797
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
798
+ assert.match(output.error.message, /Unexpected extra argument "440"/);
799
+ });
800
+
801
+ test("games review-history percent-encodes the external id into the path", () => {
802
+ const request = buildRequestFromArgv([
803
+ "games",
804
+ "review-history",
805
+ "--source",
806
+ "steam",
807
+ "7 30",
808
+ "--json"
809
+ ]);
810
+
811
+ assert.equal(request.path, "/api/v1/steam/games/7%2030/review-history");
812
+ });
813
+
814
+ test("games review-history rejects a missing external id", () => {
815
+ const { output, exitCode } = captureUsageFailure(() =>
816
+ buildRequestFromArgv(["games", "review-history", "--source", "steam", "--json"])
817
+ );
818
+
819
+ assert.equal(exitCode, 2);
820
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
821
+ assert.match(output.error.message, /gmc games review-history --source steam <externalId> --json/);
822
+ });
823
+
824
+ test("games review-history rejects a non-steam source", () => {
825
+ const { output, exitCode } = captureUsageFailure(() =>
826
+ buildRequestFromArgv(["games", "review-history", "--source", "epic", "730", "--json"])
827
+ );
828
+
829
+ assert.equal(exitCode, 2);
830
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
831
+ assert.match(output.error.message, /Only --source steam is supported/);
832
+ });
833
+
834
+ /**
835
+ * The CLI reports usage errors by writing a JSON error to stdout and calling
836
+ * process.exit — there is no throwing variant to assert on. Replacing exit
837
+ * with a throw is what makes the bounded-error path testable in-process; the
838
+ * captured code and payload are exactly what a real shell would see.
839
+ */
840
+ function captureUsageFailure(run) {
841
+ const originalWrite = process.stdout.write;
842
+ const originalExit = process.exit;
843
+ const chunks = [];
844
+ let exitCode;
845
+ process.stdout.write = (chunk) => {
846
+ chunks.push(String(chunk));
847
+ return true;
848
+ };
849
+ process.exit = (code) => {
850
+ exitCode = code;
851
+ throw new Error("__cli_exit__");
852
+ };
853
+ try {
854
+ run();
855
+ throw new Error("expected the CLI to exit with a usage error");
856
+ } catch (error) {
857
+ if (error.message !== "__cli_exit__") throw error;
858
+ } finally {
859
+ process.stdout.write = originalWrite;
860
+ process.exit = originalExit;
861
+ }
862
+
863
+ return { output: JSON.parse(chunks.join("")), exitCode };
864
+ }
865
+
866
+ test("games aggregate forwards as_of and compare_as_of (NAK-248)", () => {
867
+ const request = buildRequestFromArgv([
868
+ "games",
869
+ "aggregate",
870
+ "--source",
871
+ "steam",
872
+ "--metric",
873
+ "count",
874
+ "--group-by",
875
+ "release_year",
876
+ "--as-of",
877
+ "2026-08-01",
878
+ "--compare-as-of",
879
+ "2026-06-01",
880
+ "--json"
881
+ ]);
882
+
883
+ assert.equal(request.path, "/api/v1/steam/games/aggregate");
884
+ assert.equal(request.query.as_of, "2026-08-01");
885
+ assert.equal(request.query.compare_as_of, "2026-06-01");
886
+ });
887
+
888
+ test("games aggregate omits the comparison params when they are not supplied", () => {
889
+ const request = buildRequestFromArgv([
890
+ "games",
891
+ "aggregate",
892
+ "--source",
893
+ "steam",
894
+ "--metric",
895
+ "count",
896
+ "--json"
897
+ ]);
898
+
899
+ assert.equal("as_of" in request.query, false);
900
+ assert.equal("compare_as_of" in request.query, false);
901
+ });
902
+
903
+ test("help documents the v2 query envelope and both new lists commands (NAK-525)", () => {
904
+ const chunks = [];
905
+ const original = process.stdout.write;
906
+ process.stdout.write = (chunk) => {
907
+ chunks.push(String(chunk));
908
+ return true;
909
+ };
910
+ try {
911
+ printHelp();
912
+ } finally {
913
+ process.stdout.write = original;
914
+ }
915
+ const help = chunks.join("");
916
+
917
+ assert.ok(help.includes("gmc lists resolve <id> --json"));
918
+ assert.ok(help.includes("gmc lists materialize <id> --confirm --json"));
919
+ assert.ok(help.includes("gmc lists games <id> --limit <number> --cursor <cursor> --json"));
920
+ // The envelope itself, not just its name: a caller composing --filter needs
921
+ // the shape and the rule that structured decides membership.
922
+ assert.ok(help.includes('"version":2,"mode":"query"'));
923
+ assert.ok(help.includes("structured decides membership"));
924
+ assert.ok(help.includes("total_kind"));
925
+ assert.ok(help.includes("RESOLUTION_REQUIRED"));
926
+ });
927
+
928
+ /**
929
+ * NAK-207. `--event-name` reaches games search/count/aggregate through the
930
+ * SAME shared query builder as every other filter, so an event cohort can be
931
+ * combined with tags and bounds in one command. Validating the slug is the
932
+ * server's job (INVALID_EVENT with suggestions); the CLI only refuses a blank
933
+ * value, which the query parser would otherwise drop, leaving the caller
934
+ * reading the whole catalog as an event cohort.
935
+ */
936
+ test("(NAK-207) games search/count/aggregate forward --event-name as the eventName query param", () => {
937
+ const search = buildRequestFromArgv([
938
+ "games", "search", "--source", "steam", "--event-name", "tokyo_game_dungeon_2026", "--json"
939
+ ]);
940
+ assert.equal(search.query.eventName, "tokyo_game_dungeon_2026");
941
+
942
+ const count = buildRequestFromArgv([
943
+ "games", "count", "--source", "steam", "--event-name", "nextfest_june_2026", "--json"
944
+ ]);
945
+ assert.equal(count.query.eventName, "nextfest_june_2026");
946
+
947
+ const aggregate = buildRequestFromArgv([
948
+ "games", "aggregate", "--source", "steam", "--metric", "count",
949
+ "--event-name", "nextfest_june_2026", "--json"
950
+ ]);
951
+ assert.equal(aggregate.query.eventName, "nextfest_june_2026");
952
+ });
953
+
954
+ test("(NAK-207) --event-name composes with the other filters instead of replacing them", () => {
955
+ const request = buildRequestFromArgv([
956
+ "games", "count", "--source", "steam",
957
+ "--event-name", "tokyo_game_dungeon_2026",
958
+ "--tags", "roguelite",
959
+ "--rating-min", "80",
960
+ "--json"
961
+ ]);
962
+ assert.equal(request.query.eventName, "tokyo_game_dungeon_2026");
963
+ assert.equal(request.query.tags, "roguelite");
964
+ assert.equal(request.query.ratingMin, "80");
965
+ });
966
+
967
+ test("(NAK-207) a blank --event-name is a usage error, not a silently unfiltered request", () => {
968
+ const { output, exitCode } = captureUsageFailure(() =>
969
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--event-name", " ", "--json"])
970
+ );
971
+ assert.equal(exitCode, 2);
972
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
973
+ assert.match(output.error.message, /--event-name must be a non-empty Steam event slug/);
974
+ });
975
+
976
+ /**
977
+ * NAK-587. `--catalog-as-of` reaches games search/count/aggregate through the
978
+ * SAME shared query builder as every other filter (it is a filter, not a
979
+ * snapshot dial like `--as-of`), and it is forwarded under the REST spelling
980
+ * `catalog_as_of` rather than a camelCase one — matching `as_of` /
981
+ * `compare_as_of`, the other date parameters.
982
+ */
983
+ test("(NAK-587) games search/count/aggregate forward --catalog-as-of as the catalog_as_of query param", () => {
984
+ const search = buildRequestFromArgv([
985
+ "games", "search", "--source", "steam", "--catalog-as-of", "2026-08-01", "--json"
986
+ ]);
987
+ assert.equal(search.query.catalog_as_of, "2026-08-01");
988
+
989
+ const count = buildRequestFromArgv([
990
+ "games", "count", "--source", "steam", "--catalog-as-of", "2026-08-01", "--json"
991
+ ]);
992
+ assert.equal(count.query.catalog_as_of, "2026-08-01");
993
+
994
+ const aggregate = buildRequestFromArgv([
995
+ "games", "aggregate", "--source", "steam", "--metric", "count",
996
+ "--catalog-as-of", "2026-08-01", "--json"
997
+ ]);
998
+ assert.equal(aggregate.query.catalog_as_of, "2026-08-01");
999
+ });
1000
+
1001
+ test("(NAK-587) --catalog-as-of composes with the other filters and with --as-of", () => {
1002
+ const request = buildRequestFromArgv([
1003
+ "games", "aggregate", "--source", "steam", "--metric", "count",
1004
+ "--catalog-as-of", "2026-08-01",
1005
+ "--as-of", "2026-08-15",
1006
+ "--tags", "roguelite",
1007
+ "--rating-min", "80",
1008
+ "--json"
1009
+ ]);
1010
+ // The two dates are different dials and must both survive: `as_of` chooses
1011
+ // the metric snapshot, `catalog_as_of` chooses the population.
1012
+ assert.equal(request.query.catalog_as_of, "2026-08-01");
1013
+ assert.equal(request.query.as_of, "2026-08-15");
1014
+ assert.equal(request.query.tags, "roguelite");
1015
+ assert.equal(request.query.ratingMin, "80");
1016
+ });
1017
+
1018
+ test("(NAK-587) a blank --catalog-as-of is a usage error, not a silently unfiltered request", () => {
1019
+ const { output, exitCode } = captureUsageFailure(() =>
1020
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--catalog-as-of", " ", "--json"])
1021
+ );
1022
+ assert.equal(exitCode, 2);
1023
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1024
+ assert.match(output.error.message, /--catalog-as-of must be a date in YYYY-MM-DD format/);
1025
+ });
1026
+
1027
+ test("(NAK-587) an omitted --catalog-as-of leaves the query untouched", () => {
1028
+ const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--json"]);
1029
+ assert.equal("catalog_as_of" in request.query, false);
1030
+ });
1031
+
1032
+ /**
1033
+ * NAK-773. `--ai-disclosure` reaches games search/count/aggregate through the
1034
+ * SAME shared query builder as every other filter, and is forwarded verbatim
1035
+ * (kebab-case flag to camelCase query param) as the three literal values the
1036
+ * server matches case-sensitively (assertAiDisclosureFilterValue, in the
1037
+ * app's AI-disclosure repository module) -- the CLI does not
1038
+ * lower-case or trim a typo into a value the caller did not send.
1039
+ */
1040
+ test("(NAK-773) games search/count/aggregate forward --ai-disclosure as the aiDisclosure query param", () => {
1041
+ const search = buildRequestFromArgv([
1042
+ "games", "search", "--source", "steam", "--ai-disclosure", "disclosed", "--json"
1043
+ ]);
1044
+ assert.equal(search.query.aiDisclosure, "disclosed");
1045
+
1046
+ const count = buildRequestFromArgv([
1047
+ "games", "count", "--source", "steam", "--ai-disclosure", "absent", "--json"
1048
+ ]);
1049
+ assert.equal(count.query.aiDisclosure, "absent");
1050
+
1051
+ const aggregate = buildRequestFromArgv([
1052
+ "games", "aggregate", "--source", "steam", "--metric", "count",
1053
+ "--ai-disclosure", "unconfirmed", "--json"
1054
+ ]);
1055
+ assert.equal(aggregate.query.aiDisclosure, "unconfirmed");
1056
+ });
1057
+
1058
+ test("(NAK-773) --ai-disclosure accepts each of the three enum values", () => {
1059
+ for (const value of ["disclosed", "absent", "unconfirmed"]) {
1060
+ const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--ai-disclosure", value, "--json"]);
1061
+ assert.equal(request.query.aiDisclosure, value);
1062
+ }
1063
+ });
1064
+
1065
+ test("(NAK-773) --ai-disclosure composes with the other filters instead of replacing them", () => {
1066
+ const request = buildRequestFromArgv([
1067
+ "games", "count", "--source", "steam",
1068
+ "--ai-disclosure", "disclosed",
1069
+ "--tags", "roguelite",
1070
+ "--rating-min", "80",
1071
+ "--json"
1072
+ ]);
1073
+ assert.equal(request.query.aiDisclosure, "disclosed");
1074
+ assert.equal(request.query.tags, "roguelite");
1075
+ assert.equal(request.query.ratingMin, "80");
1076
+ });
1077
+
1078
+ test("(NAK-773) an invalid --ai-disclosure value is a usage error, not a silently unfiltered request", () => {
1079
+ const { output, exitCode } = captureUsageFailure(() =>
1080
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--ai-disclosure", "confirmed", "--json"])
1081
+ );
1082
+ assert.equal(exitCode, 2);
1083
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1084
+ assert.match(output.error.message, /--ai-disclosure must be one of: disclosed, absent, unconfirmed/);
1085
+ });
1086
+
1087
+ test("(NAK-773) --ai-disclosure is case-sensitive: a differently-cased value is rejected, not normalized", () => {
1088
+ const { output, exitCode } = captureUsageFailure(() =>
1089
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--ai-disclosure", "Disclosed", "--json"])
1090
+ );
1091
+ assert.equal(exitCode, 2);
1092
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1093
+ assert.match(output.error.message, /--ai-disclosure must be one of: disclosed, absent, unconfirmed/);
1094
+ });
1095
+
1096
+ test("(NAK-773) a blank --ai-disclosure is a usage error, not a silently unfiltered request", () => {
1097
+ const { output, exitCode } = captureUsageFailure(() =>
1098
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--ai-disclosure", " ", "--json"])
1099
+ );
1100
+ assert.equal(exitCode, 2);
1101
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1102
+ assert.match(output.error.message, /--ai-disclosure must be one of: disclosed, absent, unconfirmed/);
1103
+ });
1104
+
1105
+ test("(NAK-773) an omitted --ai-disclosure leaves the query untouched", () => {
1106
+ const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--json"]);
1107
+ assert.equal("aiDisclosure" in request.query, false);
1108
+ });
1109
+
1110
+ /**
1111
+ * NAK-773. The aggregate `--group-by` cross-tab is a plain pass-through
1112
+ * string (buildGameAggregateQuery forwards it verbatim via pickQuery), so a
1113
+ * multi-dimension cross-tab like `release_month,ai_disclosure` needs no
1114
+ * special-casing here -- proving that is the point of this test, not
1115
+ * asserting new behavior.
1116
+ */
1117
+ test("(NAK-773) --group-by release_month,ai_disclosure flows through the aggregate builder unchanged", () => {
1118
+ const request = buildRequestFromArgv([
1119
+ "games", "aggregate", "--source", "steam", "--metric", "count",
1120
+ "--group-by", "release_month,ai_disclosure",
1121
+ "--json"
1122
+ ]);
1123
+ assert.equal(request.query.groupBy, "release_month,ai_disclosure");
1124
+ });
1125
+
1126
+ test("(NAK-773) --ai-disclosure composes with the release_month,ai_disclosure cross-tab", () => {
1127
+ const request = buildRequestFromArgv([
1128
+ "games", "aggregate", "--source", "steam", "--metric", "count",
1129
+ "--group-by", "release_month,ai_disclosure",
1130
+ "--ai-disclosure", "unconfirmed",
1131
+ "--json"
1132
+ ]);
1133
+ assert.equal(request.query.groupBy, "release_month,ai_disclosure");
1134
+ assert.equal(request.query.aiDisclosure, "unconfirmed");
1135
+ });
1136
+
1137
+ /**
1138
+ * NAK-803. `--release-status` closes the CLI/REST divergence the
1139
+ * AI-disclosure chart-ready recipe documented: `releaseStatus` was reachable
1140
+ * on the REST endpoint but had no CLI mapping, so the CLI variant of a
1141
+ * "released games only" question had to rely on the release window alone.
1142
+ * The three values are the closed set the server enforces
1143
+ * (RELEASE_STATUS_FILTER_VALUES in the app's games repository module, and
1144
+ * the same literal triple the market RPCs re-check), so the CLI rejects
1145
+ * anything outside it locally rather than normalizing a typo into a value
1146
+ * the caller did not send.
1147
+ */
1148
+ test("(NAK-803) games search/count/aggregate forward --release-status as the releaseStatus query param", () => {
1149
+ const search = buildRequestFromArgv([
1150
+ "games", "search", "--source", "steam", "--release-status", "released", "--json"
1151
+ ]);
1152
+ assert.equal(search.query.releaseStatus, "released");
1153
+
1154
+ const count = buildRequestFromArgv([
1155
+ "games", "count", "--source", "steam", "--release-status", "upcoming", "--json"
1156
+ ]);
1157
+ assert.equal(count.query.releaseStatus, "upcoming");
1158
+
1159
+ const aggregate = buildRequestFromArgv([
1160
+ "games", "aggregate", "--source", "steam", "--metric", "count",
1161
+ "--release-status", "tba", "--json"
1162
+ ]);
1163
+ assert.equal(aggregate.query.releaseStatus, "tba");
1164
+ });
1165
+
1166
+ test("(NAK-803) --release-status accepts each of the three enum values", () => {
1167
+ for (const value of ["released", "upcoming", "tba"]) {
1168
+ const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", value, "--json"]);
1169
+ assert.equal(request.query.releaseStatus, value);
1170
+ }
1171
+ });
1172
+
1173
+ test("(NAK-803) --release-status composes with the other filters instead of replacing them", () => {
1174
+ const request = buildRequestFromArgv([
1175
+ "games", "count", "--source", "steam",
1176
+ "--release-status", "released",
1177
+ "--ai-disclosure", "disclosed",
1178
+ "--tags", "roguelite",
1179
+ "--rating-min", "80",
1180
+ "--json"
1181
+ ]);
1182
+ assert.equal(request.query.releaseStatus, "released");
1183
+ assert.equal(request.query.aiDisclosure, "disclosed");
1184
+ assert.equal(request.query.tags, "roguelite");
1185
+ assert.equal(request.query.ratingMin, "80");
1186
+ });
1187
+
1188
+ test("(NAK-803) an invalid --release-status value is a usage error, not a silently unfiltered request", () => {
1189
+ const { output, exitCode } = captureUsageFailure(() =>
1190
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", "launched", "--json"])
1191
+ );
1192
+ assert.equal(exitCode, 2);
1193
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1194
+ assert.match(output.error.message, /--release-status must be one of: released, upcoming, tba/);
1195
+ });
1196
+
1197
+ test("(NAK-803) --release-status is case-sensitive: a differently-cased value is rejected, not normalized", () => {
1198
+ const { output, exitCode } = captureUsageFailure(() =>
1199
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", "Released", "--json"])
1200
+ );
1201
+ assert.equal(exitCode, 2);
1202
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1203
+ assert.match(output.error.message, /--release-status must be one of: released, upcoming, tba/);
1204
+ });
1205
+
1206
+ test("(NAK-803) a blank --release-status is a usage error, not a silently unfiltered request", () => {
1207
+ const { output, exitCode } = captureUsageFailure(() =>
1208
+ buildRequestFromArgv(["games", "count", "--source", "steam", "--release-status", " ", "--json"])
1209
+ );
1210
+ assert.equal(exitCode, 2);
1211
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1212
+ assert.match(output.error.message, /--release-status must be one of: released, upcoming, tba/);
1213
+ });
1214
+
1215
+ test("(NAK-803) an omitted --release-status leaves the query untouched", () => {
1216
+ const request = buildRequestFromArgv(["games", "count", "--source", "steam", "--json"]);
1217
+ assert.equal("releaseStatus" in request.query, false);
1218
+ });
1219
+
1220
+ /**
1221
+ * NAK-706: `gmc steam tag-trends`.
1222
+ *
1223
+ * The CLI is a pass-through here on purpose — the per-view allowlist, the enum
1224
+ * members and the two limit ceilings live on the server, so these tests pin
1225
+ * the MAPPING (flag name to query parameter, and omission when unset) rather
1226
+ * than restating a contract the CLI does not own.
1227
+ */
1228
+ test("(NAK-706) steam tag-trends defaults to the bare endpoint and lets the server pick the view", () => {
1229
+ const request = buildRequestFromArgv(["steam", "tag-trends", "--json"]);
1230
+
1231
+ assert.equal(request.path, "/api/v1/steam/tag-trends");
1232
+ // Empty, not {view: "featured"}: a default copied here could drift from the
1233
+ // server's.
1234
+ assert.deepEqual(request.query, {});
1235
+ });
1236
+
1237
+ test("(NAK-706) steam tag-trends maps every flag to its query parameter", () => {
1238
+ const request = buildRequestFromArgv([
1239
+ "steam", "tag-trends",
1240
+ "--view", "table",
1241
+ "--family", "mechanic",
1242
+ "--q", "cozy",
1243
+ "--supply-direction", "up",
1244
+ "--sort", "current_share",
1245
+ "--order", "asc",
1246
+ "--limit", "50",
1247
+ "--cursor", "abc.def",
1248
+ "--json"
1249
+ ]);
1250
+
1251
+ assert.equal(request.path, "/api/v1/steam/tag-trends");
1252
+ assert.deepEqual(request.query, {
1253
+ view: "table",
1254
+ family: "mechanic",
1255
+ q: "cozy",
1256
+ // The one renaming: the CLI's kebab-case flag reaches the endpoint's
1257
+ // snake_case parameter.
1258
+ supply_direction: "up",
1259
+ sort: "current_share",
1260
+ order: "asc",
1261
+ limit: "50",
1262
+ cursor: "abc.def"
1263
+ });
1264
+ });
1265
+
1266
+ test("(NAK-706) steam tag-trends forwards --tag for the single-tag view", () => {
1267
+ const request = buildRequestFromArgv(["steam", "tag-trends", "--view", "tag", "--tag", "cozy", "--json"]);
1268
+
1269
+ assert.deepEqual(request.query, { view: "tag", tag: "cozy" });
1270
+ });
1271
+
1272
+ test("(NAK-706) a flag the endpoint has no parameter for is a usage error, not a dropped narrowing", () => {
1273
+ const { output, exitCode } = captureUsageFailure(() =>
1274
+ buildRequestFromArgv(["steam", "tag-trends", "--view", "board", "--source", "steam", "--json"])
1275
+ );
1276
+
1277
+ assert.equal(exitCode, 2);
1278
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1279
+ assert.match(output.error.message, /--source is not supported by gmc steam tag-trends/);
1280
+ });
1281
+
1282
+ test("(NAK-706) a stray positional is a usage error rather than a silently ignored argument", () => {
1283
+ const { output, exitCode } = captureUsageFailure(() =>
1284
+ buildRequestFromArgv(["steam", "tag-trends", "cozy", "--json"])
1285
+ );
1286
+
1287
+ assert.equal(exitCode, 2);
1288
+ assert.equal(output.error.code, "CLI_USAGE_ERROR");
1289
+ assert.match(output.error.message, /Unexpected extra argument "cozy"/);
1290
+ });
1291
+
1292
+ test('trend-stories preserves story, edition, locale and list pagination options', () => {
1293
+ assert.deepEqual(buildRequestFromArgv(['steam', 'trend-stories', '--story', 'story-id', '--edition', 'edition-id', '--locale', 'ja', '--json']), {
1294
+ path: '/api/v1/steam/trend-stories', query: { story: 'story-id', edition: 'edition-id', locale: 'ja' }
1295
+ });
1296
+ assert.deepEqual(buildRequestFromArgv(['steam', 'trend-stories', '--tag', 'cozy', '--limit', '1', '--offset', '1', '--fields', 'summary', '--json']).query, { tag: 'cozy', limit: '1', offset: '1', fields: 'summary' });
1297
+ assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', '--family', 'mood'])).exitCode, 2);
1298
+ assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', 'unexpected'])).exitCode, 2);
1299
+ });