dsh-all-usage 1.1.2 → 1.1.4
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 +111 -0
- package/README.md +183 -36
- package/assets/model-icons/LICENSE.upstream-lobe-icons.txt +51 -0
- package/assets/model-icons/claude-color.svg +1 -0
- package/assets/model-icons/deepseek-color.svg +1 -0
- package/assets/model-icons/doubao-color.svg +1 -0
- package/assets/model-icons/gemini-color.svg +1 -0
- package/assets/model-icons/grok.svg +1 -0
- package/assets/model-icons/kimi-color.svg +1 -0
- package/assets/model-icons/manifest.json +213 -0
- package/assets/model-icons/meta-color.svg +1 -0
- package/assets/model-icons/minimax-color.svg +1 -0
- package/assets/model-icons/openai-color.svg +1 -0
- package/assets/model-icons/qwen-color.svg +1 -0
- package/assets/model-icons/zhipu-color.svg +1 -0
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/fixtures/usage-events.json +172 -0
- package/lib/aggregation.js +1050 -0
- package/lib/balance.js +112 -0
- package/lib/client.js +1 -2906
- package/lib/http.js +305 -0
- package/lib/index.js +2 -2119
- package/lib/ledger.js +491 -0
- package/lib/plugin.js +277 -0
- package/lib/pricing-runtime.js +406 -0
- package/lib/pricing.js +631 -39
- package/lib/session-sync.js +642 -0
- package/lib/usage-core.js +171 -0
- package/package.json +28 -3
- package/scripts/replay-fixture.mjs +155 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
const TOKEN_FIELDS = Object.freeze({
|
|
2
|
+
input: 'inputTokens',
|
|
3
|
+
output: 'outputTokens',
|
|
4
|
+
cacheRead: 'cacheReadTokens',
|
|
5
|
+
cacheWrite: 'cacheWriteTokens',
|
|
6
|
+
reasoning: 'reasoningTokens',
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function validEventTime(value) {
|
|
14
|
+
return typeof value === 'number' && Number.isFinite(value) && Math.abs(value) <= 8640000000000000
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function tokenValue(source, field) {
|
|
18
|
+
const value = source[TOKEN_FIELDS[field]] !== undefined ? source[TOKEN_FIELDS[field]] : source[field]
|
|
19
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeUsageValues(usage) {
|
|
23
|
+
if (!isRecord(usage)) return null
|
|
24
|
+
return {
|
|
25
|
+
input: tokenValue(usage, 'input'),
|
|
26
|
+
output: tokenValue(usage, 'output'),
|
|
27
|
+
cacheRead: tokenValue(usage, 'cacheRead'),
|
|
28
|
+
cacheWrite: tokenValue(usage, 'cacheWrite'),
|
|
29
|
+
reasoning: tokenValue(usage, 'reasoning'),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function hasUsageValues(values) {
|
|
34
|
+
return values !== null && values !== undefined && (values.input > 0 || values.output > 0 || values.cacheRead > 0 || values.cacheWrite > 0 || values.reasoning > 0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stepIndex(value) {
|
|
38
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Extract the provider usage carried by one supported DSH event shape. */
|
|
42
|
+
export function extractUsageEvent(event) {
|
|
43
|
+
if (!isRecord(event) || !isRecord(event.data) || !validEventTime(event.time)) return null
|
|
44
|
+
const data = event.data
|
|
45
|
+
let kind
|
|
46
|
+
let usage
|
|
47
|
+
if (event.type === 'assistant/chunk') {
|
|
48
|
+
if (!isRecord(data.chunk) || data.chunk.type !== 'usage') return null
|
|
49
|
+
kind = 'chunk'
|
|
50
|
+
usage = data.chunk.usage
|
|
51
|
+
} else if (event.type === 'assistant/message') {
|
|
52
|
+
if (!Object.hasOwn(data, 'usage') || data.usage === undefined) return null
|
|
53
|
+
kind = 'message'
|
|
54
|
+
usage = data.usage
|
|
55
|
+
} else {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
const values = normalizeUsageValues(usage)
|
|
59
|
+
if (!hasUsageValues(values)) return null
|
|
60
|
+
const turn = stepIndex(data.turn)
|
|
61
|
+
const step = stepIndex(data.step)
|
|
62
|
+
if (turn === null || step === null) return null
|
|
63
|
+
return { kind, data, usage, values, turn, step }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stableJson(value, ancestors = new Set()) {
|
|
67
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number') return JSON.stringify(value)
|
|
68
|
+
if (value === undefined) return 'null'
|
|
69
|
+
if (typeof value !== 'object') return JSON.stringify(String(value))
|
|
70
|
+
if (ancestors.has(value)) return '"[Circular]"'
|
|
71
|
+
ancestors.add(value)
|
|
72
|
+
let result
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
result = '[' + value.map((item) => stableJson(item, ancestors)).join(',') + ']'
|
|
75
|
+
} else {
|
|
76
|
+
result = '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + stableJson(value[key], ancestors)).join(',') + '}'
|
|
77
|
+
}
|
|
78
|
+
ancestors.delete(value)
|
|
79
|
+
return result
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stableIdentity(value) {
|
|
83
|
+
if (typeof value === 'string' && value.trim() !== '') return value.trim()
|
|
84
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build a stable logical usage key across live, scan, and flush materializations. */
|
|
89
|
+
export function usageStepKey(sid, data, seq, fallback) {
|
|
90
|
+
const safeSid = typeof sid === 'string' ? sid : String(sid === undefined || sid === null ? '' : sid)
|
|
91
|
+
const turn = stepIndex(data && data.turn)
|
|
92
|
+
const step = stepIndex(data && data.step)
|
|
93
|
+
if (turn !== null && step !== null) return safeSid + ':step:' + turn + ':' + step
|
|
94
|
+
const message = data && isRecord(data.message) ? data.message : null
|
|
95
|
+
const logicalId = [
|
|
96
|
+
data && data.usageId,
|
|
97
|
+
data && data.sampleId,
|
|
98
|
+
data && data.requestId,
|
|
99
|
+
data && data.callId,
|
|
100
|
+
data && data.stepId,
|
|
101
|
+
data && data.messageId,
|
|
102
|
+
message && message.id,
|
|
103
|
+
].map(stableIdentity).find((value) => value !== null)
|
|
104
|
+
if (logicalId !== undefined) return safeSid + ':logical:' + logicalId
|
|
105
|
+
if (Number.isSafeInteger(seq) && seq >= 0) return safeSid + ':event:' + seq
|
|
106
|
+
const serialized = stableJson(isRecord(data) ? data : {})
|
|
107
|
+
return safeSid + ':event:' + (serialized === undefined ? String(fallback === undefined ? '' : fallback) : serialized)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Recalculate the billing instant for a re-priced sample, preferring the recorded request time. */
|
|
111
|
+
export function billingInstantOf(item, oldCost) {
|
|
112
|
+
const oldAt = oldCost != null && Number.isFinite(oldCost.pricingAt) ? oldCost.pricingAt : null
|
|
113
|
+
const at = Number.isFinite(item.pricingAt) ? item.pricingAt : oldAt !== null ? oldAt : validEventTime(item.time) ? item.time : null
|
|
114
|
+
const oldSource = oldCost != null && (oldCost.pricingTimeSource === 'request-context' || oldCost.pricingTimeSource === 'usage-event') ? oldCost.pricingTimeSource : null
|
|
115
|
+
const source = typeof item.pricingTimeSource === 'string' && (item.pricingTimeSource === 'request-context' || item.pricingTimeSource === 'usage-event') ? item.pricingTimeSource : (oldSource || 'usage-event')
|
|
116
|
+
return { at, source }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Bounded context archive shared by the live fold and the persisted ledger. */
|
|
120
|
+
export const MAX_CONTEXT_TIMES = 512
|
|
121
|
+
|
|
122
|
+
/** Insert-or-refresh a context entry with real LRU eviction (delete + set). */
|
|
123
|
+
export function touchContextTimes(times, key, time) {
|
|
124
|
+
if (typeof key !== 'string' || key === '') return
|
|
125
|
+
if (times.has(key)) times.delete(key)
|
|
126
|
+
times.set(key, time)
|
|
127
|
+
while (times.size > MAX_CONTEXT_TIMES) times.delete(times.keys().next().value)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Stable per-(turn, step) key for the pricing-time context of a request. */
|
|
131
|
+
export function contextTimeKey(turn, step) {
|
|
132
|
+
const safeTurn = Number.isSafeInteger(turn) && turn >= 0 ? turn : null
|
|
133
|
+
const safeStep = Number.isSafeInteger(step) && step >= 0 ? step : null
|
|
134
|
+
if (safeTurn !== null && safeStep !== null) return 'context:' + safeTurn + ':' + safeStep
|
|
135
|
+
return 'context:__latest__'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Pick the billing instant for a usage event: the request/context time that
|
|
140
|
+
* matches the same turn/step wins (parallel requests stay per-step), otherwise
|
|
141
|
+
* the usage event time itself is the auditable fallback.
|
|
142
|
+
*/
|
|
143
|
+
export function pickPricingTime(contextTimes, eventTime, turn, step) {
|
|
144
|
+
if (contextTimes instanceof Map && validEventTime(eventTime)) {
|
|
145
|
+
const exact = contextTimes.get(contextTimeKey(turn, step))
|
|
146
|
+
const candidate = exact !== undefined ? exact : contextTimes.get(contextTimeKey(null, null))
|
|
147
|
+
if (Number.isFinite(candidate) && candidate <= eventTime) {
|
|
148
|
+
return { time: candidate, source: 'request-context' }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { time: Number.isFinite(eventTime) && eventTime >= 0 ? eventTime : null, source: 'usage-event' }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Replace one logical sample; stale lower-seq replays are ignored. */
|
|
155
|
+
/** Event sequence contract: -1 (missing) or a non-negative safe integer. */
|
|
156
|
+
export function normalizeEventSeq(value) {
|
|
157
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : -1
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function upsertUsageSample(samples, sample) {
|
|
161
|
+
if (!(samples instanceof Map) || !isRecord(sample) || typeof sample.key !== 'string' || sample.key === '') return { accepted: false, reason: 'invalid-sample', previous: undefined, next: undefined }
|
|
162
|
+
const previous = samples.get(sample.key)
|
|
163
|
+
const seq = normalizeEventSeq(sample.seq)
|
|
164
|
+
const previousSeq = normalizeEventSeq(previous === undefined ? -1 : previous.seq)
|
|
165
|
+
if (previous !== undefined && seq >= 0 && previousSeq > seq) return { accepted: false, reason: 'stale-sample', key: sample.key, previous, next: previous }
|
|
166
|
+
const next = { ...sample, seq }
|
|
167
|
+
samples.set(sample.key, next)
|
|
168
|
+
return { accepted: true, replaced: previous !== undefined, key: sample.key, previous, next }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { TOKEN_FIELDS }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-all-usage",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -8,8 +8,16 @@
|
|
|
8
8
|
},
|
|
9
9
|
"homepage": "https://github.com/ParticleLight/dsh-all-usage#readme",
|
|
10
10
|
"type": "module",
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=22 <25"
|
|
13
|
+
},
|
|
11
14
|
"scripts": {
|
|
12
|
-
"
|
|
15
|
+
"build:client": "node scripts/build-client.mjs",
|
|
16
|
+
"test": "node --test",
|
|
17
|
+
"fixture:check": "node scripts/replay-fixture.mjs fixtures/usage-events.json",
|
|
18
|
+
"test:runtime": "node --test test/dsh-runtime.test.js",
|
|
19
|
+
"check:pack": "node scripts/check-package.mjs",
|
|
20
|
+
"prepack": "npm run build:client"
|
|
13
21
|
},
|
|
14
22
|
"main": "lib/index.js",
|
|
15
23
|
"exports": {
|
|
@@ -22,6 +30,10 @@
|
|
|
22
30
|
"./package.json": "./package.json"
|
|
23
31
|
},
|
|
24
32
|
"dsh": {
|
|
33
|
+
"compatibility": {
|
|
34
|
+
"runtime": ">=0.1.1-rc.1 <0.1.2",
|
|
35
|
+
"verified": ["0.1.1-rc.2", "0.1.1-rc.1"]
|
|
36
|
+
},
|
|
25
37
|
"bundle": {
|
|
26
38
|
"patch": "./cordis.patch.yml"
|
|
27
39
|
},
|
|
@@ -36,11 +48,21 @@
|
|
|
36
48
|
},
|
|
37
49
|
"files": [
|
|
38
50
|
"lib/index.js",
|
|
51
|
+
"lib/plugin.js",
|
|
52
|
+
"lib/aggregation.js",
|
|
53
|
+
"lib/ledger.js",
|
|
54
|
+
"lib/session-sync.js",
|
|
55
|
+
"lib/pricing-runtime.js",
|
|
56
|
+
"lib/balance.js",
|
|
57
|
+
"lib/http.js",
|
|
39
58
|
"lib/client.js",
|
|
40
59
|
"lib/pricing.js",
|
|
60
|
+
"lib/usage-core.js",
|
|
41
61
|
"CHANGELOG.md",
|
|
42
62
|
"cordis.patch.yml",
|
|
43
63
|
"screenshots.json",
|
|
64
|
+
"fixtures/usage-events.json",
|
|
65
|
+
"scripts/replay-fixture.mjs",
|
|
44
66
|
"assets"
|
|
45
67
|
],
|
|
46
68
|
"keywords": [
|
|
@@ -53,5 +75,8 @@
|
|
|
53
75
|
"tokens",
|
|
54
76
|
"balance"
|
|
55
77
|
],
|
|
56
|
-
"license": "MIT"
|
|
78
|
+
"license": "MIT",
|
|
79
|
+
"devDependencies": {
|
|
80
|
+
"terser": "5.51.2"
|
|
81
|
+
}
|
|
57
82
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
5
|
+
import { apply } from '../lib/index.js'
|
|
6
|
+
|
|
7
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
8
|
+
|
|
9
|
+
function makeResponse() {
|
|
10
|
+
let body = ''
|
|
11
|
+
const headers = {}
|
|
12
|
+
return {
|
|
13
|
+
res: {
|
|
14
|
+
statusCode: 200,
|
|
15
|
+
setHeader(name, value) {
|
|
16
|
+
headers[String(name).toLowerCase()] = value
|
|
17
|
+
},
|
|
18
|
+
end(value = '') {
|
|
19
|
+
body += String(value)
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
body: () => body,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeRequest(url) {
|
|
27
|
+
return {
|
|
28
|
+
method: 'GET',
|
|
29
|
+
url,
|
|
30
|
+
headers: { host: '127.0.0.1:3080' },
|
|
31
|
+
socket: { remoteAddress: '127.0.0.1' },
|
|
32
|
+
on(event, callback) {
|
|
33
|
+
if (event === 'end') callback()
|
|
34
|
+
return this
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createHarness(fixture) {
|
|
40
|
+
const routes = new Map()
|
|
41
|
+
const listeners = new Map()
|
|
42
|
+
const cleanups = []
|
|
43
|
+
const webServer = {
|
|
44
|
+
register(route) {
|
|
45
|
+
routes.set(route.path, route.handler)
|
|
46
|
+
return () => routes.delete(route.path)
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
const ctx = {
|
|
50
|
+
sessionQuery: {
|
|
51
|
+
async listSessions() {
|
|
52
|
+
return fixture.sessions
|
|
53
|
+
},
|
|
54
|
+
async readSession(sessionId) {
|
|
55
|
+
return { events: fixture.events[sessionId] || [] }
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
workspaceRegistry: {
|
|
59
|
+
list() {
|
|
60
|
+
return fixture.workspaces
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
async timeout() {},
|
|
64
|
+
on(name, handler) {
|
|
65
|
+
const current = listeners.get(name) || []
|
|
66
|
+
current.push(handler)
|
|
67
|
+
listeners.set(name, current)
|
|
68
|
+
},
|
|
69
|
+
effect(factory) {
|
|
70
|
+
const cleanup = factory()
|
|
71
|
+
if (typeof cleanup === 'function') cleanups.push(cleanup)
|
|
72
|
+
return cleanup
|
|
73
|
+
},
|
|
74
|
+
get(name) {
|
|
75
|
+
return name === 'webServer' ? webServer : undefined
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
return { ctx, routes, cleanups, listeners }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function invoke(harness, url) {
|
|
82
|
+
const pathname = new URL(url || '/', 'http://fixture.local').pathname
|
|
83
|
+
const handler = harness.routes.get(pathname)
|
|
84
|
+
assert.equal(typeof handler, 'function', 'fixture route should be registered: ' + pathname)
|
|
85
|
+
const response = makeResponse()
|
|
86
|
+
await handler(makeRequest(url), response.res)
|
|
87
|
+
return { status: response.res.statusCode, body: JSON.parse(response.body()) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function projectModel(row) {
|
|
91
|
+
return {
|
|
92
|
+
provider: row.provider,
|
|
93
|
+
requestedModel: row.requestedModel,
|
|
94
|
+
actualModel: row.actualModel,
|
|
95
|
+
calls: row.calls,
|
|
96
|
+
input: row.input,
|
|
97
|
+
output: row.output,
|
|
98
|
+
cacheRead: row.cacheRead,
|
|
99
|
+
cacheWrite: row.cacheWrite,
|
|
100
|
+
reasoning: row.reasoning,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertExpected(fixture, snapshot, records) {
|
|
105
|
+
const expected = fixture.expected
|
|
106
|
+
const totals = snapshot.totals
|
|
107
|
+
assert.deepEqual({
|
|
108
|
+
turns: totals.turns,
|
|
109
|
+
sessions: totals.sessions,
|
|
110
|
+
input: totals.input,
|
|
111
|
+
output: totals.output,
|
|
112
|
+
cacheRead: totals.cacheRead,
|
|
113
|
+
cacheWrite: totals.cacheWrite,
|
|
114
|
+
reasoning: totals.reasoning,
|
|
115
|
+
}, expected.totals)
|
|
116
|
+
assert.equal(records.items.length, expected.records)
|
|
117
|
+
assert.deepEqual(snapshot.perModel.map(projectModel).sort((left, right) => String(left.provider).localeCompare(String(right.provider))), expected.models)
|
|
118
|
+
assert.equal(totals.cost.unpricedCalls + totals.cost.ambiguousCalls + totals.cost.unsupportedCalls + totals.cost.pricedCalls, expected.records)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function runFixture(fixture) {
|
|
122
|
+
assert.equal(fixture.schemaVersion, 1)
|
|
123
|
+
const harness = createHarness(fixture)
|
|
124
|
+
apply(harness.ctx)
|
|
125
|
+
try {
|
|
126
|
+
let snapshot = null
|
|
127
|
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
128
|
+
snapshot = await invoke(harness, '/api/all-usage')
|
|
129
|
+
if (snapshot.status === 200 && snapshot.body.scan.done === true) break
|
|
130
|
+
await new Promise((resolvePromise) => setImmediate(resolvePromise))
|
|
131
|
+
}
|
|
132
|
+
assert.ok(snapshot && snapshot.body && snapshot.body.scan.done === true, 'fixture scan did not complete')
|
|
133
|
+
const records = await invoke(harness, '/api/all-usage/records?start=' + fixture.query.start + '&end=' + fixture.query.end + '&utc=' + fixture.query.utc + '&limit=200')
|
|
134
|
+
assert.equal(records.status, 200)
|
|
135
|
+
assertExpected(fixture, snapshot.body, records.body)
|
|
136
|
+
return {
|
|
137
|
+
fixture: fixture.name,
|
|
138
|
+
totals: snapshot.body.totals,
|
|
139
|
+
models: snapshot.body.perModel.map(projectModel),
|
|
140
|
+
records: records.body.items.length,
|
|
141
|
+
}
|
|
142
|
+
} finally {
|
|
143
|
+
for (const cleanup of harness.cleanups.slice().reverse()) await cleanup()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function main() {
|
|
148
|
+
const fixturePath = process.argv[2] === undefined ? join(PACKAGE_ROOT, 'fixtures', 'usage-events.json') : resolve(process.argv[2])
|
|
149
|
+
const fixture = JSON.parse(await readFile(fixturePath, 'utf8'))
|
|
150
|
+
const result = await runFixture(fixture)
|
|
151
|
+
console.log(JSON.stringify(result, null, 2))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const invokedPath = process.argv[1] === undefined ? null : pathToFileURL(resolve(process.argv[1])).href
|
|
155
|
+
if (invokedPath === import.meta.url) await main()
|