@witchpot/gmc 0.1.8 → 0.1.9
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 +8 -0
- package/README.md +17 -12
- package/package.json +3 -1
- package/scripts/smoke-beta.mjs +35 -9
- package/skill/gmc-analysis/SKILL.md +5 -0
- package/skill/gmc-analysis/references/pitfalls.md +3 -1
- package/skill/gmc-analysis/references/quota.md +3 -0
- package/skill/gmc-analysis/references/recipes.md +3 -1
- package/src/args.js +1 -1
- package/src/config.js +5 -5
- package/src/constants.js +1 -1
- package/src/help.js +4 -2
- package/src/main.js +12 -0
- package/src/requests.js +17 -5
- package/src/requests.test.js +43 -6
- package/src/skill.js +2 -1
- package/src/telemetry.js +341 -0
- package/src/telemetry.test.js +121 -0
- package/src/workflows.js +81 -27
- package/src/workflows.test.js +28 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Game Market Copilot CLI will be recorded here.
|
|
4
4
|
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 0.1.9 - 2026-07-03
|
|
8
|
+
|
|
9
|
+
- Clarified packaged Agent Skill docs so Claude Code uses `gmc skill install`
|
|
10
|
+
and Codex/AGENTS.md-based agents use
|
|
11
|
+
`gmc skill print --format agents-md >> AGENTS.md`.
|
|
12
|
+
|
|
5
13
|
## 0.1.8 - 2026-06-12
|
|
6
14
|
|
|
7
15
|
- Updated the bundled `gmc-analysis` skill to v0.2.0 with the learnings from
|
package/README.md
CHANGED
|
@@ -6,17 +6,17 @@ showcases.
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
Install the
|
|
9
|
+
Install the CLI:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npm install -g @witchpot/gmc
|
|
12
|
+
npm install -g @witchpot/gmc
|
|
13
13
|
gmc --version
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Or run it without a global install:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
npx @witchpot/gmc
|
|
19
|
+
npx @witchpot/gmc --version
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
The package installs three equivalent binaries: `gmc`, `gmcp`, and
|
|
@@ -64,8 +64,10 @@ a non-secret prefix.
|
|
|
64
64
|
## Agent Skill
|
|
65
65
|
|
|
66
66
|
The package bundles the `gmc-analysis` Agent Skill — analysis recipes, pivot
|
|
67
|
-
heuristics, quota etiquette, and honesty rules for coding agents.
|
|
68
|
-
|
|
67
|
+
heuristics, quota etiquette, and honesty rules for coding agents. The core
|
|
68
|
+
analysis guidance is shared, but the registration step depends on the agent.
|
|
69
|
+
|
|
70
|
+
For Claude Code, install the packaged skill directory:
|
|
69
71
|
|
|
70
72
|
```bash
|
|
71
73
|
gmc skill install # writes .claude/skills/gmc-analysis (project)
|
|
@@ -73,13 +75,16 @@ gmc skill install --global # or ~/.claude/skills/gmc-analysis
|
|
|
73
75
|
gmc skill status --json # packaged vs installed versions
|
|
74
76
|
```
|
|
75
77
|
|
|
76
|
-
For Codex and other AGENTS.md-based agents, append the self-contained
|
|
77
|
-
|
|
78
|
+
For Codex and other AGENTS.md-based agents, append the self-contained edition
|
|
79
|
+
(references inlined) to the repo brief the agent reads:
|
|
78
80
|
|
|
79
81
|
```bash
|
|
80
82
|
gmc skill print --format agents-md >> AGENTS.md
|
|
81
83
|
```
|
|
82
84
|
|
|
85
|
+
If `AGENTS.md` already contains a generated `gmc-analysis` section, update that
|
|
86
|
+
section instead of appending a duplicate.
|
|
87
|
+
|
|
83
88
|
Re-run install/print after `npm update` to pick up newer skill versions.
|
|
84
89
|
|
|
85
90
|
## First Queries
|
|
@@ -87,11 +92,11 @@ Re-run install/print after `npm update` to pick up newer skill versions.
|
|
|
87
92
|
```bash
|
|
88
93
|
gmc schema --json
|
|
89
94
|
gmc update --json
|
|
90
|
-
gmc games
|
|
91
|
-
gmc games
|
|
92
|
-
gmc
|
|
93
|
-
gmc
|
|
94
|
-
gmc showcases
|
|
95
|
+
gmc games search --source steam --release-year 2025 --reviews-min 1000 --fields title,appid,rating,reviews --json
|
|
96
|
+
gmc games aggregate --source steam --release-year 2025 --metric count,median_reviews --group-by release_year --reviews-min 1000 --json
|
|
97
|
+
gmc games benchmark --source steam --tags <tag_slug[,tag_slug...]> --reviews-min 1000 --preset summary --json
|
|
98
|
+
gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --sample-size 15 --preset summary --json
|
|
99
|
+
gmc showcases patterns --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
95
100
|
```
|
|
96
101
|
|
|
97
102
|
Preserve `meta.requestId`, `meta.entitlements`, `warnings`, and `pagination`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@witchpot/gmc",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Command line tools for Game Market Copilot market research.",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"smoke:beta": "node scripts/smoke-beta.mjs",
|
|
46
46
|
"pack:check": "node scripts/smoke-pack.mjs --dry-run",
|
|
47
47
|
"smoke:pack": "node scripts/smoke-pack.mjs",
|
|
48
|
+
"publish:latest:dry-run": "npm publish --access public --dry-run --cache /tmp/gmc-npm-cache",
|
|
49
|
+
"publish:latest": "npm publish --access public --cache /tmp/gmc-npm-cache",
|
|
48
50
|
"publish:beta:dry-run": "npm publish --tag beta --access public --dry-run --cache /tmp/gmc-npm-cache",
|
|
49
51
|
"publish:beta": "npm publish --tag beta --access public --cache /tmp/gmc-npm-cache"
|
|
50
52
|
}
|
package/scripts/smoke-beta.mjs
CHANGED
|
@@ -33,14 +33,40 @@ const smokeCommands = [
|
|
|
33
33
|
validate: (payload) => requireSuccessEnvelope(payload, ["data.version", "data.cliCommands"])
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
|
-
name: "
|
|
37
|
-
args: [
|
|
38
|
-
|
|
36
|
+
name: "search recent reviewed games",
|
|
37
|
+
args: [
|
|
38
|
+
"games",
|
|
39
|
+
"search",
|
|
40
|
+
"--source",
|
|
41
|
+
"steam",
|
|
42
|
+
"--release-year",
|
|
43
|
+
"2025",
|
|
44
|
+
"--reviews-min",
|
|
45
|
+
"1000",
|
|
46
|
+
"--limit",
|
|
47
|
+
"5",
|
|
48
|
+
"--json"
|
|
49
|
+
],
|
|
50
|
+
validate: (payload) => requireSuccessEnvelope(payload, ["data"])
|
|
39
51
|
},
|
|
40
52
|
{
|
|
41
|
-
name: "
|
|
42
|
-
args: [
|
|
43
|
-
|
|
53
|
+
name: "aggregate recent reviewed games",
|
|
54
|
+
args: [
|
|
55
|
+
"games",
|
|
56
|
+
"aggregate",
|
|
57
|
+
"--source",
|
|
58
|
+
"steam",
|
|
59
|
+
"--release-year",
|
|
60
|
+
"2025",
|
|
61
|
+
"--metric",
|
|
62
|
+
"count,median_reviews",
|
|
63
|
+
"--group-by",
|
|
64
|
+
"release_year",
|
|
65
|
+
"--reviews-min",
|
|
66
|
+
"1000",
|
|
67
|
+
"--json"
|
|
68
|
+
],
|
|
69
|
+
validate: (payload) => requireSuccessEnvelope(payload, ["data"])
|
|
44
70
|
},
|
|
45
71
|
{
|
|
46
72
|
name: "benchmark city builder automation",
|
|
@@ -60,9 +86,9 @@ const smokeCommands = [
|
|
|
60
86
|
validate: (payload) => requireComposedEnvelope(payload, ["data.summary"])
|
|
61
87
|
},
|
|
62
88
|
{
|
|
63
|
-
name: "showcase
|
|
64
|
-
args: ["showcases", "
|
|
65
|
-
validate: (payload) => requireComposedEnvelope(payload, ["data.
|
|
89
|
+
name: "showcase patterns by tag",
|
|
90
|
+
args: ["showcases", "patterns", "--source", "steam", "--tags", "city_builder,automation", "--json"],
|
|
91
|
+
validate: (payload) => requireComposedEnvelope(payload, ["data.summary"])
|
|
66
92
|
}
|
|
67
93
|
];
|
|
68
94
|
|
|
@@ -53,6 +53,11 @@ For cohorts beyond one page, and for the full-cohort map-reduce procedure
|
|
|
53
53
|
(pagination, bounded concurrency, `--fields` projection, subagent chunking),
|
|
54
54
|
follow the recipes reference.
|
|
55
55
|
|
|
56
|
+
Search commands are optimized for row retrieval. Do not expect
|
|
57
|
+
`pagination.total` from `gmc games search` or CLI-composed evidence packs
|
|
58
|
+
unless you explicitly pass `--include-total`; for count-only questions use
|
|
59
|
+
`gmc games count` instead.
|
|
60
|
+
|
|
56
61
|
## Hard rules
|
|
57
62
|
|
|
58
63
|
- **Missing data is not absent sentiment.** Report `cohort.usableGames`,
|
|
@@ -69,4 +69,6 @@ exemplar rows and caveat bucket assignments, especially during sale seasons.
|
|
|
69
69
|
List endpoints cap at `limit=100`. For "how many" questions use
|
|
70
70
|
`gmc games count`; for distributions use `gmc games aggregate` — both are
|
|
71
71
|
single-request server aggregates. Reserve pagination loops for when you
|
|
72
|
-
actually need the rows (recipe R2).
|
|
72
|
+
actually need the rows (recipe R2). `gmc games search` is optimized for rows
|
|
73
|
+
and may omit `pagination.total`; pass `--include-total` only when a row page
|
|
74
|
+
also needs an exact count.
|
|
@@ -25,3 +25,6 @@ indicative — the response metadata is the source of truth.
|
|
|
25
25
|
midnight), and tell the user how much of the analysis completed.
|
|
26
26
|
5. **Prefer server aggregates** (`count`, `aggregate`) over row pagination —
|
|
27
27
|
they answer sizing questions in one request.
|
|
28
|
+
6. **Keep totals explicit.** Search commands prioritize fast row pages and
|
|
29
|
+
exact totals are opt-in (`--include-total`). Use `games count` for
|
|
30
|
+
denominators so row collection stays cheap.
|
|
@@ -36,7 +36,9 @@ Cost: ~(pages + N) requests. Check quota headroom first (see quota.md).
|
|
|
36
36
|
1. **Collect the cohort**: page `gmc games search <filters> --coverage
|
|
37
37
|
full,partial --sort reviews_desc --limit 100 --offset <0,100,...>
|
|
38
38
|
--fields appid,title,reviews --json` until `pagination.nextOffset` is
|
|
39
|
-
null.
|
|
39
|
+
null. Run `gmc games count` first when you need the denominator; add
|
|
40
|
+
`--include-total` to search only when the exact total must travel with the
|
|
41
|
+
row page.
|
|
40
42
|
2. **Fan out** `gmc games analysis --source steam <appid> --sections
|
|
41
43
|
reviews --json` per game with bounded concurrency (<= 8 parallel
|
|
42
44
|
processes). Write each response to a temp file; tolerate individual
|
package/src/args.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { EXIT_USAGE } from "./constants.js";
|
|
2
2
|
import { writeJsonError } from "./output.js";
|
|
3
3
|
|
|
4
|
-
export const BOOLEAN_OPTIONS = new Set(["json", "help", "version", "no-open", "global"]);
|
|
4
|
+
export const BOOLEAN_OPTIONS = new Set(["json", "help", "version", "no-open", "global", "include-total"]);
|
|
5
5
|
|
|
6
6
|
export function parseArgs(args) {
|
|
7
7
|
const command = [];
|
package/src/config.js
CHANGED
|
@@ -187,15 +187,15 @@ export async function handleLocalCommand(command, options, positional, config) {
|
|
|
187
187
|
status: "manual_update_required",
|
|
188
188
|
updateCheck: {
|
|
189
189
|
available: null,
|
|
190
|
-
reason: "The CLI does not query the npm registry yet. Use
|
|
190
|
+
reason: "The CLI does not query the npm registry yet. Use npm install or npx to run the current public package."
|
|
191
191
|
},
|
|
192
192
|
commands: {
|
|
193
|
-
npmGlobal: `npm install -g ${
|
|
194
|
-
npmExec: `npx ${
|
|
193
|
+
npmGlobal: `npm install -g ${CLI_PACKAGE_NAME}`,
|
|
194
|
+
npmExec: `npx ${CLI_PACKAGE_NAME} --version`
|
|
195
195
|
},
|
|
196
196
|
notes: [
|
|
197
|
-
"This command is intentionally non-mutating
|
|
198
|
-
"A future gmc update --apply command can run the package-manager update after
|
|
197
|
+
"This command is intentionally non-mutating.",
|
|
198
|
+
"A future gmc update --apply command can run the package-manager update after registry checks are implemented."
|
|
199
199
|
]
|
|
200
200
|
}
|
|
201
201
|
};
|
package/src/constants.js
CHANGED
|
@@ -8,7 +8,7 @@ export const EXIT_API = 4;
|
|
|
8
8
|
|
|
9
9
|
export const CLI_PACKAGE_NAME = "@witchpot/gmc";
|
|
10
10
|
export const CLI_BETA_PACKAGE_NAME = "@witchpot/gmc";
|
|
11
|
-
export const CLI_VERSION = "0.1.
|
|
11
|
+
export const CLI_VERSION = "0.1.9";
|
|
12
12
|
|
|
13
13
|
export const DEFAULT_API_BASE_URL = "https://gamemarketcopilot.com";
|
|
14
14
|
export const DEFAULT_CONFIG_PATH = path.join(homedir(), ".config", "gmc", "config.json");
|
package/src/help.js
CHANGED
|
@@ -56,10 +56,10 @@ Binary aliases:
|
|
|
56
56
|
alias shadows gmc (Oh My Zsh's git plugin defines gmc as
|
|
57
57
|
'git merge --continue').
|
|
58
58
|
|
|
59
|
-
Supported
|
|
59
|
+
Supported commands:
|
|
60
60
|
${SUPPORTED_BETA_COMMANDS.map((command) => ` ${command}`).join("\n")}
|
|
61
61
|
|
|
62
|
-
Provisional
|
|
62
|
+
Provisional commands:
|
|
63
63
|
${PROVISIONAL_BETA_COMMANDS.map((command) => ` ${command}`).join("\n")}
|
|
64
64
|
|
|
65
65
|
Environment:
|
|
@@ -102,6 +102,8 @@ Sorting:
|
|
|
102
102
|
Pagination:
|
|
103
103
|
--limit <number> 1-100, default 20.
|
|
104
104
|
--offset <number> Offset for the next page.
|
|
105
|
+
--include-total Also fetch an exact total. Prefer games count
|
|
106
|
+
when the user only asks how many games match.
|
|
105
107
|
|
|
106
108
|
Output:
|
|
107
109
|
--fields <list> Optional comma-separated field list.
|
package/src/main.js
CHANGED
|
@@ -12,6 +12,7 @@ import { printGamesSearchHelp, printHelp } from "./help.js";
|
|
|
12
12
|
import { writeJsonError } from "./output.js";
|
|
13
13
|
import { applyOutputProjection } from "./projection.js";
|
|
14
14
|
import { buildRequest } from "./requests.js";
|
|
15
|
+
import { trackCliCommandExecuted } from "./telemetry.js";
|
|
15
16
|
import { executeRequestPlan } from "./workflows.js";
|
|
16
17
|
|
|
17
18
|
export async function main(argv = process.argv.slice(2)) {
|
|
@@ -78,6 +79,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
78
79
|
process.exit(EXIT_CONFIG);
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
const startedAt = Date.now();
|
|
81
83
|
const response = await executeRequestPlan(baseUrl, request, { apiKey });
|
|
82
84
|
const payload = matches(command, ["auth", "status"])
|
|
83
85
|
? enrichAuthStatusOutput(response, config)
|
|
@@ -89,6 +91,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
89
91
|
});
|
|
90
92
|
|
|
91
93
|
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
|
|
94
|
+
await trackCliCommandExecuted({
|
|
95
|
+
baseUrl,
|
|
96
|
+
apiKey,
|
|
97
|
+
command,
|
|
98
|
+
options,
|
|
99
|
+
positional,
|
|
100
|
+
request,
|
|
101
|
+
response: payload,
|
|
102
|
+
startedAt
|
|
103
|
+
});
|
|
92
104
|
}
|
|
93
105
|
|
|
94
106
|
export function runCliEntryPoint() {
|
package/src/requests.js
CHANGED
|
@@ -88,7 +88,7 @@ export function buildRequest(command, options, positional) {
|
|
|
88
88
|
const query = {
|
|
89
89
|
sort: "reviews_desc",
|
|
90
90
|
limit: sampling?.pageLimit ?? DEFAULT_BENCHMARK_LIMIT,
|
|
91
|
-
...buildGameSearchQuery(options)
|
|
91
|
+
...buildGameSearchQuery(options, { includeTotal: false })
|
|
92
92
|
};
|
|
93
93
|
if (sampling && options.limit == null) query.limit = sampling.pageLimit;
|
|
94
94
|
return {
|
|
@@ -96,6 +96,7 @@ export function buildRequest(command, options, positional) {
|
|
|
96
96
|
source: "steam",
|
|
97
97
|
query,
|
|
98
98
|
sampling,
|
|
99
|
+
countRequest: buildOptionalGameCountRequest(options),
|
|
99
100
|
searchRequest: {
|
|
100
101
|
path: "/api/v1/steam/games",
|
|
101
102
|
query
|
|
@@ -201,7 +202,7 @@ export function buildRequest(command, options, positional) {
|
|
|
201
202
|
const query = {
|
|
202
203
|
sort: "reviews_desc",
|
|
203
204
|
limit: sampling?.pageLimit ?? DEFAULT_REVIEW_PATTERNS_LIMIT,
|
|
204
|
-
...buildGameSearchQuery(options)
|
|
205
|
+
...buildGameSearchQuery(options, { includeTotal: false })
|
|
205
206
|
};
|
|
206
207
|
if (sampling && options.limit == null) query.limit = sampling.pageLimit;
|
|
207
208
|
|
|
@@ -210,6 +211,7 @@ export function buildRequest(command, options, positional) {
|
|
|
210
211
|
source: "steam",
|
|
211
212
|
query,
|
|
212
213
|
sampling,
|
|
214
|
+
countRequest: buildOptionalGameCountRequest(options),
|
|
213
215
|
minGames,
|
|
214
216
|
maxTopicsPerGame,
|
|
215
217
|
sentiment,
|
|
@@ -361,7 +363,7 @@ function buildCohortEvidenceRequest({ workflow, evidenceType, sections, options
|
|
|
361
363
|
const query = {
|
|
362
364
|
sort: "reviews_desc",
|
|
363
365
|
limit: sampling?.pageLimit ?? DEFAULT_COHORT_WORKFLOW_LIMIT,
|
|
364
|
-
...buildGameSearchQuery(options)
|
|
366
|
+
...buildGameSearchQuery(options, { includeTotal: false })
|
|
365
367
|
};
|
|
366
368
|
if (sampling && options.limit == null) query.limit = sampling.pageLimit;
|
|
367
369
|
return {
|
|
@@ -372,6 +374,7 @@ function buildCohortEvidenceRequest({ workflow, evidenceType, sections, options
|
|
|
372
374
|
sections,
|
|
373
375
|
query,
|
|
374
376
|
sampling,
|
|
377
|
+
countRequest: buildOptionalGameCountRequest(options),
|
|
375
378
|
searchRequest: {
|
|
376
379
|
path: "/api/v1/steam/games",
|
|
377
380
|
query
|
|
@@ -410,7 +413,7 @@ function pickQuery(options, mapping) {
|
|
|
410
413
|
return query;
|
|
411
414
|
}
|
|
412
415
|
|
|
413
|
-
function buildGameSearchQuery(options) {
|
|
416
|
+
function buildGameSearchQuery(options, { includeTotal = Boolean(options["include-total"]) } = {}) {
|
|
414
417
|
const query = {
|
|
415
418
|
...buildGameCountQuery(options),
|
|
416
419
|
...pickQuery(options, {
|
|
@@ -418,12 +421,21 @@ function buildGameSearchQuery(options) {
|
|
|
418
421
|
limit: "limit",
|
|
419
422
|
offset: "offset",
|
|
420
423
|
locale: "locale"
|
|
421
|
-
})
|
|
424
|
+
}),
|
|
425
|
+
includeTotal
|
|
422
426
|
};
|
|
423
427
|
|
|
424
428
|
return query;
|
|
425
429
|
}
|
|
426
430
|
|
|
431
|
+
function buildOptionalGameCountRequest(options) {
|
|
432
|
+
if (!options["include-total"]) return undefined;
|
|
433
|
+
return {
|
|
434
|
+
path: "/api/v1/steam/games/count",
|
|
435
|
+
query: buildGameCountQuery(options)
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
427
439
|
function buildGameCountQuery(options) {
|
|
428
440
|
const query = pickQuery(options, {
|
|
429
441
|
query: "q",
|
package/src/requests.test.js
CHANGED
|
@@ -15,11 +15,11 @@ test("games success-report builds the success-report request plan", () => {
|
|
|
15
15
|
"success-report",
|
|
16
16
|
"--source",
|
|
17
17
|
"steam",
|
|
18
|
-
"
|
|
18
|
+
"123456",
|
|
19
19
|
"--json"
|
|
20
20
|
]);
|
|
21
21
|
|
|
22
|
-
assert.equal(request.path, "/api/v1/steam/games/
|
|
22
|
+
assert.equal(request.path, "/api/v1/steam/games/123456/success-report");
|
|
23
23
|
assert.deepEqual(request.query, {});
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -29,13 +29,13 @@ test("games success-report forwards locale", () => {
|
|
|
29
29
|
"success-report",
|
|
30
30
|
"--source",
|
|
31
31
|
"steam",
|
|
32
|
-
"
|
|
32
|
+
"123456",
|
|
33
33
|
"--locale",
|
|
34
34
|
"ja",
|
|
35
35
|
"--json"
|
|
36
36
|
]);
|
|
37
37
|
|
|
38
|
-
assert.equal(request.path, "/api/v1/steam/games/
|
|
38
|
+
assert.equal(request.path, "/api/v1/steam/games/123456/success-report");
|
|
39
39
|
assert.deepEqual(request.query, { locale: "ja" });
|
|
40
40
|
});
|
|
41
41
|
|
|
@@ -45,11 +45,11 @@ test("games detail request plan stays unchanged", () => {
|
|
|
45
45
|
"detail",
|
|
46
46
|
"--source",
|
|
47
47
|
"steam",
|
|
48
|
-
"
|
|
48
|
+
"123456",
|
|
49
49
|
"--json"
|
|
50
50
|
]);
|
|
51
51
|
|
|
52
|
-
assert.equal(request.path, "/api/v1/steam/games/
|
|
52
|
+
assert.equal(request.path, "/api/v1/steam/games/123456");
|
|
53
53
|
assert.deepEqual(request.query, {});
|
|
54
54
|
});
|
|
55
55
|
|
|
@@ -69,6 +69,21 @@ test("games search forwards coverage as a filter", () => {
|
|
|
69
69
|
assert.equal(request.path, "/api/v1/steam/games");
|
|
70
70
|
assert.equal(request.query.coverage, "full,partial");
|
|
71
71
|
assert.equal(request.query.reviewsMin, "1000");
|
|
72
|
+
assert.equal(request.query.includeTotal, false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("games search can opt into an exact total", () => {
|
|
76
|
+
const request = buildRequestFromArgv([
|
|
77
|
+
"games",
|
|
78
|
+
"search",
|
|
79
|
+
"--source",
|
|
80
|
+
"steam",
|
|
81
|
+
"--include-total",
|
|
82
|
+
"--json"
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
assert.equal(request.path, "/api/v1/steam/games");
|
|
86
|
+
assert.equal(request.query.includeTotal, true);
|
|
72
87
|
});
|
|
73
88
|
|
|
74
89
|
test("games count forwards coverage for cohort pre-estimation", () => {
|
|
@@ -101,4 +116,26 @@ test("reviews patterns forwards coverage into the cohort search request", () =>
|
|
|
101
116
|
|
|
102
117
|
assert.equal(request.kind, "reviewPatterns");
|
|
103
118
|
assert.equal(request.searchRequest.query.coverage, "full,partial");
|
|
119
|
+
assert.equal(request.searchRequest.query.includeTotal, false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("sampled workflows keep exact totals in a separate count request", () => {
|
|
123
|
+
const request = buildRequestFromArgv([
|
|
124
|
+
"games",
|
|
125
|
+
"benchmark",
|
|
126
|
+
"--source",
|
|
127
|
+
"steam",
|
|
128
|
+
"--release-year",
|
|
129
|
+
"2025",
|
|
130
|
+
"--include-total",
|
|
131
|
+
"--json"
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
assert.equal(request.kind, "benchmark");
|
|
135
|
+
assert.equal(request.searchRequest.query.includeTotal, false);
|
|
136
|
+
assert.equal(request.countRequest.path, "/api/v1/steam/games/count");
|
|
137
|
+
assert.deepEqual(request.countRequest.query, {
|
|
138
|
+
releaseFrom: "2025-01-01",
|
|
139
|
+
releaseTo: "2025-12-31"
|
|
140
|
+
});
|
|
104
141
|
});
|
package/src/skill.js
CHANGED
|
@@ -51,7 +51,8 @@ export function handleSkillInstall(options) {
|
|
|
51
51
|
scope: options.dir ? "custom" : options.global ? "global" : "project",
|
|
52
52
|
next: [
|
|
53
53
|
"Claude Code picks up project skills from .claude/skills automatically.",
|
|
54
|
-
"For Codex or other AGENTS.md-based agents
|
|
54
|
+
"For Codex or other AGENTS.md-based agents, append the self-contained instructions with: gmc skill print --format agents-md >> AGENTS.md",
|
|
55
|
+
"If AGENTS.md already contains a generated gmc-analysis section, update it instead of appending a duplicate."
|
|
55
56
|
]
|
|
56
57
|
}
|
|
57
58
|
};
|
package/src/telemetry.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { CLI_VERSION } from "./constants.js";
|
|
2
|
+
import { buildRequestHeaders } from "./requests.js";
|
|
3
|
+
|
|
4
|
+
const TELEMETRY_TIMEOUT_MS = 800;
|
|
5
|
+
const TELEMETRY_ENDPOINT = "/api/v1/product-events";
|
|
6
|
+
const FORMAT_OPTION_KEYS = new Set(["json", "fields", "preset", "help", "version", "format"]);
|
|
7
|
+
const RAW_TEXT_KEYS = new Set(["q", "query", "title", "topic", "message", "prompt"]);
|
|
8
|
+
const SENSITIVE_OPTION_KEYS = new Set([
|
|
9
|
+
"api-key",
|
|
10
|
+
"authorization",
|
|
11
|
+
"bearer",
|
|
12
|
+
"credential",
|
|
13
|
+
"key",
|
|
14
|
+
"secret",
|
|
15
|
+
"token"
|
|
16
|
+
]);
|
|
17
|
+
const SAFE_QUERY_VALUE_KEYS = new Set([
|
|
18
|
+
"bucket",
|
|
19
|
+
"compareBy",
|
|
20
|
+
"coverage",
|
|
21
|
+
"detail",
|
|
22
|
+
"format",
|
|
23
|
+
"focus",
|
|
24
|
+
"groupBy",
|
|
25
|
+
"includeTotal",
|
|
26
|
+
"limit",
|
|
27
|
+
"locale",
|
|
28
|
+
"maxTopicsPerGame",
|
|
29
|
+
"metric",
|
|
30
|
+
"minGames",
|
|
31
|
+
"offset",
|
|
32
|
+
"priceMax",
|
|
33
|
+
"priceMin",
|
|
34
|
+
"ratingMin",
|
|
35
|
+
"releaseFrom",
|
|
36
|
+
"releaseTo",
|
|
37
|
+
"reviewsGt",
|
|
38
|
+
"reviewsLt",
|
|
39
|
+
"reviewsMax",
|
|
40
|
+
"reviewsMin",
|
|
41
|
+
"samplePages",
|
|
42
|
+
"sampleSize",
|
|
43
|
+
"sections",
|
|
44
|
+
"sentiment",
|
|
45
|
+
"sort",
|
|
46
|
+
"source",
|
|
47
|
+
"status",
|
|
48
|
+
"tagMode",
|
|
49
|
+
"tags"
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
export async function trackCliCommandExecuted({
|
|
53
|
+
baseUrl,
|
|
54
|
+
apiKey,
|
|
55
|
+
command,
|
|
56
|
+
options,
|
|
57
|
+
positional,
|
|
58
|
+
request,
|
|
59
|
+
response,
|
|
60
|
+
startedAt
|
|
61
|
+
}) {
|
|
62
|
+
if (!apiKey || isCliTelemetryDisabled()) return;
|
|
63
|
+
|
|
64
|
+
const event = buildCliCommandTelemetryEvent({
|
|
65
|
+
command,
|
|
66
|
+
options,
|
|
67
|
+
positional,
|
|
68
|
+
request,
|
|
69
|
+
response,
|
|
70
|
+
startedAt
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await sendCliTelemetryEvent(baseUrl, apiKey, event);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function buildCliCommandTelemetryEvent({
|
|
77
|
+
command,
|
|
78
|
+
options = {},
|
|
79
|
+
positional = [],
|
|
80
|
+
request,
|
|
81
|
+
response,
|
|
82
|
+
startedAt,
|
|
83
|
+
now = Date.now()
|
|
84
|
+
}) {
|
|
85
|
+
const appid = extractSingleAppid(request);
|
|
86
|
+
const queryInputs = collectRequestQueries(request);
|
|
87
|
+
const safeQueryValues = mergeSafeQueryValues(queryInputs);
|
|
88
|
+
const properties = compactObject({
|
|
89
|
+
source: "gmc-cli",
|
|
90
|
+
cliVersion: CLI_VERSION,
|
|
91
|
+
command: command.join(" "),
|
|
92
|
+
commandGroup: command[0],
|
|
93
|
+
commandAction: command.slice(1).join(" ") || undefined,
|
|
94
|
+
workflow: request.workflow ?? request.kind ?? "direct",
|
|
95
|
+
endpointTemplate: getPrimaryEndpointTemplate(request),
|
|
96
|
+
success: true,
|
|
97
|
+
durationMs: boundedInteger(now - startedAt),
|
|
98
|
+
outputMode: options.json ? "json" : "default",
|
|
99
|
+
optionKeys: safeOptionKeys(options),
|
|
100
|
+
filterKeys: safeFilterKeys(queryInputs),
|
|
101
|
+
hasSearchText: hasRawTextInput(options, request, positional),
|
|
102
|
+
externalIdCount: countExternalIds(request),
|
|
103
|
+
tags: normalizeTokenList(safeQueryValues.tags),
|
|
104
|
+
sections: normalizeTokenList(safeQueryValues.sections),
|
|
105
|
+
coverage: normalizeTokenList(safeQueryValues.coverage),
|
|
106
|
+
focus: safeToken(request.focus ?? safeQueryValues.focus),
|
|
107
|
+
compareBy: safeToken(request.compareBy ?? safeQueryValues.compareBy),
|
|
108
|
+
evidenceType: safeToken(request.evidenceType),
|
|
109
|
+
metric: safeToken(safeQueryValues.metric),
|
|
110
|
+
groupBy: safeToken(safeQueryValues.groupBy),
|
|
111
|
+
bucket: safeToken(safeQueryValues.bucket),
|
|
112
|
+
sort: safeToken(safeQueryValues.sort),
|
|
113
|
+
status: safeToken(safeQueryValues.status),
|
|
114
|
+
format: safeToken(safeQueryValues.format),
|
|
115
|
+
tagMode: safeToken(safeQueryValues.tagMode),
|
|
116
|
+
limit: safeNumber(safeQueryValues.limit ?? request.limit),
|
|
117
|
+
offset: safeNumber(safeQueryValues.offset),
|
|
118
|
+
minGames: safeNumber(request.minGames),
|
|
119
|
+
maxTopicsPerGame: safeNumber(request.maxTopicsPerGame),
|
|
120
|
+
sampleMode: safeToken(request.sampling?.mode),
|
|
121
|
+
sampleRequestedSize: safeNumber(request.sampling?.requestedSize),
|
|
122
|
+
sampleRequestedPages: safeNumber(request.sampling?.requestedPages),
|
|
123
|
+
samplePageLimit: safeNumber(request.sampling?.pageLimit),
|
|
124
|
+
includeTotal: Boolean(safeQueryValues.includeTotal || request.countRequest),
|
|
125
|
+
releaseFrom: safeDate(safeQueryValues.releaseFrom),
|
|
126
|
+
releaseTo: safeDate(safeQueryValues.releaseTo),
|
|
127
|
+
reviewsMin: safeNumber(safeQueryValues.reviewsMin),
|
|
128
|
+
reviewsMax: safeNumber(safeQueryValues.reviewsMax),
|
|
129
|
+
reviewsGt: safeNumber(safeQueryValues.reviewsGt),
|
|
130
|
+
reviewsLt: safeNumber(safeQueryValues.reviewsLt),
|
|
131
|
+
ratingMin: safeNumber(safeQueryValues.ratingMin),
|
|
132
|
+
priceMin: safeNumber(safeQueryValues.priceMin),
|
|
133
|
+
priceMax: safeNumber(safeQueryValues.priceMax),
|
|
134
|
+
rowCount: topLevelRowCount(response),
|
|
135
|
+
warningCount: safeWarnings(response).length,
|
|
136
|
+
lockedCount: safeLockedSections(response).length,
|
|
137
|
+
resultKind: safeToken(response?.data?.kind),
|
|
138
|
+
apiRequestId: safeShortString(response?.meta?.requestId)
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return compactObject({
|
|
142
|
+
eventType: "cli_command_executed",
|
|
143
|
+
appid,
|
|
144
|
+
properties
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function sendCliTelemetryEvent(baseUrl, apiKey, event, {
|
|
149
|
+
fetchImpl = globalThis.fetch,
|
|
150
|
+
timeoutMs = TELEMETRY_TIMEOUT_MS
|
|
151
|
+
} = {}) {
|
|
152
|
+
if (typeof fetchImpl !== "function") return;
|
|
153
|
+
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const url = new URL(TELEMETRY_ENDPOINT, baseUrl);
|
|
159
|
+
await fetchImpl(url, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: {
|
|
162
|
+
...buildRequestHeaders(apiKey),
|
|
163
|
+
"content-type": "application/json"
|
|
164
|
+
},
|
|
165
|
+
body: JSON.stringify(event),
|
|
166
|
+
signal: controller.signal
|
|
167
|
+
});
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// CLI telemetry is best-effort and must never change command behavior.
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(timeout);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function isCliTelemetryDisabled(env = process.env) {
|
|
176
|
+
if (env.GMC_CLI_TELEMETRY != null) {
|
|
177
|
+
return /^(0|false|off|no|disabled)$/i.test(String(env.GMC_CLI_TELEMETRY));
|
|
178
|
+
}
|
|
179
|
+
if (env.GMC_DISABLE_TELEMETRY != null) {
|
|
180
|
+
return /^(1|true|on|yes|enabled)$/i.test(String(env.GMC_DISABLE_TELEMETRY));
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function safeOptionKeys(options) {
|
|
186
|
+
return Object.keys(options)
|
|
187
|
+
.filter((key) => !FORMAT_OPTION_KEYS.has(key) && !SENSITIVE_OPTION_KEYS.has(key))
|
|
188
|
+
.sort()
|
|
189
|
+
.slice(0, 40);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function collectRequestQueries(request) {
|
|
193
|
+
return [
|
|
194
|
+
request.query,
|
|
195
|
+
request.searchRequest?.query,
|
|
196
|
+
request.countRequest?.query,
|
|
197
|
+
request.topicQuery,
|
|
198
|
+
request.detailQuery,
|
|
199
|
+
request.analysisQuery,
|
|
200
|
+
request.showcaseQuery
|
|
201
|
+
].filter((query) => query && typeof query === "object");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function mergeSafeQueryValues(queries) {
|
|
205
|
+
const values = {};
|
|
206
|
+
for (const query of queries) {
|
|
207
|
+
for (const [key, value] of Object.entries(query)) {
|
|
208
|
+
if (SAFE_QUERY_VALUE_KEYS.has(key) && value != null && value !== "") values[key] = value;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return values;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function safeFilterKeys(queries) {
|
|
215
|
+
const keys = new Set();
|
|
216
|
+
for (const query of queries) {
|
|
217
|
+
for (const [key, value] of Object.entries(query)) {
|
|
218
|
+
if (value == null || value === "" || RAW_TEXT_KEYS.has(key)) continue;
|
|
219
|
+
keys.add(key);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return [...keys].sort().slice(0, 40);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function hasRawTextInput(options, request, positional) {
|
|
226
|
+
if (Object.keys(options).some((key) => RAW_TEXT_KEYS.has(key) && options[key] != null && options[key] !== "")) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
if (typeof request.query === "string" && request.query.trim()) return true;
|
|
230
|
+
if (typeof request.topic === "string" && request.topic.trim()) return true;
|
|
231
|
+
if (request.query && typeof request.query === "object" && typeof request.query.q === "string" && request.query.q.trim()) {
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
if ((request.kind === "resolve" || request.workflow) && positional.some((value) => String(value).trim())) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getPrimaryEndpointTemplate(request) {
|
|
241
|
+
const path = request.path ?? request.searchRequest?.path ?? request.countRequest?.path;
|
|
242
|
+
if (!path) return undefined;
|
|
243
|
+
return templateEndpoint(path);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function templateEndpoint(path) {
|
|
247
|
+
return String(path)
|
|
248
|
+
.replace(/\/api\/v1\/steam\/games\/[^/]+/g, "/api/v1/steam/games/{externalId}")
|
|
249
|
+
.replace(/\/\d+(?=\/|$)/g, "/{id}");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function extractSingleAppid(request) {
|
|
253
|
+
const candidates = [
|
|
254
|
+
request.externalId,
|
|
255
|
+
...(Array.isArray(request.externalIds) ? request.externalIds : []),
|
|
256
|
+
extractAppidFromPath(request.path)
|
|
257
|
+
].filter(Boolean);
|
|
258
|
+
const unique = [...new Set(candidates.map((value) => String(value)))];
|
|
259
|
+
if (unique.length !== 1 || !/^\d+$/.test(unique[0])) return undefined;
|
|
260
|
+
const appid = Number(unique[0]);
|
|
261
|
+
return Number.isSafeInteger(appid) && appid > 0 ? appid : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function extractAppidFromPath(path) {
|
|
265
|
+
if (!path) return undefined;
|
|
266
|
+
const match = String(path).match(/\/api\/v1\/steam\/games\/([^/]+)/);
|
|
267
|
+
if (!match) return undefined;
|
|
268
|
+
return decodeURIComponent(match[1]);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function countExternalIds(request) {
|
|
272
|
+
if (Array.isArray(request.externalIds)) return request.externalIds.length;
|
|
273
|
+
if (request.externalId) return 1;
|
|
274
|
+
return extractAppidFromPath(request.path) ? 1 : 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function normalizeTokenList(value) {
|
|
278
|
+
const values = Array.isArray(value) ? value : String(value ?? "").split(",");
|
|
279
|
+
const normalized = values
|
|
280
|
+
.map((item) => safeToken(item))
|
|
281
|
+
.filter(Boolean);
|
|
282
|
+
return normalized.length ? [...new Set(normalized)].slice(0, 20) : undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function safeToken(value) {
|
|
286
|
+
if (value == null || value === "") return undefined;
|
|
287
|
+
const normalized = String(value)
|
|
288
|
+
.trim()
|
|
289
|
+
.replace(/[^a-zA-Z0-9_.:-]/g, "_")
|
|
290
|
+
.replace(/_+/g, "_")
|
|
291
|
+
.slice(0, 80);
|
|
292
|
+
return normalized || undefined;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function safeShortString(value) {
|
|
296
|
+
if (typeof value !== "string") return undefined;
|
|
297
|
+
const trimmed = value.trim();
|
|
298
|
+
return trimmed ? trimmed.slice(0, 120) : undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function safeDate(value) {
|
|
302
|
+
if (typeof value !== "string") return undefined;
|
|
303
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function safeNumber(value) {
|
|
307
|
+
if (value == null || value === "") return undefined;
|
|
308
|
+
const number = Number(value);
|
|
309
|
+
return Number.isFinite(number) ? number : undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function boundedInteger(value) {
|
|
313
|
+
const number = Number(value);
|
|
314
|
+
if (!Number.isFinite(number)) return undefined;
|
|
315
|
+
return Math.max(0, Math.round(number));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function topLevelRowCount(response) {
|
|
319
|
+
if (Array.isArray(response?.data)) return response.data.length;
|
|
320
|
+
if (Array.isArray(response?.data?.games)) return response.data.games.length;
|
|
321
|
+
if (Array.isArray(response?.data?.cohort?.included)) return response.data.cohort.included.length;
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function safeWarnings(response) {
|
|
326
|
+
if (Array.isArray(response?.warnings)) return response.warnings;
|
|
327
|
+
if (Array.isArray(response?.data?.warnings)) return response.data.warnings;
|
|
328
|
+
if (Array.isArray(response?.meta?.warnings)) return response.meta.warnings;
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function safeLockedSections(response) {
|
|
333
|
+
const locked = response?.meta?.entitlements?.locked;
|
|
334
|
+
return Array.isArray(locked) ? locked : [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function compactObject(value) {
|
|
338
|
+
return Object.fromEntries(
|
|
339
|
+
Object.entries(value).filter(([, entry]) => entry !== undefined)
|
|
340
|
+
);
|
|
341
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildCliCommandTelemetryEvent,
|
|
6
|
+
isCliTelemetryDisabled,
|
|
7
|
+
sendCliTelemetryEvent
|
|
8
|
+
} from "./telemetry.js";
|
|
9
|
+
|
|
10
|
+
test("buildCliCommandTelemetryEvent captures safe CLI filters without raw search text", () => {
|
|
11
|
+
const event = buildCliCommandTelemetryEvent({
|
|
12
|
+
command: ["games", "search"],
|
|
13
|
+
options: {
|
|
14
|
+
source: "steam",
|
|
15
|
+
query: "cozy fishing games like Dredge",
|
|
16
|
+
tags: "cozy,fishing",
|
|
17
|
+
"reviews-min": "100",
|
|
18
|
+
"api-key": "gmc_should_not_appear",
|
|
19
|
+
json: true
|
|
20
|
+
},
|
|
21
|
+
positional: [],
|
|
22
|
+
request: {
|
|
23
|
+
path: "/api/v1/steam/games",
|
|
24
|
+
query: {
|
|
25
|
+
q: "cozy fishing games like Dredge",
|
|
26
|
+
tags: "cozy,fishing",
|
|
27
|
+
reviewsMin: "100",
|
|
28
|
+
source: "steam",
|
|
29
|
+
limit: "20"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
response: {
|
|
33
|
+
data: [{ appid: 1 }, { appid: 2 }],
|
|
34
|
+
meta: {
|
|
35
|
+
requestId: "req_123"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
startedAt: 1_000,
|
|
39
|
+
now: 1_250
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const serialized = JSON.stringify(event);
|
|
43
|
+
assert.equal(event.eventType, "cli_command_executed");
|
|
44
|
+
assert.equal(event.properties.command, "games search");
|
|
45
|
+
assert.deepEqual(event.properties.tags, ["cozy", "fishing"]);
|
|
46
|
+
assert.equal(event.properties.reviewsMin, 100);
|
|
47
|
+
assert.equal(event.properties.hasSearchText, true);
|
|
48
|
+
assert.equal(event.properties.rowCount, 2);
|
|
49
|
+
assert.equal(event.properties.durationMs, 250);
|
|
50
|
+
assert.equal(event.properties.optionKeys.includes("api-key"), false);
|
|
51
|
+
assert.equal(serialized.includes("Dredge"), false);
|
|
52
|
+
assert.equal(serialized.includes("cozy fishing games"), false);
|
|
53
|
+
assert.equal(serialized.includes("gmc_should_not_appear"), false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("buildCliCommandTelemetryEvent templates app endpoints and attaches single appid", () => {
|
|
57
|
+
const event = buildCliCommandTelemetryEvent({
|
|
58
|
+
command: ["games", "analysis"],
|
|
59
|
+
options: {
|
|
60
|
+
source: "steam",
|
|
61
|
+
sections: "reviews,marketing",
|
|
62
|
+
json: true
|
|
63
|
+
},
|
|
64
|
+
positional: ["1384160"],
|
|
65
|
+
request: {
|
|
66
|
+
path: "/api/v1/steam/games/1384160/analysis",
|
|
67
|
+
query: {
|
|
68
|
+
sections: "reviews,marketing"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
response: {
|
|
72
|
+
data: {
|
|
73
|
+
kind: "game_analysis"
|
|
74
|
+
},
|
|
75
|
+
meta: {
|
|
76
|
+
entitlements: {
|
|
77
|
+
locked: ["marketing.full"]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
startedAt: 2_000,
|
|
82
|
+
now: 2_040
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
assert.equal(event.appid, 1384160);
|
|
86
|
+
assert.equal(event.properties.endpointTemplate, "/api/v1/steam/games/{externalId}/analysis");
|
|
87
|
+
assert.deepEqual(event.properties.sections, ["reviews", "marketing"]);
|
|
88
|
+
assert.equal(event.properties.lockedCount, 1);
|
|
89
|
+
assert.equal(event.properties.resultKind, "game_analysis");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("sendCliTelemetryEvent posts product events with bearer auth and swallows failures", async () => {
|
|
93
|
+
const calls = [];
|
|
94
|
+
const fetchImpl = async (url, init) => {
|
|
95
|
+
calls.push({ url, init });
|
|
96
|
+
throw new Error("network unavailable");
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
await sendCliTelemetryEvent("https://example.test", "gmcc_secret", {
|
|
100
|
+
eventType: "cli_command_executed",
|
|
101
|
+
properties: {
|
|
102
|
+
source: "gmc-cli"
|
|
103
|
+
}
|
|
104
|
+
}, {
|
|
105
|
+
fetchImpl,
|
|
106
|
+
timeoutMs: 20
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
assert.equal(calls.length, 1);
|
|
110
|
+
assert.equal(String(calls[0].url), "https://example.test/api/v1/product-events");
|
|
111
|
+
assert.equal(calls[0].init.method, "POST");
|
|
112
|
+
assert.equal(calls[0].init.headers.authorization, "Bearer gmcc_secret");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("isCliTelemetryDisabled supports opt-out environment values", () => {
|
|
116
|
+
assert.equal(isCliTelemetryDisabled({}), false);
|
|
117
|
+
assert.equal(isCliTelemetryDisabled({ GMC_CLI_TELEMETRY: "0" }), true);
|
|
118
|
+
assert.equal(isCliTelemetryDisabled({ GMC_CLI_TELEMETRY: "1" }), false);
|
|
119
|
+
assert.equal(isCliTelemetryDisabled({ GMC_DISABLE_TELEMETRY: "1" }), true);
|
|
120
|
+
assert.equal(isCliTelemetryDisabled({ GMC_CLI_TELEMETRY: "true" }), false);
|
|
121
|
+
});
|
package/src/workflows.js
CHANGED
|
@@ -30,7 +30,10 @@ export async function executeRequestPlan(baseUrl, request, { apiKey }) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
async function executeCohortEvidenceRequest(baseUrl, request, { apiKey }) {
|
|
33
|
-
const sampled = await
|
|
33
|
+
const [sampled, count] = await Promise.all([
|
|
34
|
+
executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey }),
|
|
35
|
+
fetchOptionalCount(baseUrl, request.countRequest, { apiKey })
|
|
36
|
+
]);
|
|
34
37
|
const search = mergeSearchPages(sampled.searches, request.sampling);
|
|
35
38
|
const games = Array.isArray(search?.data) ? search.data : [];
|
|
36
39
|
const analysisResults = request.analysisQuery
|
|
@@ -69,6 +72,7 @@ async function executeCohortEvidenceRequest(baseUrl, request, { apiKey }) {
|
|
|
69
72
|
|
|
70
73
|
return composeCohortEvidencePayload(request, {
|
|
71
74
|
search,
|
|
75
|
+
count,
|
|
72
76
|
searches: sampled.searches,
|
|
73
77
|
searchPageErrors: sampled.pageErrors,
|
|
74
78
|
analysisResults
|
|
@@ -81,8 +85,11 @@ async function executeResolveRequest(baseUrl, request, { apiKey }) {
|
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
async function executeBenchmarkRequest(baseUrl, request, { apiKey }) {
|
|
84
|
-
const sampled = await
|
|
85
|
-
|
|
88
|
+
const [sampled, count] = await Promise.all([
|
|
89
|
+
executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey }),
|
|
90
|
+
fetchOptionalCount(baseUrl, request.countRequest, { apiKey })
|
|
91
|
+
]);
|
|
92
|
+
return composeBenchmarkPayload(request, { ...sampled, count });
|
|
86
93
|
}
|
|
87
94
|
|
|
88
95
|
async function executeCompareRequest(baseUrl, request, { apiKey }) {
|
|
@@ -116,7 +123,10 @@ async function executeCompareRequest(baseUrl, request, { apiKey }) {
|
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
async function executeReviewPatternsRequest(baseUrl, request, { apiKey }) {
|
|
119
|
-
const sampled = await
|
|
126
|
+
const [sampled, count] = await Promise.all([
|
|
127
|
+
executeSearchPages(baseUrl, request.searchRequest, request.sampling, { apiKey }),
|
|
128
|
+
fetchOptionalCount(baseUrl, request.countRequest, { apiKey })
|
|
129
|
+
]);
|
|
120
130
|
const search = mergeSearchPages(sampled.searches, request.sampling);
|
|
121
131
|
const games = Array.isArray(search?.data) ? search.data : [];
|
|
122
132
|
const topicResults = await Promise.all(
|
|
@@ -153,6 +163,7 @@ async function executeReviewPatternsRequest(baseUrl, request, { apiKey }) {
|
|
|
153
163
|
|
|
154
164
|
return composeReviewPatternsPayload(request, {
|
|
155
165
|
search,
|
|
166
|
+
count,
|
|
156
167
|
searches: sampled.searches,
|
|
157
168
|
searchPageErrors: sampled.pageErrors,
|
|
158
169
|
topicResults
|
|
@@ -243,6 +254,11 @@ async function executeSearchPages(baseUrl, searchRequest, sampling, { apiKey })
|
|
|
243
254
|
};
|
|
244
255
|
}
|
|
245
256
|
|
|
257
|
+
async function fetchOptionalCount(baseUrl, countRequest, { apiKey }) {
|
|
258
|
+
if (!countRequest) return undefined;
|
|
259
|
+
return fetchJson(createRequestUrl(baseUrl, countRequest), { apiKey });
|
|
260
|
+
}
|
|
261
|
+
|
|
246
262
|
function shouldFetchNextSamplePage(sampling, searches) {
|
|
247
263
|
if (sampling.mode === "sample-pages") return searches.length < sampling.requestedPages;
|
|
248
264
|
const fetchedRows = searches.reduce((sum, search) => sum + asArray(search?.data).length, 0);
|
|
@@ -383,6 +399,8 @@ export function composeBenchmarkPayload(request, search, options = {}) {
|
|
|
383
399
|
: search;
|
|
384
400
|
const games = Array.isArray(mergedSearch?.data) ? mergedSearch.data : [];
|
|
385
401
|
const sample = buildSampleMetadata(request, searches, games, pageErrors);
|
|
402
|
+
const totalMatches = getTotalMatches(search.count, mergedSearch?.pagination);
|
|
403
|
+
const pagination = withPaginationTotal(mergedSearch?.pagination, totalMatches);
|
|
386
404
|
|
|
387
405
|
return {
|
|
388
406
|
data: {
|
|
@@ -391,19 +409,19 @@ export function composeBenchmarkPayload(request, search, options = {}) {
|
|
|
391
409
|
workflow: "gamesBenchmark",
|
|
392
410
|
inputs: {
|
|
393
411
|
filters: request.query,
|
|
394
|
-
...(sample ? { sample: buildEvidencePackSample(sample, games.length) } : {
|
|
395
|
-
sample: buildReturnedPageSample(
|
|
412
|
+
...(sample ? { sample: buildEvidencePackSample(sample, games.length, totalMatches) } : {
|
|
413
|
+
sample: buildReturnedPageSample(pagination, games.length, totalMatches)
|
|
396
414
|
})
|
|
397
415
|
},
|
|
398
416
|
query: request.query,
|
|
399
417
|
summary: withSummaryBasis(
|
|
400
|
-
buildBenchmarkSummary(games,
|
|
418
|
+
buildBenchmarkSummary(games, pagination, request.source, { sampled: Boolean(sample) }),
|
|
401
419
|
sample ? "bounded_sample" : "returned_page"
|
|
402
420
|
),
|
|
403
421
|
...(sample
|
|
404
422
|
? {
|
|
405
423
|
sampledSummary: withSummaryBasis(
|
|
406
|
-
buildBenchmarkSummary(games,
|
|
424
|
+
buildBenchmarkSummary(games, pagination, request.source, { sampled: true }),
|
|
407
425
|
"bounded_sample"
|
|
408
426
|
)
|
|
409
427
|
}
|
|
@@ -424,11 +442,15 @@ export function composeBenchmarkPayload(request, search, options = {}) {
|
|
|
424
442
|
requestId,
|
|
425
443
|
composedBy: "gmc-cli",
|
|
426
444
|
...(sample ? { sample } : {}),
|
|
427
|
-
upstreamRequests:
|
|
445
|
+
upstreamRequests: [
|
|
446
|
+
...buildSearchUpstreamRequests(searches, pageErrors),
|
|
447
|
+
...buildCountUpstreamRequests(search.count)
|
|
448
|
+
]
|
|
428
449
|
},
|
|
429
|
-
...(
|
|
450
|
+
...(pagination ? { pagination } : {}),
|
|
430
451
|
warnings: uniqueStrings([
|
|
431
452
|
...searches.flatMap((entry) => safeWarnings(entry)),
|
|
453
|
+
...safeWarnings(search.count),
|
|
432
454
|
...pageErrors.map((entry) => `benchmark sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
|
|
433
455
|
sample
|
|
434
456
|
? "sampledSummary is computed from bounded sampled pages; it is not a full market-size estimate"
|
|
@@ -457,6 +479,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
|
|
|
457
479
|
const includedGames = normalizedGames.filter((game) => !game.skippedReason);
|
|
458
480
|
const usableGames = includedGames.filter((game) => game.topics.length > 0);
|
|
459
481
|
const sample = buildSampleMetadata(request, searches, games, searchPageErrors);
|
|
482
|
+
const totalMatches = getTotalMatches(result.count, result.search?.pagination);
|
|
460
483
|
const skipped = normalizedGames
|
|
461
484
|
.filter((game) => game.skippedReason)
|
|
462
485
|
.map((game) => ({
|
|
@@ -469,6 +492,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
|
|
|
469
492
|
}));
|
|
470
493
|
const warnings = uniqueStrings([
|
|
471
494
|
...safeWarnings(result.search),
|
|
495
|
+
...safeWarnings(result.count),
|
|
472
496
|
...searches.flatMap((entry) => safeWarnings(entry)),
|
|
473
497
|
...searchPageErrors.map((entry) => `review pattern sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
|
|
474
498
|
...topicResults.flatMap((entry) => safeWarnings(entry.topics)),
|
|
@@ -491,8 +515,8 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
|
|
|
491
515
|
...(request.topic ? { topic: request.topic } : {}),
|
|
492
516
|
...(request.compareBy ? { compareBy: request.compareBy } : {}),
|
|
493
517
|
...(sample
|
|
494
|
-
? { sample: buildEvidencePackSample(sample, usableGames.length) }
|
|
495
|
-
: { sample: buildReturnedPageSample(result.search?.pagination, usableGames.length) })
|
|
518
|
+
? { sample: buildEvidencePackSample(sample, usableGames.length, totalMatches) }
|
|
519
|
+
: { sample: buildReturnedPageSample(result.search?.pagination, usableGames.length, totalMatches) })
|
|
496
520
|
},
|
|
497
521
|
cohort: {
|
|
498
522
|
query: request.query,
|
|
@@ -505,7 +529,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
|
|
|
505
529
|
usableGames: usableGames.length,
|
|
506
530
|
skippedGames: skipped.length,
|
|
507
531
|
skippedReasons: tallySkippedReasons(skipped),
|
|
508
|
-
totalMatches:
|
|
532
|
+
...(typeof totalMatches === "number" ? { totalMatches } : {}),
|
|
509
533
|
included: usableGames.map((game) => ({
|
|
510
534
|
source: request.source,
|
|
511
535
|
externalId: game.externalId,
|
|
@@ -521,7 +545,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
|
|
|
521
545
|
skipped
|
|
522
546
|
},
|
|
523
547
|
summary: withSummaryBasis(
|
|
524
|
-
buildReviewPatternsSummary(request, usableGames, skipped, result.search?.pagination),
|
|
548
|
+
buildReviewPatternsSummary(request, usableGames, skipped, withPaginationTotal(result.search?.pagination, totalMatches)),
|
|
525
549
|
sample ? "bounded_sample" : "returned_page"
|
|
526
550
|
),
|
|
527
551
|
patterns: buildReviewPatterns(usableGames),
|
|
@@ -547,6 +571,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
|
|
|
547
571
|
...(sample ? { sample } : {}),
|
|
548
572
|
upstreamRequests: [
|
|
549
573
|
...buildSearchUpstreamRequests(searches, searchPageErrors),
|
|
574
|
+
...buildCountUpstreamRequests(result.count),
|
|
550
575
|
...topicResults
|
|
551
576
|
.filter((entry) => entry.topics || entry.error)
|
|
552
577
|
.map((entry) => ({
|
|
@@ -559,7 +584,7 @@ export function composeReviewPatternsPayload(request, result, options = {}) {
|
|
|
559
584
|
}))
|
|
560
585
|
]
|
|
561
586
|
},
|
|
562
|
-
...(result.search?.pagination ? { pagination: result.search.pagination } : {}),
|
|
587
|
+
...(result.search?.pagination ? { pagination: withPaginationTotal(result.search.pagination, totalMatches) } : {}),
|
|
563
588
|
...(warnings.length ? { warnings } : {})
|
|
564
589
|
};
|
|
565
590
|
}
|
|
@@ -670,8 +695,10 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
|
|
|
670
695
|
...(entry.error ? { error: entry.error } : {})
|
|
671
696
|
}));
|
|
672
697
|
const sample = buildSampleMetadata(request, searches, games, searchPageErrors);
|
|
698
|
+
const totalMatches = getTotalMatches(result.count, result.search?.pagination);
|
|
673
699
|
const warnings = uniqueStrings([
|
|
674
700
|
...safeWarnings(result.search),
|
|
701
|
+
...safeWarnings(result.count),
|
|
675
702
|
...searches.flatMap((entry) => safeWarnings(entry)),
|
|
676
703
|
...searchPageErrors.map((entry) => `${request.workflow} sample page failed at offset ${entry.offset}: ${entry.error?.code ?? "CLI_API_ERROR"}`),
|
|
677
704
|
...analysisResults.flatMap((entry) => safeWarnings(entry.analysis)),
|
|
@@ -688,8 +715,8 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
|
|
|
688
715
|
inputs: {
|
|
689
716
|
filters: request.query,
|
|
690
717
|
sections: request.sections,
|
|
691
|
-
...(sample ? { sample: buildEvidencePackSample(sample, included.length) } : {
|
|
692
|
-
sample: buildReturnedPageSample(result.search?.pagination, included.length)
|
|
718
|
+
...(sample ? { sample: buildEvidencePackSample(sample, included.length, totalMatches) } : {
|
|
719
|
+
sample: buildReturnedPageSample(result.search?.pagination, included.length, totalMatches)
|
|
693
720
|
})
|
|
694
721
|
},
|
|
695
722
|
cohort: {
|
|
@@ -698,7 +725,7 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
|
|
|
698
725
|
usableGames: included.length,
|
|
699
726
|
skippedGames: skipped.length,
|
|
700
727
|
skippedReasons: tallySkippedReasons(skipped),
|
|
701
|
-
totalMatches:
|
|
728
|
+
...(typeof totalMatches === "number" ? { totalMatches } : {}),
|
|
702
729
|
included: included.map((entry) => ({
|
|
703
730
|
source: request.source,
|
|
704
731
|
externalId: entry.externalId,
|
|
@@ -716,12 +743,12 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
|
|
|
716
743
|
skipped
|
|
717
744
|
},
|
|
718
745
|
summary: withSummaryBasis(
|
|
719
|
-
buildCohortEvidenceSummary(request, included, result.search?.pagination),
|
|
746
|
+
buildCohortEvidenceSummary(request, included, withPaginationTotal(result.search?.pagination, totalMatches)),
|
|
720
747
|
sample ? "bounded_sample" : "returned_page"
|
|
721
748
|
),
|
|
722
749
|
patterns: buildCohortEvidencePatterns(request, included),
|
|
723
750
|
...(request.evidenceType === "pricing" ? { segments: buildPricingSegments(included) } : {}),
|
|
724
|
-
...(request.evidenceType === "trends" || request.evidenceType === "brief" ? { trends: buildTrendSummary(included, result.search?.pagination, request.source) } : {}),
|
|
751
|
+
...(request.evidenceType === "trends" || request.evidenceType === "brief" ? { trends: buildTrendSummary(included, withPaginationTotal(result.search?.pagination, totalMatches), request.source) } : {}),
|
|
725
752
|
games: included.map((entry) => ({
|
|
726
753
|
source: request.source,
|
|
727
754
|
externalId: entry.externalId,
|
|
@@ -744,6 +771,7 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
|
|
|
744
771
|
...(sample ? { sample } : {}),
|
|
745
772
|
upstreamRequests: [
|
|
746
773
|
...buildSearchUpstreamRequests(searches, searchPageErrors),
|
|
774
|
+
...buildCountUpstreamRequests(result.count),
|
|
747
775
|
...analysisResults
|
|
748
776
|
.filter((entry) => entry.analysis || entry.error)
|
|
749
777
|
.map((entry) => ({
|
|
@@ -756,7 +784,7 @@ export function composeCohortEvidencePayload(request, result, options = {}) {
|
|
|
756
784
|
}))
|
|
757
785
|
]
|
|
758
786
|
},
|
|
759
|
-
...(result.search?.pagination ? { pagination: result.search.pagination } : {}),
|
|
787
|
+
...(result.search?.pagination ? { pagination: withPaginationTotal(result.search.pagination, totalMatches) } : {}),
|
|
760
788
|
...(warnings.length ? { warnings } : {})
|
|
761
789
|
};
|
|
762
790
|
}
|
|
@@ -1021,7 +1049,7 @@ function mergeSearchPages(searches, sampling) {
|
|
|
1021
1049
|
...(first.pagination ?? {}),
|
|
1022
1050
|
limit: first.pagination?.limit,
|
|
1023
1051
|
offset: first.pagination?.offset,
|
|
1024
|
-
total: first.pagination
|
|
1052
|
+
...(typeof first.pagination?.total === "number" ? { total: first.pagination.total } : {}),
|
|
1025
1053
|
nextOffset: last.pagination?.nextOffset
|
|
1026
1054
|
},
|
|
1027
1055
|
warnings: uniqueStrings(searches.flatMap((search) => safeWarnings(search)))
|
|
@@ -1038,12 +1066,12 @@ function buildSampleMetadata(request, searches, games, pageErrors) {
|
|
|
1038
1066
|
fetchedPages: searches.length,
|
|
1039
1067
|
fetchedGames: games.length,
|
|
1040
1068
|
skippedPages: pageErrors.length,
|
|
1041
|
-
|
|
1069
|
+
...(typeof searches[0]?.pagination?.total === "number" ? { totalMatches: searches[0].pagination.total } : {}),
|
|
1042
1070
|
quotaCaveat: "Sampled workflows issue one search request per fetched page, plus downstream per-game requests when the command needs detail evidence."
|
|
1043
1071
|
};
|
|
1044
1072
|
}
|
|
1045
1073
|
|
|
1046
|
-
function buildEvidencePackSample(sample, returnedRows) {
|
|
1074
|
+
function buildEvidencePackSample(sample, returnedRows, totalMatches) {
|
|
1047
1075
|
return {
|
|
1048
1076
|
mode: normalizeEvidencePackSampleMode(sample.mode),
|
|
1049
1077
|
requestedRows: sample.requestedSize,
|
|
@@ -1051,16 +1079,31 @@ function buildEvidencePackSample(sample, returnedRows) {
|
|
|
1051
1079
|
returnedRows,
|
|
1052
1080
|
fetchedPages: sample.fetchedPages,
|
|
1053
1081
|
skippedPages: sample.skippedPages,
|
|
1054
|
-
totalMatches: sample.totalMatches
|
|
1082
|
+
totalMatches: typeof totalMatches === "number" ? totalMatches : sample.totalMatches
|
|
1055
1083
|
};
|
|
1056
1084
|
}
|
|
1057
1085
|
|
|
1058
|
-
function buildReturnedPageSample(pagination, returnedRows) {
|
|
1086
|
+
function buildReturnedPageSample(pagination, returnedRows, totalMatches) {
|
|
1059
1087
|
return {
|
|
1060
1088
|
mode: "returned_page",
|
|
1061
1089
|
requestedRows: pagination?.limit,
|
|
1062
1090
|
returnedRows,
|
|
1063
|
-
totalMatches: pagination?.total
|
|
1091
|
+
totalMatches: typeof totalMatches === "number" ? totalMatches : pagination?.total
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function getTotalMatches(count, pagination) {
|
|
1096
|
+
const countValue = numericValue(count?.data?.count);
|
|
1097
|
+
if (Number.isFinite(countValue)) return countValue;
|
|
1098
|
+
const paginationTotal = numericValue(pagination?.total);
|
|
1099
|
+
return Number.isFinite(paginationTotal) ? paginationTotal : undefined;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function withPaginationTotal(pagination, totalMatches) {
|
|
1103
|
+
if (typeof totalMatches !== "number") return pagination;
|
|
1104
|
+
return {
|
|
1105
|
+
...(pagination ?? {}),
|
|
1106
|
+
total: totalMatches
|
|
1064
1107
|
};
|
|
1065
1108
|
}
|
|
1066
1109
|
|
|
@@ -1096,6 +1139,17 @@ function buildSearchUpstreamRequests(searches, pageErrors = []) {
|
|
|
1096
1139
|
];
|
|
1097
1140
|
}
|
|
1098
1141
|
|
|
1142
|
+
function buildCountUpstreamRequests(count) {
|
|
1143
|
+
if (!count) return [];
|
|
1144
|
+
return [{
|
|
1145
|
+
kind: "count",
|
|
1146
|
+
requestId: count?.meta?.requestId,
|
|
1147
|
+
source: count?.meta?.source,
|
|
1148
|
+
entitlements: count?.meta?.entitlements,
|
|
1149
|
+
count: count?.data?.count
|
|
1150
|
+
}];
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1099
1153
|
function buildReviewPatternGame(request, entry) {
|
|
1100
1154
|
const game = entry.game ?? {};
|
|
1101
1155
|
const externalId = asString(entry.externalId ?? game?.externalId ?? game?.appid);
|
package/src/workflows.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
|
|
4
|
-
import { composeCohortEvidencePayload } from "./workflows.js";
|
|
4
|
+
import { composeBenchmarkPayload, composeCohortEvidencePayload } from "./workflows.js";
|
|
5
5
|
|
|
6
6
|
test("cohort evidence summarizes skipped games by reason", () => {
|
|
7
7
|
const request = {
|
|
@@ -78,3 +78,30 @@ test("cohort evidence reports an empty reason tally when nothing is skipped", ()
|
|
|
78
78
|
|
|
79
79
|
assert.deepEqual(payload.data.cohort.skippedReasons, {});
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
test("benchmark attaches exact total only when a count response is supplied", () => {
|
|
83
|
+
const request = {
|
|
84
|
+
source: "steam",
|
|
85
|
+
query: { sort: "reviews_desc", limit: 20, includeTotal: false }
|
|
86
|
+
};
|
|
87
|
+
const payload = composeBenchmarkPayload(request, {
|
|
88
|
+
searches: [{
|
|
89
|
+
data: [{ appid: 1, title: "Usable", reviews: 100 }],
|
|
90
|
+
pagination: { limit: 20, offset: 0, nextOffset: 20 },
|
|
91
|
+
meta: { requestId: "search_req", source: "steam-analysis" }
|
|
92
|
+
}],
|
|
93
|
+
pageErrors: [],
|
|
94
|
+
count: {
|
|
95
|
+
data: { count: 123 },
|
|
96
|
+
meta: { requestId: "count_req", source: "steam-analysis" }
|
|
97
|
+
}
|
|
98
|
+
}, {
|
|
99
|
+
generatedAt: "2026-06-12T00:00:00.000Z",
|
|
100
|
+
requestId: "req_test"
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
assert.equal(payload.data.summary.totalMatches, 123);
|
|
104
|
+
assert.equal(payload.data.inputs.sample.totalMatches, 123);
|
|
105
|
+
assert.equal(payload.pagination.total, 123);
|
|
106
|
+
assert.deepEqual(payload.meta.upstreamRequests.map((entry) => entry.kind), ["search", "count"]);
|
|
107
|
+
});
|