principles-disciple 1.92.0 → 1.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/hooks/after-tool-call-helpers.ts +577 -0
- package/src/hooks/after-tool-call-types.ts +105 -0
- package/src/hooks/gate-block-helper.ts +72 -29
- package/src/hooks/llm.ts +49 -29
- package/src/hooks/pain.ts +176 -462
- package/src/hooks/trajectory-evidence.ts +75 -0
- package/src/hooks/triage-adapter.ts +156 -0
- package/tests/hooks/gate-block-helper-profile.test.ts +186 -0
- package/tests/hooks/pain.test.ts +288 -0
- package/tests/hooks/triage-adapter.test.ts +260 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trajectory Evidence Builder — PRI-326
|
|
3
|
+
*
|
|
4
|
+
* Extracted from pain.ts to avoid circular imports between
|
|
5
|
+
* pain.ts and after-tool-call-helpers.ts.
|
|
6
|
+
*
|
|
7
|
+
* Pure data extraction — reads from trajectory DB, sanitizes, returns evidence entries.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { sanitizeAssistantText } from './message-sanitize.js';
|
|
11
|
+
import type { PainEvidenceEntry } from '@principles/core/runtime-v2';
|
|
12
|
+
import { MAX_EVIDENCE_ENTRIES, MAX_EVIDENCE_NOTE_CHARS } from '@principles/core/runtime-v2';
|
|
13
|
+
import type { WorkspaceContext } from '../core/workspace-context.js';
|
|
14
|
+
|
|
15
|
+
export function buildTrajectoryEvidence(wctx: WorkspaceContext, sessionId: string): PainEvidenceEntry[] {
|
|
16
|
+
const evidence: PainEvidenceEntry[] = [];
|
|
17
|
+
|
|
18
|
+
if (!wctx.trajectory || sessionId === 'unknown') {
|
|
19
|
+
evidence.push({
|
|
20
|
+
sourceRef: 'owner_message:unavailable',
|
|
21
|
+
note: `trajectory_unavailable: ${!wctx.trajectory ? 'no_trajectory_db' : 'unknown_session'}`,
|
|
22
|
+
});
|
|
23
|
+
return evidence.slice(0, MAX_EVIDENCE_ENTRIES);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const userTurns = wctx.trajectory.listUserTurnsForSession(sessionId) ?? [];
|
|
28
|
+
const lastCorrectionTurn = [...userTurns].reverse().find(t => t.correctionDetected);
|
|
29
|
+
if (lastCorrectionTurn) {
|
|
30
|
+
const sanitizedOwnerMessage = sanitizeAssistantText(
|
|
31
|
+
(lastCorrectionTurn.rawExcerpt ?? '').slice(0, MAX_EVIDENCE_NOTE_CHARS)
|
|
32
|
+
);
|
|
33
|
+
evidence.push({
|
|
34
|
+
sourceRef: `owner_message:${lastCorrectionTurn.createdAt}`,
|
|
35
|
+
note: sanitizedOwnerMessage,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {
|
|
39
|
+
evidence.push({
|
|
40
|
+
sourceRef: 'owner_message:unavailable',
|
|
41
|
+
note: `trajectory_user_turns_unavailable: ${String(e).slice(0, 100)}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const assistantTurns = wctx.trajectory.listAssistantTurns(sessionId) ?? [];
|
|
47
|
+
const recentAssistant = assistantTurns.slice(-3);
|
|
48
|
+
for (const turn of recentAssistant) {
|
|
49
|
+
if (evidence.length >= MAX_EVIDENCE_ENTRIES) break;
|
|
50
|
+
const sanitizedNote = sanitizeAssistantText(
|
|
51
|
+
(turn.sanitizedText ?? '').slice(0, MAX_EVIDENCE_NOTE_CHARS)
|
|
52
|
+
);
|
|
53
|
+
evidence.push({
|
|
54
|
+
sourceRef: `agent_turn:${turn.createdAt}`,
|
|
55
|
+
note: sanitizedNote,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (evidence.length < MAX_EVIDENCE_ENTRIES) {
|
|
60
|
+
evidence.push({
|
|
61
|
+
sourceRef: 'agent_turn:unavailable',
|
|
62
|
+
note: `trajectory_assistant_turns_unavailable: ${String(e).slice(0, 100)}`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (evidence.length === 0) {
|
|
68
|
+
evidence.push({
|
|
69
|
+
sourceRef: 'trajectory:empty',
|
|
70
|
+
note: 'trajectory_available_but_empty: no user correction or assistant turns found',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return evidence.slice(0, MAX_EVIDENCE_ENTRIES);
|
|
75
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triage Adapter — PEAT-B1
|
|
3
|
+
*
|
|
4
|
+
* Plugin-side adapter that maps OpenClaw hook context to evidence triage input.
|
|
5
|
+
* Calls the pure triage policy from principles-core.
|
|
6
|
+
*
|
|
7
|
+
* This file lives in openclaw-plugin because it:
|
|
8
|
+
* - Maps hook-specific context (source strings, session state) to SourceKind
|
|
9
|
+
* - Wraps evaluatePainDiagnosticGate as a compatibility sub-policy
|
|
10
|
+
* - Knows about OpenClaw hook conventions (sessionId, toolName, etc.)
|
|
11
|
+
*
|
|
12
|
+
* It does NOT expose evaluatePainDiagnosticGate to core.
|
|
13
|
+
* Core only sees SourceKind and TriageResult.
|
|
14
|
+
*
|
|
15
|
+
* ERR checklist:
|
|
16
|
+
* - ERR-001: Source kind derived from runtime values with guards, not `as` casts.
|
|
17
|
+
* - ERR-002: Every triage result carries reason + nextAction.
|
|
18
|
+
* - ERR-024/025/048: Production-path tests cover this adapter.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
evaluateTriage,
|
|
23
|
+
type TriageInput,
|
|
24
|
+
type TriageResult,
|
|
25
|
+
type SourceKind,
|
|
26
|
+
} from '@principles/core/runtime-v2';
|
|
27
|
+
|
|
28
|
+
// ── Source Kind Resolution ───────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Map after_tool_call hook context to SourceKind.
|
|
32
|
+
*
|
|
33
|
+
* Classifies based on:
|
|
34
|
+
* - toolName: 'pain' or 'skill:pain' → agent_on_owner_request
|
|
35
|
+
* - failureSource: 'dispatch_error' vs 'tool_failure'
|
|
36
|
+
* - isRisky + score: only used for rulehost_block upgrade, not for kind resolution
|
|
37
|
+
*/
|
|
38
|
+
export function resolveSourceKindFromToolFailure(
|
|
39
|
+
toolName: string | undefined,
|
|
40
|
+
failureSource: 'tool_failure' | 'dispatch_error',
|
|
41
|
+
provenance?: 'openclaw_context_bound' | 'owner_reported_no_host_trace' | 'automatic_hook',
|
|
42
|
+
): SourceKind {
|
|
43
|
+
// Manual pain via agent tool call
|
|
44
|
+
if (toolName === 'pain' || toolName === 'skill:pain') {
|
|
45
|
+
return provenance === 'openclaw_context_bound' ? 'agent_on_owner_request' : 'owner_reported';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Dispatch errors (tool not found, unknown tool)
|
|
49
|
+
if (failureSource === 'dispatch_error') {
|
|
50
|
+
return 'dispatch_error';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Regular tool failure
|
|
54
|
+
return 'tool_failure';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Map empathy/semantic detection context to SourceKind.
|
|
59
|
+
*
|
|
60
|
+
* Classifies based on detection source prefix:
|
|
61
|
+
* - 'llm_paralysis' → llm_paralysis
|
|
62
|
+
* - 'llm_*' (detection rule) → semantic
|
|
63
|
+
* - 'user_empathy' or empathy keyword match → empathy_inferred
|
|
64
|
+
* - GFI threshold crossed → gfi_threshold
|
|
65
|
+
*/
|
|
66
|
+
export function resolveSourceKindFromLlmDetection(
|
|
67
|
+
detectionSource: string,
|
|
68
|
+
isGfiTriggered: boolean,
|
|
69
|
+
): SourceKind {
|
|
70
|
+
if (isGfiTriggered) return 'gfi_threshold';
|
|
71
|
+
if (detectionSource === 'llm_paralysis') return 'llm_paralysis';
|
|
72
|
+
if (detectionSource.startsWith('llm_')) return 'semantic';
|
|
73
|
+
if (detectionSource === 'user_empathy') return 'empathy_inferred';
|
|
74
|
+
return 'unknown';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Map gate-block context to SourceKind.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveSourceKindFromGateBlock(): SourceKind {
|
|
81
|
+
return 'rulehost_block';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Map /pd-pain command to SourceKind.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveSourceKindFromCommand(): SourceKind {
|
|
88
|
+
return 'owner_reported';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Map provider/rate-limit failure to SourceKind.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveSourceKindFromProvider(
|
|
95
|
+
isRateLimit: boolean,
|
|
96
|
+
): SourceKind {
|
|
97
|
+
return isRateLimit ? 'rate_limit' : 'provider_failure';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Map subagent error to SourceKind.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveSourceKindFromSubagent(): SourceKind {
|
|
104
|
+
return 'subagent_error';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Triage Evaluation ───────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Evaluate evidence triage for a given source kind and context.
|
|
111
|
+
*
|
|
112
|
+
* This is the main entry point for hooks. It calls the pure triage policy
|
|
113
|
+
* from principles-core and returns the result.
|
|
114
|
+
*
|
|
115
|
+
* The caller (hook) is responsible for:
|
|
116
|
+
* - Checking the painEvidenceAdmission feature flag
|
|
117
|
+
* - Acting on the triage result (proceed to diagnosis, store evidence, etc.)
|
|
118
|
+
* - Falling back to existing behavior when the flag is off
|
|
119
|
+
*/
|
|
120
|
+
export function evaluateEvidenceTriage(
|
|
121
|
+
sourceKind: SourceKind,
|
|
122
|
+
score: number,
|
|
123
|
+
options?: {
|
|
124
|
+
isUnsafeHighConfidence?: boolean;
|
|
125
|
+
provenance?: 'openclaw_context_bound' | 'owner_reported_no_host_trace' | 'automatic_hook';
|
|
126
|
+
},
|
|
127
|
+
): TriageResult {
|
|
128
|
+
const input: TriageInput = {
|
|
129
|
+
sourceKind,
|
|
130
|
+
score,
|
|
131
|
+
isUnsafeHighConfidence: options?.isUnsafeHighConfidence,
|
|
132
|
+
provenance: options?.provenance,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return evaluateTriage(input);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── High-Confidence Unsafe Action Detection ──────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Determine if a gate-blocked action is a high-confidence unsafe action.
|
|
142
|
+
*
|
|
143
|
+
* This is a heuristic that the plugin adapter owns. Core does not know about
|
|
144
|
+
* these heuristics — it only receives the boolean flag.
|
|
145
|
+
*
|
|
146
|
+
* Criteria for high-confidence unsafe:
|
|
147
|
+
* - Score >= 70 (high severity)
|
|
148
|
+
* - Tool is in the risky write set
|
|
149
|
+
* - Action would be irreversible (file deletion, force push, etc.)
|
|
150
|
+
*/
|
|
151
|
+
export function isHighConfidenceUnsafeAction(
|
|
152
|
+
score: number,
|
|
153
|
+
isRisky: boolean,
|
|
154
|
+
): boolean {
|
|
155
|
+
return isRisky && score >= 70;
|
|
156
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate Block Helper — PROFILE loading resilience tests
|
|
3
|
+
*
|
|
4
|
+
* Verifies that recordGateBlockAndReturn handles malformed/oversized PROFILE
|
|
5
|
+
* gracefully: try/catch, 1MB size guard, fallback to non-risky, no crash.
|
|
6
|
+
*
|
|
7
|
+
* ERR checklist:
|
|
8
|
+
* - ERR-026: All PROFILE loads have try/catch (gate-block-helper.ts matches pain.ts)
|
|
9
|
+
* - ERR-024/025: Production-path tests for the edge case
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
13
|
+
import * as fs from 'fs';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import * as os from 'os';
|
|
16
|
+
import { WorkspaceContext } from '../../src/core/workspace-context.js';
|
|
17
|
+
import { EventLogService } from '../../src/core/event-log.js';
|
|
18
|
+
import { clearSession } from '../../src/core/session-tracker.js';
|
|
19
|
+
import { resetPainDiagnosticGateForTest } from '../../src/core/pain-diagnostic-gate.js';
|
|
20
|
+
|
|
21
|
+
vi.mock('fs');
|
|
22
|
+
vi.mock('../../src/utils/io.js', () => ({
|
|
23
|
+
isRisky: vi.fn(() => false),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('../../src/core/evolution-engine.js', () => ({
|
|
26
|
+
recordEvolutionSuccess: vi.fn(),
|
|
27
|
+
recordEvolutionFailure: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
vi.mock('../../src/core/evolution-logger.js', () => ({
|
|
30
|
+
createTraceId: vi.fn(() => 'trace-123'),
|
|
31
|
+
getEvolutionLogger: vi.fn(() => ({
|
|
32
|
+
logPainDetected: vi.fn(),
|
|
33
|
+
})),
|
|
34
|
+
}));
|
|
35
|
+
vi.mock('../../src/core/pd-config-loader.js', () => ({
|
|
36
|
+
loadPdConfigForPlugin: vi.fn(() => ({ ok: true, source: 'mock', effective: {}, errors: [] })),
|
|
37
|
+
loadFeatureFlagFromConfig: vi.fn(() => ({ enabled: true, source: 'test' })),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const mockEmitSync = vi.fn();
|
|
41
|
+
const mockRecordProbationFeedback = vi.fn();
|
|
42
|
+
const mockUpdatePrincipleValueMetrics = vi.fn();
|
|
43
|
+
|
|
44
|
+
function makeTestWctx(overrides: Record<string, unknown> = {}) {
|
|
45
|
+
return {
|
|
46
|
+
workspaceDir: '/mock/workspace',
|
|
47
|
+
stateDir: '/mock/state',
|
|
48
|
+
config: { get: vi.fn().mockReturnValue(40) },
|
|
49
|
+
eventLog: {
|
|
50
|
+
recordGateBlock: vi.fn(),
|
|
51
|
+
recordPainSignal: vi.fn(),
|
|
52
|
+
},
|
|
53
|
+
trajectory: {
|
|
54
|
+
recordGateBlock: vi.fn(),
|
|
55
|
+
recordPainEvent: vi.fn(),
|
|
56
|
+
recordToolCall: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
principleTreeLedger: {
|
|
59
|
+
updatePrincipleValueMetrics: mockUpdatePrincipleValueMetrics,
|
|
60
|
+
},
|
|
61
|
+
evolutionReducer: {
|
|
62
|
+
emitSync: mockEmitSync,
|
|
63
|
+
recordProbationFeedback: mockRecordProbationFeedback,
|
|
64
|
+
getPrincipleById: vi.fn(),
|
|
65
|
+
},
|
|
66
|
+
resolve: vi.fn().mockImplementation((key: string) => {
|
|
67
|
+
if (key === 'PROFILE') return '/mock/workspace/PROFILE.json';
|
|
68
|
+
return '';
|
|
69
|
+
}),
|
|
70
|
+
...overrides,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('Gate Block Helper — PROFILE Resilience', () => {
|
|
75
|
+
const sessionId = 's-profile-test';
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
vi.clearAllMocks();
|
|
79
|
+
mockEmitSync.mockReset();
|
|
80
|
+
mockRecordProbationFeedback.mockReset();
|
|
81
|
+
mockUpdatePrincipleValueMetrics.mockReset();
|
|
82
|
+
vi.spyOn(WorkspaceContext, 'fromHookContext').mockReturnValue(makeTestWctx() as any);
|
|
83
|
+
vi.spyOn(EventLogService, 'get').mockReturnValue({} as any);
|
|
84
|
+
clearSession(sessionId);
|
|
85
|
+
resetPainDiagnosticGateForTest();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
vi.restoreAllMocks();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('malformed PROFILE.json does not throw, returns block result with non-risky fallback', async () => {
|
|
93
|
+
// Arrange: PROFILE exists but contains invalid JSON
|
|
94
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
95
|
+
vi.mocked(fs.readFileSync).mockReturnValue('{ invalid json }');
|
|
96
|
+
|
|
97
|
+
// Dynamic import AFTER mocks are set up
|
|
98
|
+
const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
|
|
99
|
+
|
|
100
|
+
// Act & Assert: does NOT throw
|
|
101
|
+
const result = recordGateBlockAndReturn(
|
|
102
|
+
makeTestWctx() as any,
|
|
103
|
+
{
|
|
104
|
+
filePath: 'src/danger.ts',
|
|
105
|
+
reason: 'Test block reason',
|
|
106
|
+
toolName: 'write',
|
|
107
|
+
sessionId,
|
|
108
|
+
},
|
|
109
|
+
{ warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(result).toBeDefined();
|
|
113
|
+
expect(result.block).toBe(true);
|
|
114
|
+
expect(result.blockReason).toContain('Security Gate Blocked');
|
|
115
|
+
// verify emitPainDetectedEvent was NOT called (triage fell back to non-risky)
|
|
116
|
+
expect(mockEmitSync).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('oversized PROFILE (>1MB) falls back to non-risky without crash', async () => {
|
|
120
|
+
// Arrange: PROFILE > 1MB
|
|
121
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
122
|
+
vi.mocked(fs.readFileSync).mockReturnValue('x'.repeat(1024 * 1024 + 1));
|
|
123
|
+
|
|
124
|
+
const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
|
|
125
|
+
|
|
126
|
+
const result = recordGateBlockAndReturn(
|
|
127
|
+
makeTestWctx() as any,
|
|
128
|
+
{
|
|
129
|
+
filePath: 'src/danger.ts',
|
|
130
|
+
reason: 'Test block reason',
|
|
131
|
+
toolName: 'write',
|
|
132
|
+
sessionId,
|
|
133
|
+
},
|
|
134
|
+
{ warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(result).toBeDefined();
|
|
138
|
+
expect(result.block).toBe(true);
|
|
139
|
+
expect(mockEmitSync).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('missing PROFILE.json defaults to non-risky without error', async () => {
|
|
143
|
+
// Arrange: PROFILE does not exist
|
|
144
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
145
|
+
|
|
146
|
+
const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
|
|
147
|
+
|
|
148
|
+
const result = recordGateBlockAndReturn(
|
|
149
|
+
makeTestWctx() as any,
|
|
150
|
+
{
|
|
151
|
+
filePath: 'src/danger.ts',
|
|
152
|
+
reason: 'Test block',
|
|
153
|
+
toolName: 'edit',
|
|
154
|
+
sessionId,
|
|
155
|
+
},
|
|
156
|
+
{ warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(result).toBeDefined();
|
|
160
|
+
expect(result.block).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('fs.readFileSync permission error falls back gracefully', async () => {
|
|
164
|
+
// Arrange: existsSync returns true but readFileSync throws
|
|
165
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
166
|
+
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
|
167
|
+
throw new Error('EACCES: permission denied');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const { recordGateBlockAndReturn } = await import('../../src/hooks/gate-block-helper.js');
|
|
171
|
+
|
|
172
|
+
const result = recordGateBlockAndReturn(
|
|
173
|
+
makeTestWctx() as any,
|
|
174
|
+
{
|
|
175
|
+
filePath: 'src/danger.ts',
|
|
176
|
+
reason: 'Test block',
|
|
177
|
+
toolName: 'write',
|
|
178
|
+
sessionId,
|
|
179
|
+
},
|
|
180
|
+
{ warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
expect(result).toBeDefined();
|
|
184
|
+
expect(result.block).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
});
|