@witchpot/gmc 0.8.0 → 0.10.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/CHANGELOG.md +52 -0
- package/package.json +1 -2
- package/skill/gmc-analysis/CHANGELOG.md +25 -0
- package/skill/gmc-analysis/SKILL.md +42 -3
- package/skill/gmc-analysis.manifest.json +2 -2
- package/src/args.js +9 -1
- package/src/constants.js +13 -2
- package/src/help.js +99 -0
- package/src/main.js +7 -1
- package/src/main.test.js +130 -0
- package/src/projection.js +21 -0
- package/src/requests.js +234 -0
- package/src/requests.test.js +369 -3
- package/scripts/check-release-readiness.mjs +0 -177
- package/scripts/gen-skill-manifest.mjs +0 -151
- package/scripts/smoke-beta.mjs +0 -246
- package/scripts/smoke-pack.mjs +0 -123
package/src/requests.js
CHANGED
|
@@ -387,6 +387,29 @@ export function buildRequest(command, options, positional) {
|
|
|
387
387
|
};
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
// NAK-804. A different route shape from every other `games` sub-path above:
|
|
391
|
+
// there is exactly one game namespace behind it, keyed on the plain numeric
|
|
392
|
+
// appid, so there is no --source flag to select and no `assertSource` call.
|
|
393
|
+
// The endpoint takes no query parameters at all (the review-history
|
|
394
|
+
// sibling's shape), so `assertSupportedOptions` closes the same gap that
|
|
395
|
+
// one documents: an unsupported flag would otherwise be silently dropped
|
|
396
|
+
// and answer with exit 0 as if the narrowing had been applied.
|
|
397
|
+
if (matches(command, ["games", "ai-disclosure"])) {
|
|
398
|
+
assertSupportedOptions(options, {
|
|
399
|
+
command: "gmc games ai-disclosure",
|
|
400
|
+
allowed: []
|
|
401
|
+
});
|
|
402
|
+
const appid = assertAppid(positional[0], "gmc games ai-disclosure <appid> --json");
|
|
403
|
+
assertNoExtraPositionals(positional, {
|
|
404
|
+
expected: 1,
|
|
405
|
+
usage: "gmc games ai-disclosure <appid> --json"
|
|
406
|
+
});
|
|
407
|
+
return {
|
|
408
|
+
path: `/api/v1/games/${encodeURIComponent(String(appid))}/ai-disclosure`,
|
|
409
|
+
query: {}
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
390
413
|
if (matches(command, ["reviews", "patterns"])) {
|
|
391
414
|
assertSource(options.source);
|
|
392
415
|
const isCompare = positional[0] === "compare";
|
|
@@ -627,6 +650,137 @@ export function buildRequest(command, options, positional) {
|
|
|
627
650
|
};
|
|
628
651
|
}
|
|
629
652
|
|
|
653
|
+
// NAK-715. Thin pass-throughs to the Roblox read endpoints, on the same rule
|
|
654
|
+
// the tag-trends branch above documents: the CLI forwards the flags it was
|
|
655
|
+
// given and the SERVER owns the contract — the per-endpoint allowlists, the
|
|
656
|
+
// enum members, the limit ceilings, the window bounds and the cursor
|
|
657
|
+
// binding. `assertSupportedOptions` still runs for the narrower thing it is
|
|
658
|
+
// for: a flag the endpoint has no parameter for would otherwise be dropped
|
|
659
|
+
// silently and answer the unnarrowed question with exit 0.
|
|
660
|
+
//
|
|
661
|
+
// A Roblox universe id is NOT a Steam appid and is never sent as a number.
|
|
662
|
+
// It travels as the decimal string the user typed, because these ids exceed
|
|
663
|
+
// the range a JS number represents exactly.
|
|
664
|
+
if (matches(command, ["roblox", "charts"])) {
|
|
665
|
+
assertSupportedOptions(options, {
|
|
666
|
+
command: "gmc roblox charts",
|
|
667
|
+
allowed: ["country", "device", "sort", "as-of", "limit", "offset"]
|
|
668
|
+
});
|
|
669
|
+
assertNoExtraPositionals(positional, {
|
|
670
|
+
expected: 0,
|
|
671
|
+
usage: "gmc roblox charts --country all --sort top-playing-now --json"
|
|
672
|
+
});
|
|
673
|
+
return {
|
|
674
|
+
path: "/api/v1/roblox/charts",
|
|
675
|
+
// Every value is omitted when unset so the server owns each default and
|
|
676
|
+
// no default can drift into a copy kept here.
|
|
677
|
+
query: pickQuery(options, {
|
|
678
|
+
country: "country",
|
|
679
|
+
device: "device",
|
|
680
|
+
sort: "sort",
|
|
681
|
+
"as-of": "as_of",
|
|
682
|
+
limit: "limit",
|
|
683
|
+
offset: "offset"
|
|
684
|
+
})
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (matches(command, ["roblox", "experiences"])) {
|
|
689
|
+
assertSupportedOptions(options, {
|
|
690
|
+
command: "gmc roblox experiences",
|
|
691
|
+
allowed: [
|
|
692
|
+
"country",
|
|
693
|
+
"device",
|
|
694
|
+
"sort",
|
|
695
|
+
"as-of",
|
|
696
|
+
"eligibility-days",
|
|
697
|
+
"min-player-count",
|
|
698
|
+
"max-rank",
|
|
699
|
+
"genre",
|
|
700
|
+
"name",
|
|
701
|
+
"creator-type",
|
|
702
|
+
"detail",
|
|
703
|
+
"order-by",
|
|
704
|
+
"order",
|
|
705
|
+
"limit",
|
|
706
|
+
"cursor"
|
|
707
|
+
]
|
|
708
|
+
});
|
|
709
|
+
assertNoExtraPositionals(positional, {
|
|
710
|
+
expected: 0,
|
|
711
|
+
usage: "gmc roblox experiences --country all --limit 50 --json"
|
|
712
|
+
});
|
|
713
|
+
return {
|
|
714
|
+
path: "/api/v1/roblox/experiences",
|
|
715
|
+
query: pickQuery(options, {
|
|
716
|
+
country: "country",
|
|
717
|
+
device: "device",
|
|
718
|
+
sort: "sort",
|
|
719
|
+
"as-of": "as_of",
|
|
720
|
+
"eligibility-days": "eligibility_days",
|
|
721
|
+
"min-player-count": "min_player_count",
|
|
722
|
+
"max-rank": "max_rank",
|
|
723
|
+
genre: "genre",
|
|
724
|
+
name: "name",
|
|
725
|
+
"creator-type": "creator_type",
|
|
726
|
+
detail: "detail",
|
|
727
|
+
"order-by": "order_by",
|
|
728
|
+
order: "order",
|
|
729
|
+
limit: "limit",
|
|
730
|
+
cursor: "cursor"
|
|
731
|
+
})
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (matches(command, ["roblox", "experience"])) {
|
|
736
|
+
assertSupportedOptions(options, { command: "gmc roblox experience", allowed: ["as-of"] });
|
|
737
|
+
const universeId = assertUniverseId(
|
|
738
|
+
positional[0],
|
|
739
|
+
"gmc roblox experience <universeId> --json"
|
|
740
|
+
);
|
|
741
|
+
assertNoExtraPositionals(positional, {
|
|
742
|
+
expected: 1,
|
|
743
|
+
usage: "gmc roblox experience <universeId> --json"
|
|
744
|
+
});
|
|
745
|
+
return {
|
|
746
|
+
path: `/api/v1/roblox/experiences/${encodeURIComponent(universeId)}`,
|
|
747
|
+
query: pickQuery(options, { "as-of": "as_of" })
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (matches(command, ["roblox", "history"])) {
|
|
752
|
+
assertSupportedOptions(options, {
|
|
753
|
+
command: "gmc roblox history",
|
|
754
|
+
allowed: ["granularity", "from", "to", "as-of"]
|
|
755
|
+
});
|
|
756
|
+
const universeId = assertUniverseId(
|
|
757
|
+
positional[0],
|
|
758
|
+
"gmc roblox history <universeId> --granularity daily --json"
|
|
759
|
+
);
|
|
760
|
+
assertNoExtraPositionals(positional, {
|
|
761
|
+
expected: 1,
|
|
762
|
+
usage: "gmc roblox history <universeId> --granularity daily --json"
|
|
763
|
+
});
|
|
764
|
+
return {
|
|
765
|
+
path: `/api/v1/roblox/experiences/${encodeURIComponent(universeId)}/history`,
|
|
766
|
+
query: pickQuery(options, {
|
|
767
|
+
granularity: "granularity",
|
|
768
|
+
from: "from",
|
|
769
|
+
to: "to",
|
|
770
|
+
"as-of": "as_of"
|
|
771
|
+
})
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (matches(command, ["roblox", "coverage"])) {
|
|
776
|
+
assertSupportedOptions(options, { command: "gmc roblox coverage", allowed: ["as-of"] });
|
|
777
|
+
assertNoExtraPositionals(positional, { expected: 0, usage: "gmc roblox coverage --json" });
|
|
778
|
+
return {
|
|
779
|
+
path: "/api/v1/roblox/coverage",
|
|
780
|
+
query: pickQuery(options, { "as-of": "as_of" })
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
|
|
630
784
|
return null;
|
|
631
785
|
}
|
|
632
786
|
|
|
@@ -749,6 +903,13 @@ function buildOptionalGameCountRequest(options) {
|
|
|
749
903
|
// server would have rejected.
|
|
750
904
|
const AI_DISCLOSURE_VALUES = ["disclosed", "absent", "unconfirmed"];
|
|
751
905
|
|
|
906
|
+
// Mirrors RELEASE_STATUS_FILTER_VALUES in the app's games repository module
|
|
907
|
+
// (NAK-803), which the market RPCs re-check against the same literal triple.
|
|
908
|
+
// Kept as a local literal for the same reason as AI_DISCLOSURE_VALUES above:
|
|
909
|
+
// the CLI package cannot import server code, and the server remains the
|
|
910
|
+
// enforcing authority.
|
|
911
|
+
const RELEASE_STATUS_VALUES = ["released", "upcoming", "tba"];
|
|
912
|
+
|
|
752
913
|
function buildGameCountQuery(options) {
|
|
753
914
|
const query = pickQuery(options, {
|
|
754
915
|
query: "q",
|
|
@@ -834,6 +995,26 @@ function buildGameCountQuery(options) {
|
|
|
834
995
|
AI_DISCLOSURE_VALUES
|
|
835
996
|
);
|
|
836
997
|
}
|
|
998
|
+
// NAK-803: --release-status is a FILTER, so it lives in the shared
|
|
999
|
+
// count-query builder and reaches games search, count and aggregate alike.
|
|
1000
|
+
// `releaseStatus` was already in the REST contract but had no CLI mapping,
|
|
1001
|
+
// which forced the CLI form of a "released games only" question to lean on
|
|
1002
|
+
// the release window alone (see docs/cli-use-cases.md section 67). Like
|
|
1003
|
+
// --ai-disclosure, the three values are a closed set the server enforces
|
|
1004
|
+
// case-sensitively, so a mistyped value is a usage error here rather than a
|
|
1005
|
+
// silently-unfiltered, still-charged request.
|
|
1006
|
+
//
|
|
1007
|
+
// Note this filter is an exact match layered ON TOP of the unconditional
|
|
1008
|
+
// stale-upcoming guard (NAK-78): a title still marked `upcoming` past its
|
|
1009
|
+
// provisional release date is dropped from every query regardless of this
|
|
1010
|
+
// flag, so `--release-status upcoming` never resurrects one.
|
|
1011
|
+
if (options["release-status"] != null) {
|
|
1012
|
+
query.releaseStatus = assertEnumQueryOption(
|
|
1013
|
+
options["release-status"],
|
|
1014
|
+
"--release-status",
|
|
1015
|
+
RELEASE_STATUS_VALUES
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
837
1018
|
return query;
|
|
838
1019
|
}
|
|
839
1020
|
|
|
@@ -1168,6 +1349,59 @@ function assertExternalId(value, usage) {
|
|
|
1168
1349
|
}
|
|
1169
1350
|
}
|
|
1170
1351
|
|
|
1352
|
+
/**
|
|
1353
|
+
* NAK-804. Validates the appid positional for `gmc games ai-disclosure`,
|
|
1354
|
+
* mirroring the strict path-parameter check the app applies to every
|
|
1355
|
+
* `/api/v1/.../games/{appid}` route: a plain positive decimal integer, and
|
|
1356
|
+
* nothing else. The lexical test is deliberately `/^\d+$/` rather than
|
|
1357
|
+
* `Number(value)`, the same reasoning `assertLanguageTopicMinReviews` above
|
|
1358
|
+
* documents for `--min-reviews` -- `Number` also accepts the exponent, hex
|
|
1359
|
+
* and decimal-point spellings ("7e2", "0x2da", "730.0") that the app's
|
|
1360
|
+
* validator rejects outright, so letting those through here would mean a
|
|
1361
|
+
* value the API answers 400 for silently reaches the wire as if it were
|
|
1362
|
+
* fine. This check is advisory; the server remains the enforcing authority.
|
|
1363
|
+
*/
|
|
1364
|
+
function assertAppid(value, usage) {
|
|
1365
|
+
const raw = value == null ? "" : String(value);
|
|
1366
|
+
const appid = DECIMAL_INTEGER_RE.test(raw) ? Number.parseInt(raw, 10) : Number.NaN;
|
|
1367
|
+
if (!Number.isSafeInteger(appid) || appid <= 0) {
|
|
1368
|
+
writeJsonError({
|
|
1369
|
+
code: "CLI_USAGE_ERROR",
|
|
1370
|
+
message: `Missing or invalid appid (must be a positive integer). Usage: ${usage}`
|
|
1371
|
+
});
|
|
1372
|
+
process.exit(EXIT_USAGE);
|
|
1373
|
+
}
|
|
1374
|
+
return appid;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* NAK-715. Validates the universeId positional for the Roblox per-experience
|
|
1379
|
+
* commands, mirroring the path-parameter check the app applies.
|
|
1380
|
+
*
|
|
1381
|
+
* It returns a STRING and never a number, which is the point: a Roblox
|
|
1382
|
+
* universe id is a 64-bit value, and `Number.parseInt` would round anything
|
|
1383
|
+
* above 2^53 into a different experience's id. The lexical test is
|
|
1384
|
+
* `/^[1-9][0-9]*$/` for the same reason `assertAppid` above uses a lexical
|
|
1385
|
+
* test — `Number` also accepts the exponent, hex and decimal-point spellings
|
|
1386
|
+
* the app's validator rejects, and leading zeros would make one experience
|
|
1387
|
+
* reachable under two spellings. This check is advisory; the server remains
|
|
1388
|
+
* the enforcing authority.
|
|
1389
|
+
*
|
|
1390
|
+
* A Roblox universe id is NOT a Steam appid: the two id spaces are unrelated
|
|
1391
|
+
* and this value never reaches a Steam route.
|
|
1392
|
+
*/
|
|
1393
|
+
function assertUniverseId(value, usage) {
|
|
1394
|
+
const raw = value == null ? "" : String(value).trim();
|
|
1395
|
+
if (!/^[1-9][0-9]{0,18}$/.test(raw)) {
|
|
1396
|
+
writeJsonError({
|
|
1397
|
+
code: "CLI_USAGE_ERROR",
|
|
1398
|
+
message: `Missing or invalid universeId (must be a positive decimal Roblox universe id, not a Steam appid). Usage: ${usage}`
|
|
1399
|
+
});
|
|
1400
|
+
process.exit(EXIT_USAGE);
|
|
1401
|
+
}
|
|
1402
|
+
return raw;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1171
1405
|
function assertResolveQuery(value) {
|
|
1172
1406
|
if (value == null || String(value).trim() === "") {
|
|
1173
1407
|
writeJsonError({
|
package/src/requests.test.js
CHANGED
|
@@ -4,6 +4,7 @@ import test from "node:test";
|
|
|
4
4
|
import { parseArgs } from "./args.js";
|
|
5
5
|
import { buildRequest } from "./requests.js";
|
|
6
6
|
import { printHelp } from "./help.js";
|
|
7
|
+
import { PROVISIONAL_BETA_COMMANDS } from "./constants.js";
|
|
7
8
|
|
|
8
9
|
function buildRequestFromArgv(argv) {
|
|
9
10
|
const { command, options, positional } = parseArgs(argv);
|
|
@@ -402,6 +403,117 @@ test("games more-like-this forwards an explicit country, normalized", () => {
|
|
|
402
403
|
assert.deepEqual(request.query, { country: "jp" });
|
|
403
404
|
});
|
|
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
|
+
|
|
405
517
|
// --- gmc games language-topics (NAK-455) ---------------------------------
|
|
406
518
|
|
|
407
519
|
test("games language-topics targets the canonical route and sends no invented parameters", () => {
|
|
@@ -480,7 +592,7 @@ test("games language-topics rejects an out-of-range min_reviews before any reque
|
|
|
480
592
|
/**
|
|
481
593
|
* `Number("1e2")` and `Number("0x64")` are both 100, so a `Number`-based bounds
|
|
482
594
|
* check would forward these spellings to an endpoint that rejects them:
|
|
483
|
-
* `parseIntParam` (
|
|
595
|
+
* `parseIntParam` (in the app's API validation module) tests `/^-?\d+$/` and
|
|
484
596
|
* answers 400 `min_reviews must be an integer`. The CLI must not turn a string
|
|
485
597
|
* the API refuses into a successful request.
|
|
486
598
|
*/
|
|
@@ -921,8 +1033,8 @@ test("(NAK-587) an omitted --catalog-as-of leaves the query untouched", () => {
|
|
|
921
1033
|
* NAK-773. `--ai-disclosure` reaches games search/count/aggregate through the
|
|
922
1034
|
* SAME shared query builder as every other filter, and is forwarded verbatim
|
|
923
1035
|
* (kebab-case flag to camelCase query param) as the three literal values the
|
|
924
|
-
* server matches case-sensitively (assertAiDisclosureFilterValue,
|
|
925
|
-
*
|
|
1036
|
+
* server matches case-sensitively (assertAiDisclosureFilterValue, in the
|
|
1037
|
+
* app's AI-disclosure repository module) -- the CLI does not
|
|
926
1038
|
* lower-case or trim a typo into a value the caller did not send.
|
|
927
1039
|
*/
|
|
928
1040
|
test("(NAK-773) games search/count/aggregate forward --ai-disclosure as the aiDisclosure query param", () => {
|
|
@@ -1022,6 +1134,89 @@ test("(NAK-773) --ai-disclosure composes with the release_month,ai_disclosure cr
|
|
|
1022
1134
|
assert.equal(request.query.aiDisclosure, "unconfirmed");
|
|
1023
1135
|
});
|
|
1024
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
|
+
|
|
1025
1220
|
/**
|
|
1026
1221
|
* NAK-706: `gmc steam tag-trends`.
|
|
1027
1222
|
*
|
|
@@ -1102,3 +1297,174 @@ test('trend-stories preserves story, edition, locale and list pagination options
|
|
|
1102
1297
|
assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', '--family', 'mood'])).exitCode, 2);
|
|
1103
1298
|
assert.equal(captureUsageFailure(() => buildRequestFromArgv(['steam', 'trend-stories', 'unexpected'])).exitCode, 2);
|
|
1104
1299
|
});
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* NAK-715: `gmc roblox ...`.
|
|
1303
|
+
*
|
|
1304
|
+
* The CLI is a pass-through here on the same rule the tag-trends block above
|
|
1305
|
+
* documents — the per-endpoint allowlists, enum members, limit ceilings and
|
|
1306
|
+
* window bounds live on the server — so these tests pin the MAPPING (flag name
|
|
1307
|
+
* to query parameter, omission when unset, path construction) and the one
|
|
1308
|
+
* thing the CLI does own: a universe id is a STRING and is not a Steam appid.
|
|
1309
|
+
*/
|
|
1310
|
+
test("(NAK-715) roblox charts defaults to the bare endpoint and lets the server pick the cohort", () => {
|
|
1311
|
+
const request = buildRequestFromArgv(["roblox", "charts", "--json"]);
|
|
1312
|
+
|
|
1313
|
+
assert.equal(request.path, "/api/v1/roblox/charts");
|
|
1314
|
+
// Empty, not {country: "all"}: a default copied here could drift from the
|
|
1315
|
+
// server's.
|
|
1316
|
+
assert.deepEqual(request.query, {});
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
test("(NAK-715) roblox charts maps every flag to its query parameter", () => {
|
|
1320
|
+
const request = buildRequestFromArgv([
|
|
1321
|
+
"roblox", "charts",
|
|
1322
|
+
"--country", "JP",
|
|
1323
|
+
"--device", "computer",
|
|
1324
|
+
"--sort", "top-revisited",
|
|
1325
|
+
"--as-of", "2026-09-14T00:00:00.000Z",
|
|
1326
|
+
"--limit", "50",
|
|
1327
|
+
"--offset", "10",
|
|
1328
|
+
"--json"
|
|
1329
|
+
]);
|
|
1330
|
+
|
|
1331
|
+
assert.deepEqual(request.query, {
|
|
1332
|
+
country: "JP",
|
|
1333
|
+
device: "computer",
|
|
1334
|
+
// Still forwarded although the source stopped publishing this sort: a
|
|
1335
|
+
// historical cutoff has real data, and the server reports the gap.
|
|
1336
|
+
sort: "top-revisited",
|
|
1337
|
+
// The kebab-case flag reaches the endpoint's snake_case parameter.
|
|
1338
|
+
as_of: "2026-09-14T00:00:00.000Z",
|
|
1339
|
+
limit: "50",
|
|
1340
|
+
offset: "10"
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
test("(NAK-715) roblox experiences maps the browse filters and the cursor", () => {
|
|
1345
|
+
const request = buildRequestFromArgv([
|
|
1346
|
+
"roblox", "experiences",
|
|
1347
|
+
"--country", "US",
|
|
1348
|
+
"--eligibility-days", "30",
|
|
1349
|
+
"--min-player-count", "1000",
|
|
1350
|
+
"--max-rank", "50",
|
|
1351
|
+
"--genre", "Adventure",
|
|
1352
|
+
"--creator-type", "Group",
|
|
1353
|
+
"--detail", "chart_only",
|
|
1354
|
+
"--order-by", "player_count",
|
|
1355
|
+
"--order", "desc",
|
|
1356
|
+
"--limit", "25",
|
|
1357
|
+
"--cursor", "abc.def",
|
|
1358
|
+
"--json"
|
|
1359
|
+
]);
|
|
1360
|
+
|
|
1361
|
+
assert.equal(request.path, "/api/v1/roblox/experiences");
|
|
1362
|
+
assert.deepEqual(request.query, {
|
|
1363
|
+
country: "US",
|
|
1364
|
+
eligibility_days: "30",
|
|
1365
|
+
min_player_count: "1000",
|
|
1366
|
+
max_rank: "50",
|
|
1367
|
+
genre: "Adventure",
|
|
1368
|
+
creator_type: "Group",
|
|
1369
|
+
detail: "chart_only",
|
|
1370
|
+
order_by: "player_count",
|
|
1371
|
+
order: "desc",
|
|
1372
|
+
limit: "25",
|
|
1373
|
+
cursor: "abc.def"
|
|
1374
|
+
});
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* The id is carried as the decimal STRING the user typed. Parsing it into a
|
|
1379
|
+
* number would round anything above 2^53 into a different experience's id.
|
|
1380
|
+
*/
|
|
1381
|
+
test("(NAK-715) a universe id travels as a decimal string, never as a number", () => {
|
|
1382
|
+
const request = buildRequestFromArgv(["roblox", "experience", "9007199254740993", "--json"]);
|
|
1383
|
+
|
|
1384
|
+
assert.equal(request.path, "/api/v1/roblox/experiences/9007199254740993");
|
|
1385
|
+
assert.deepEqual(request.query, {});
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
test("(NAK-715) roblox history builds the history path and maps the window flags", () => {
|
|
1389
|
+
const request = buildRequestFromArgv([
|
|
1390
|
+
"roblox", "history", "4924922222",
|
|
1391
|
+
"--granularity", "hourly",
|
|
1392
|
+
"--from", "2026-09-13T00:00:00.000Z",
|
|
1393
|
+
"--to", "2026-09-20T00:00:00.000Z",
|
|
1394
|
+
"--json"
|
|
1395
|
+
]);
|
|
1396
|
+
|
|
1397
|
+
assert.equal(request.path, "/api/v1/roblox/experiences/4924922222/history");
|
|
1398
|
+
assert.deepEqual(request.query, {
|
|
1399
|
+
granularity: "hourly",
|
|
1400
|
+
from: "2026-09-13T00:00:00.000Z",
|
|
1401
|
+
to: "2026-09-20T00:00:00.000Z"
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
test("(NAK-715) roblox coverage takes only the cutoff", () => {
|
|
1406
|
+
assert.deepEqual(buildRequestFromArgv(["roblox", "coverage", "--json"]), {
|
|
1407
|
+
path: "/api/v1/roblox/coverage",
|
|
1408
|
+
query: {}
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
const { exitCode } = captureUsageFailure(() =>
|
|
1412
|
+
buildRequestFromArgv(["roblox", "coverage", "--country", "JP", "--json"])
|
|
1413
|
+
);
|
|
1414
|
+
assert.equal(exitCode, 2);
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* A Steam appid spelling the app's validator rejects must not reach the wire
|
|
1419
|
+
* looking fine, and it must never be mistaken for a universe id.
|
|
1420
|
+
*/
|
|
1421
|
+
test("(NAK-715) a malformed universe id is a usage error naming the Steam-appid confusion", () => {
|
|
1422
|
+
for (const bad of ["730.0", "0x2da", "7e2", "-1", "0", "007", "abc"]) {
|
|
1423
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
1424
|
+
buildRequestFromArgv(["roblox", "experience", bad, "--json"])
|
|
1425
|
+
);
|
|
1426
|
+
assert.equal(exitCode, 2, `expected ${bad} to be refused`);
|
|
1427
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
1428
|
+
assert.match(output.error.message, /not a Steam appid/);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const missing = captureUsageFailure(() => buildRequestFromArgv(["roblox", "history", "--json"]));
|
|
1432
|
+
assert.equal(missing.exitCode, 2);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
test("(NAK-715) a flag the endpoint has no parameter for is a usage error, not a dropped narrowing", () => {
|
|
1436
|
+
const { output, exitCode } = captureUsageFailure(() =>
|
|
1437
|
+
buildRequestFromArgv(["roblox", "charts", "--source", "steam", "--json"])
|
|
1438
|
+
);
|
|
1439
|
+
|
|
1440
|
+
assert.equal(exitCode, 2);
|
|
1441
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
1442
|
+
assert.match(output.error.message, /--source is not supported by gmc roblox charts/);
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
test("(NAK-715) a stray positional is a usage error rather than a silently ignored argument", () => {
|
|
1446
|
+
const { exitCode } = captureUsageFailure(() =>
|
|
1447
|
+
buildRequestFromArgv(["roblox", "charts", "cozy", "--json"])
|
|
1448
|
+
);
|
|
1449
|
+
assert.equal(exitCode, 2);
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Mirrors the tag-trends precedent: removing these from
|
|
1454
|
+
* PROVISIONAL_BETA_COMMANDS would leave every test above green while the
|
|
1455
|
+
* commands silently vanished from `--help`.
|
|
1456
|
+
*/
|
|
1457
|
+
test("(NAK-715) the roblox commands are published as provisional in --help", () => {
|
|
1458
|
+
for (const command of [
|
|
1459
|
+
"gmc roblox charts --country all --sort top-playing-now --json",
|
|
1460
|
+
"gmc roblox experiences --country all --limit 50 --json",
|
|
1461
|
+
"gmc roblox experience <universeId> --json",
|
|
1462
|
+
"gmc roblox history <universeId> --granularity daily|hourly --json",
|
|
1463
|
+
"gmc roblox coverage --json"
|
|
1464
|
+
]) {
|
|
1465
|
+
assert.ok(
|
|
1466
|
+
PROVISIONAL_BETA_COMMANDS.includes(command),
|
|
1467
|
+
`${command} must stay listed as provisional`
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
});
|