@crewhaus/target-onchain-game 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +40 -0
- package/{src/index.ts → dist/index.js} +58 -84
- package/package.json +11 -8
- package/src/index.test.ts +0 -179
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog F2 `target-onchain-game` — §47.
|
|
3
|
+
*
|
|
4
|
+
* Codegen for the `onchain-game` perceive-act-perceive loop. The
|
|
5
|
+
* emitted bundle is a single `agent.ts` file that:
|
|
6
|
+
*
|
|
7
|
+
* 1. Constructs the chain adapter from `chain` (slice-0
|
|
8
|
+
* `@crewhaus/chain-adapter-evm` is the only adapter shipped
|
|
9
|
+
* today).
|
|
10
|
+
* 2. Constructs a wallet engine bound to the single player wallet.
|
|
11
|
+
* 3. Defines a `playOneTurn()` function that:
|
|
12
|
+
* a. Reads game state via `game.stateReader` (a view function).
|
|
13
|
+
* b. Classifies the raw state payload via
|
|
14
|
+
* `classifyBoundary({origin: "chain"})`.
|
|
15
|
+
* c. Calls the agent with the state + objective in the user
|
|
16
|
+
* message (one `runChatLoop({singleTurn: true})` call).
|
|
17
|
+
* d. Parses the agent's terminal output for a move action and
|
|
18
|
+
* dispatches via the wallet-engine
|
|
19
|
+
* `requestSignAndBroadcast()` flow.
|
|
20
|
+
* e. Waits for confirmation up to `moveTimeoutMs` (real-time)
|
|
21
|
+
* or indefinitely (turn-based / async).
|
|
22
|
+
* 4. For `turn-based`: loops `playOneTurn()` until the agent
|
|
23
|
+
* reports the objective met or the move count hits a configured
|
|
24
|
+
* ceiling. For `real-time`: drives the loop on a fixed cadence.
|
|
25
|
+
* For `async`: subscribes to a state-change event and runs one
|
|
26
|
+
* turn per inbound state mutation.
|
|
27
|
+
* 5. Emits JSON events for turn-start / turn-end / move-broadcast /
|
|
28
|
+
* move-confirmed / game-over.
|
|
29
|
+
*
|
|
30
|
+
* Slice 2 ships the codegen surface — the emitted file references
|
|
31
|
+
* runtime packages by name. A follow-up slice wires the agent run
|
|
32
|
+
* loop end-to-end against an anvil fork.
|
|
33
|
+
*/
|
|
34
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
35
|
+
import type { Bundle, IrChainGameV0 } from "@crewhaus/ir";
|
|
36
|
+
export declare class TargetEmitError extends CrewhausError {
|
|
37
|
+
readonly name = "TargetEmitError";
|
|
38
|
+
constructor(message: string, cause?: unknown);
|
|
39
|
+
}
|
|
40
|
+
export declare function emitOnchainGame(ir: IrChainGameV0): Bundle;
|
|
@@ -33,22 +33,17 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { CrewhausError } from "@crewhaus/errors";
|
|
35
35
|
import { escapeJsonString } from "@crewhaus/infra-utils";
|
|
36
|
-
import type { Bundle, IrChainGameV0, IrSecretRef } from "@crewhaus/ir";
|
|
37
|
-
|
|
38
36
|
export class TargetEmitError extends CrewhausError {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
name = "TargetEmitError";
|
|
38
|
+
constructor(message, cause) {
|
|
39
|
+
super("compiler", message, cause);
|
|
40
|
+
}
|
|
43
41
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
`missing required env var ${ref.name}`,
|
|
49
|
-
)}); })()`;
|
|
42
|
+
function renderSecretRef(ref) {
|
|
43
|
+
if (ref.kind === "literal")
|
|
44
|
+
return JSON.stringify(ref.value);
|
|
45
|
+
return `process.env[${JSON.stringify(ref.name)}] ?? (() => { throw new Error(${JSON.stringify(`missing required env var ${ref.name}`)}); })()`;
|
|
50
46
|
}
|
|
51
|
-
|
|
52
47
|
/**
|
|
53
48
|
* #159 (CWE-798) — a wallet signing key must never be a bare literal in
|
|
54
49
|
* the emitted DO-NOT-EDIT artifact. The compiler's `lowerWalletKeyRef`
|
|
@@ -58,76 +53,56 @@ function renderSecretRef(ref: IrSecretRef): string {
|
|
|
58
53
|
* key handles are permitted as literals.
|
|
59
54
|
*/
|
|
60
55
|
const KEY_HANDLE_RE = /^(kms|hsm):\/\/.+/;
|
|
61
|
-
function renderWalletKeyRef(walletId
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
return renderSecretRef(ref);
|
|
56
|
+
function renderWalletKeyRef(walletId, ref) {
|
|
57
|
+
if (ref.kind === "literal" && !KEY_HANDLE_RE.test(ref.value)) {
|
|
58
|
+
throw new TargetEmitError(`wallet "${walletId}" keyRef is a literal signing key; refusing to embed it in the generated bundle. Use an environment reference or a kms:// / hsm:// handle.`);
|
|
59
|
+
}
|
|
60
|
+
return renderSecretRef(ref);
|
|
68
61
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
: "";
|
|
114
|
-
|
|
115
|
-
const objectiveField =
|
|
116
|
-
ir.game.objective !== undefined ? `\n objective: ${escapeJsonString(ir.game.objective)},` : "";
|
|
117
|
-
const moveTimeoutField =
|
|
118
|
-
ir.game.moveTimeoutMs !== undefined ? `\n moveTimeoutMs: ${ir.game.moveTimeoutMs},` : "";
|
|
119
|
-
const actionsContractField =
|
|
120
|
-
ir.game.actionsContract !== undefined
|
|
121
|
-
? `\n actionsContract: ${JSON.stringify(ir.game.actionsContract)},`
|
|
122
|
-
: "";
|
|
123
|
-
|
|
124
|
-
const instructions = escapeJsonString(ir.agent.instructions);
|
|
125
|
-
const name = escapeJsonString(ir.name);
|
|
126
|
-
|
|
127
|
-
// Header is a `//` line comment using the JSON-escaped name: a raw ir.name in
|
|
128
|
-
// a block comment lets a crafted spec.name containing `*/` (or a newline) break
|
|
129
|
-
// out of the comment and inject top-level code — RCE on build/run (#147).
|
|
130
|
-
const content = `// Generated by @crewhaus/target-onchain-game (§47 onchain-game).
|
|
62
|
+
export function emitOnchainGame(ir) {
|
|
63
|
+
if (ir.chain.id !== ir.wallet.chainId) {
|
|
64
|
+
throw new TargetEmitError(`wallet.chainId "${ir.wallet.chainId}" does not match chain.id "${ir.chain.id}"`);
|
|
65
|
+
}
|
|
66
|
+
if (ir.chain.id !== ir.game.contract.chainId) {
|
|
67
|
+
throw new TargetEmitError(`game.contract.chainId "${ir.game.contract.chainId}" does not match chain.id "${ir.chain.id}"`);
|
|
68
|
+
}
|
|
69
|
+
if (ir.game.turnSemantics === "real-time" && ir.game.moveTimeoutMs === undefined) {
|
|
70
|
+
throw new TargetEmitError("real-time games require game.moveTimeoutMs so the loop can bound per-move spend");
|
|
71
|
+
}
|
|
72
|
+
const rpcs = ir.chain.rpcUrls.map(renderSecretRef).join(", ");
|
|
73
|
+
const finality = ir.chain.finality.kind === "confirmations"
|
|
74
|
+
? `{ kind: "confirmations", count: ${ir.chain.finality.count} }`
|
|
75
|
+
: `{ kind: ${JSON.stringify(ir.chain.finality.kind)} }`;
|
|
76
|
+
const policy = ir.transactionPolicy;
|
|
77
|
+
// #151 — resolve the declared game contract (id -> address) into the policy
|
|
78
|
+
// so the wallet-engine can bind `tx.to` to the address registered for the
|
|
79
|
+
// claimed contractId. A game is single-contract, so the map carries exactly
|
|
80
|
+
// the one entry the move-broadcast flow can target.
|
|
81
|
+
const contractAddresses = {
|
|
82
|
+
[ir.game.contract.id]: ir.game.contract.address,
|
|
83
|
+
};
|
|
84
|
+
const policyLiteral = JSON.stringify({
|
|
85
|
+
defaultWriteApproval: policy.defaultWriteApproval,
|
|
86
|
+
allowedContracts: policy.allowedContracts,
|
|
87
|
+
simulationRequired: policy.simulationRequired,
|
|
88
|
+
...(policy.maxValueUsd !== undefined ? { maxValueUsd: policy.maxValueUsd } : {}),
|
|
89
|
+
...(policy.maxValueWei !== undefined ? { maxValueWei: policy.maxValueWei } : {}),
|
|
90
|
+
contractAddresses,
|
|
91
|
+
});
|
|
92
|
+
const walletKeyRef = ir.wallet.keyRef !== undefined
|
|
93
|
+
? `, keyRef: ${renderWalletKeyRef(ir.wallet.id, ir.wallet.keyRef)}`
|
|
94
|
+
: "";
|
|
95
|
+
const objectiveField = ir.game.objective !== undefined ? `\n objective: ${escapeJsonString(ir.game.objective)},` : "";
|
|
96
|
+
const moveTimeoutField = ir.game.moveTimeoutMs !== undefined ? `\n moveTimeoutMs: ${ir.game.moveTimeoutMs},` : "";
|
|
97
|
+
const actionsContractField = ir.game.actionsContract !== undefined
|
|
98
|
+
? `\n actionsContract: ${JSON.stringify(ir.game.actionsContract)},`
|
|
99
|
+
: "";
|
|
100
|
+
const instructions = escapeJsonString(ir.agent.instructions);
|
|
101
|
+
const name = escapeJsonString(ir.name);
|
|
102
|
+
// Header is a `//` line comment using the JSON-escaped name: a raw ir.name in
|
|
103
|
+
// a block comment lets a crafted spec.name containing `*/` (or a newline) break
|
|
104
|
+
// out of the comment and inject top-level code — RCE on build/run (#147).
|
|
105
|
+
const content = `// Generated by @crewhaus/target-onchain-game (§47 onchain-game).
|
|
131
106
|
// Compiled from spec: ${name}
|
|
132
107
|
// DO NOT EDIT — re-run \`crewhaus compile\` to regenerate.
|
|
133
108
|
import { classifyBoundary } from "@crewhaus/boundary-classifier";
|
|
@@ -209,6 +184,5 @@ function selectorOf(method: string): string {
|
|
|
209
184
|
);
|
|
210
185
|
}
|
|
211
186
|
`;
|
|
212
|
-
|
|
213
|
-
return { files: [{ path: "agent.ts", content }] };
|
|
187
|
+
return { files: [{ path: "agent.ts", content }] };
|
|
214
188
|
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/target-onchain-game",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Codegen for the §47 `onchain-game` perceive-act-perceive loop. Emits a daemon that reads game state, asks the model for a move, broadcasts the move, awaits confirmation, and repeats.",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
16
|
-
"@crewhaus/infra-utils": "0.1.
|
|
17
|
-
"@crewhaus/ir": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.5",
|
|
19
|
+
"@crewhaus/infra-utils": "0.1.5",
|
|
20
|
+
"@crewhaus/ir": "0.1.5"
|
|
18
21
|
},
|
|
19
22
|
"license": "Apache-2.0",
|
|
20
23
|
"author": {
|
|
@@ -34,5 +37,5 @@
|
|
|
34
37
|
"publishConfig": {
|
|
35
38
|
"access": "public"
|
|
36
39
|
},
|
|
37
|
-
"files": ["
|
|
40
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
38
41
|
}
|
package/src/index.test.ts
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { IrChainGameV0 } from "@crewhaus/ir";
|
|
3
|
-
import { TargetEmitError, emitOnchainGame } from "./index";
|
|
4
|
-
|
|
5
|
-
function baseIr(overrides: Partial<IrChainGameV0> = {}): IrChainGameV0 {
|
|
6
|
-
return {
|
|
7
|
-
version: 0,
|
|
8
|
-
name: "tic-tac-toe-agent",
|
|
9
|
-
target: "onchain-game",
|
|
10
|
-
agent: { model: "claude-opus-4-7", instructions: "Play optimal moves." },
|
|
11
|
-
chain: {
|
|
12
|
-
id: "base-sepolia",
|
|
13
|
-
kind: "evm",
|
|
14
|
-
rpcUrls: [{ kind: "env", name: "BASE_SEPOLIA_RPC" }],
|
|
15
|
-
rpcPolicy: "single",
|
|
16
|
-
finality: { kind: "confirmations", count: 1 },
|
|
17
|
-
reorgTolerant: true,
|
|
18
|
-
},
|
|
19
|
-
wallet: {
|
|
20
|
-
id: "player",
|
|
21
|
-
chainId: "base-sepolia",
|
|
22
|
-
custody: "local",
|
|
23
|
-
signingPolicy: "automated",
|
|
24
|
-
keyRef: { kind: "env", name: "PLAYER_KEY" },
|
|
25
|
-
},
|
|
26
|
-
game: {
|
|
27
|
-
contract: {
|
|
28
|
-
id: "tictactoe",
|
|
29
|
-
chainId: "base-sepolia",
|
|
30
|
-
address: "0xgame",
|
|
31
|
-
abiRef: "abi://tictactoe",
|
|
32
|
-
},
|
|
33
|
-
stateReader: "9af14a16",
|
|
34
|
-
turnSemantics: "turn-based",
|
|
35
|
-
objective: "Win or draw — never lose.",
|
|
36
|
-
},
|
|
37
|
-
transactionPolicy: {
|
|
38
|
-
defaultWriteApproval: "policy",
|
|
39
|
-
allowedContracts: ["tictactoe"],
|
|
40
|
-
simulationRequired: true,
|
|
41
|
-
},
|
|
42
|
-
tools: [],
|
|
43
|
-
toolConfigs: {},
|
|
44
|
-
mcp_servers: {},
|
|
45
|
-
permissions: { rules: [] },
|
|
46
|
-
compaction: {},
|
|
47
|
-
...overrides,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe("emitOnchainGame — happy path", () => {
|
|
52
|
-
test("emits a single agent.ts with game metadata", () => {
|
|
53
|
-
const bundle = emitOnchainGame(baseIr());
|
|
54
|
-
expect(bundle.files).toHaveLength(1);
|
|
55
|
-
const file = bundle.files[0];
|
|
56
|
-
expect(file?.path).toBe("agent.ts");
|
|
57
|
-
expect(file?.content).toContain('SPEC_NAME = "tic-tac-toe-agent"');
|
|
58
|
-
expect(file?.content).toContain('AGENT_MODEL = "claude-opus-4-7"');
|
|
59
|
-
expect(file?.content).toContain('AGENT_INSTRUCTIONS = "Play optimal moves."');
|
|
60
|
-
expect(file?.content).toContain('chainId: "base-sepolia"');
|
|
61
|
-
expect(file?.content).toContain('id: "tictactoe"');
|
|
62
|
-
expect(file?.content).toContain('stateReader: "9af14a16"');
|
|
63
|
-
expect(file?.content).toContain('turnSemantics: "turn-based"');
|
|
64
|
-
expect(file?.content).toContain('objective: "Win or draw');
|
|
65
|
-
expect(file?.content).toContain("createEvmAdapter");
|
|
66
|
-
expect(file?.content).toContain("classifyBoundary");
|
|
67
|
-
expect(file?.content).toContain('origin: "chain"');
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("wallet keyRef env-secret renders as process.env lookup", () => {
|
|
71
|
-
const bundle = emitOnchainGame(baseIr());
|
|
72
|
-
const c = bundle.files[0]?.content ?? "";
|
|
73
|
-
expect(c).toContain('process.env["PLAYER_KEY"]');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("renders the transaction_policy literal", () => {
|
|
77
|
-
const bundle = emitOnchainGame(baseIr());
|
|
78
|
-
const c = bundle.files[0]?.content ?? "";
|
|
79
|
-
expect(c).toContain('"allowedContracts":["tictactoe"]');
|
|
80
|
-
expect(c).toContain('"simulationRequired":true');
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
// #151 activation — the single game contract is resolved into the policy's
|
|
84
|
-
// contractId -> address map so the wallet-engine binds tx.to.
|
|
85
|
-
test("populates transaction_policy.contractAddresses from the game contract", () => {
|
|
86
|
-
const bundle = emitOnchainGame(baseIr());
|
|
87
|
-
const c = bundle.files[0]?.content ?? "";
|
|
88
|
-
expect(c).toContain('"contractAddresses":{"tictactoe":"0xgame"}');
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
// #159 (CWE-798) — a kms:// / hsm:// key handle is a permitted literal.
|
|
92
|
-
test("renders a kms:// keyRef handle verbatim", () => {
|
|
93
|
-
const bundle = emitOnchainGame(
|
|
94
|
-
baseIr({
|
|
95
|
-
wallet: {
|
|
96
|
-
id: "player",
|
|
97
|
-
chainId: "base-sepolia",
|
|
98
|
-
custody: "kms",
|
|
99
|
-
signingPolicy: "automated",
|
|
100
|
-
keyRef: { kind: "literal", value: "kms://aws/player-key" },
|
|
101
|
-
},
|
|
102
|
-
}),
|
|
103
|
-
);
|
|
104
|
-
const c = bundle.files[0]?.content ?? "";
|
|
105
|
-
expect(c).toContain('"kms://aws/player-key"');
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe("emitOnchainGame — validation", () => {
|
|
110
|
-
test("rejects when wallet.chainId differs from chain.id", () => {
|
|
111
|
-
expect(() =>
|
|
112
|
-
emitOnchainGame(
|
|
113
|
-
baseIr({
|
|
114
|
-
wallet: {
|
|
115
|
-
id: "player",
|
|
116
|
-
chainId: "polygon-mainnet",
|
|
117
|
-
custody: "local",
|
|
118
|
-
signingPolicy: "automated",
|
|
119
|
-
keyRef: { kind: "env", name: "PLAYER_KEY" },
|
|
120
|
-
},
|
|
121
|
-
}),
|
|
122
|
-
),
|
|
123
|
-
).toThrow(TargetEmitError);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("rejects when game.contract.chainId differs from chain.id", () => {
|
|
127
|
-
const ir = baseIr();
|
|
128
|
-
expect(() =>
|
|
129
|
-
emitOnchainGame({
|
|
130
|
-
...ir,
|
|
131
|
-
game: {
|
|
132
|
-
...ir.game,
|
|
133
|
-
contract: { ...ir.game.contract, chainId: "polygon-mainnet" },
|
|
134
|
-
},
|
|
135
|
-
}),
|
|
136
|
-
).toThrow(TargetEmitError);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test("rejects real-time games without moveTimeoutMs", () => {
|
|
140
|
-
const ir = baseIr();
|
|
141
|
-
expect(() =>
|
|
142
|
-
emitOnchainGame({
|
|
143
|
-
...ir,
|
|
144
|
-
game: { ...ir.game, turnSemantics: "real-time" },
|
|
145
|
-
}),
|
|
146
|
-
).toThrow(/moveTimeoutMs/);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
// #159 (CWE-798) — a literal (raw hex private key) wallet keyRef must not be
|
|
150
|
-
// baked into the emitted artifact.
|
|
151
|
-
test("rejects a literal (hex private key) wallet keyRef", () => {
|
|
152
|
-
expect(() =>
|
|
153
|
-
emitOnchainGame(
|
|
154
|
-
baseIr({
|
|
155
|
-
wallet: {
|
|
156
|
-
id: "player",
|
|
157
|
-
chainId: "base-sepolia",
|
|
158
|
-
custody: "local",
|
|
159
|
-
signingPolicy: "automated",
|
|
160
|
-
keyRef: {
|
|
161
|
-
kind: "literal",
|
|
162
|
-
value: "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
}),
|
|
166
|
-
),
|
|
167
|
-
).toThrow(TargetEmitError);
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
describe("emitOnchainGame — spec-name codegen injection (#147)", () => {
|
|
172
|
-
test("a crafted name cannot break out of the header comment", () => {
|
|
173
|
-
const evil = "safe */ globalThis.__PWNED_GAME__ = 1; /*\nmore";
|
|
174
|
-
const content = emitOnchainGame(baseIr({ name: evil })).files[0]?.content ?? "";
|
|
175
|
-
expect(content).toMatch(/^\/\/ Compiled from spec:/m);
|
|
176
|
-
expect(content).not.toMatch(/^globalThis\.__PWNED_GAME__/m);
|
|
177
|
-
expect(content).not.toContain("*/\nglobalThis");
|
|
178
|
-
});
|
|
179
|
-
});
|