@raidiant/notifai 10.1.0 → 10.1.2
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 +37 -0
- package/README.md +3 -0
- package/dist/cli-bin.d.ts +21 -1
- package/dist/cli-bin.js +141 -10
- package/dist/cli-bin.js.map +1 -1
- package/dist/cli-update-contract.d.ts +7 -0
- package/dist/cli-update-contract.js +10 -0
- package/dist/cli-update-contract.js.map +1 -0
- package/dist/client.d.ts +6 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/codex-wake.d.ts +2 -0
- package/dist/codex-wake.js +2 -1
- package/dist/codex-wake.js.map +1 -1
- package/dist/commands-auth.d.ts +15 -1
- package/dist/commands-auth.js +29 -11
- package/dist/commands-auth.js.map +1 -1
- package/dist/commands-core.d.ts +9 -2
- package/dist/commands-core.js +11 -4
- package/dist/commands-core.js.map +1 -1
- package/dist/commands-devices.d.ts +10 -1
- package/dist/commands-devices.js +17 -13
- package/dist/commands-devices.js.map +1 -1
- package/dist/commands-doctor.js +93 -49
- package/dist/commands-doctor.js.map +1 -1
- package/dist/commands-hooks.js +47 -10
- package/dist/commands-hooks.js.map +1 -1
- package/dist/commands-init.d.ts +16 -0
- package/dist/commands-init.js +254 -93
- package/dist/commands-init.js.map +1 -1
- package/dist/commands-send-support.js +3 -0
- package/dist/commands-send-support.js.map +1 -1
- package/dist/commands-send.d.ts +1 -1
- package/dist/commands-send.js +121 -35
- package/dist/commands-send.js.map +1 -1
- package/dist/commands-skill.js +72 -54
- package/dist/commands-skill.js.map +1 -1
- package/dist/commands-update.d.ts +10 -0
- package/dist/commands-update.js +141 -0
- package/dist/commands-update.js.map +1 -0
- package/dist/commands.d.ts +2 -1
- package/dist/commands.js +2 -1
- package/dist/commands.js.map +1 -1
- package/dist/hook-adapter.d.ts +2 -0
- package/dist/hook-adapter.js +12 -1
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +43 -2
- package/dist/hooks.js +163 -21
- package/dist/hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +1 -0
- package/dist/invocation-context.js +9 -1
- package/dist/invocation-context.js.map +1 -1
- package/dist/main-run.d.ts +1 -0
- package/dist/main-run.js +42 -0
- package/dist/main-run.js.map +1 -0
- package/dist/main.js +10 -31
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +13 -3
- package/dist/native-skills.js +51 -12
- package/dist/native-skills.js.map +1 -1
- package/dist/node-floor.d.ts +19 -0
- package/dist/node-floor.js +39 -0
- package/dist/node-floor.js.map +1 -0
- package/dist/orca-session-title.d.ts +5 -4
- package/dist/orca-session-title.js +32 -19
- package/dist/orca-session-title.js.map +1 -1
- package/dist/platform.d.ts +11 -17
- package/dist/platform.js +20 -23
- package/dist/platform.js.map +1 -1
- package/dist/program.d.ts +2 -1
- package/dist/program.js +22 -5
- package/dist/program.js.map +1 -1
- package/dist/question-settlement-process.d.ts +15 -0
- package/dist/question-settlement-process.js +35 -0
- package/dist/question-settlement-process.js.map +1 -0
- package/dist/readiness.d.ts +25 -1
- package/dist/readiness.js +32 -2
- package/dist/readiness.js.map +1 -1
- package/dist/release.d.ts +5 -5
- package/dist/release.js +5 -5
- package/dist/setup-destinations.d.ts +23 -0
- package/dist/setup-destinations.js +44 -0
- package/dist/setup-destinations.js.map +1 -0
- package/dist/skill-integrity.d.ts +49 -0
- package/dist/skill-integrity.js +154 -0
- package/dist/skill-integrity.js.map +1 -0
- package/dist/skill-source/manifest.json +16 -0
- package/dist/skill-source/notifai/SKILL.md +390 -0
- package/dist/skill-source/notifai/references/harness-setup.md +220 -0
- package/package.json +11 -14
- package/src/atomic-file.ts +0 -198
- package/src/claude-wake.test.ts +0 -321
- package/src/claude-wake.ts +0 -499
- package/src/cli-bin.test.ts +0 -38
- package/src/cli-bin.ts +0 -67
- package/src/cli-release.test.ts +0 -46
- package/src/cli-release.ts +0 -78
- package/src/client.test.ts +0 -390
- package/src/client.ts +0 -345
- package/src/codex-wake.test.ts +0 -254
- package/src/codex-wake.ts +0 -297
- package/src/command-session.ts +0 -38
- package/src/commands-agent-sessions.ts +0 -72
- package/src/commands-auth.ts +0 -205
- package/src/commands-config.ts +0 -336
- package/src/commands-core.ts +0 -298
- package/src/commands-devices.test.ts +0 -15
- package/src/commands-devices.ts +0 -173
- package/src/commands-doctor.ts +0 -1440
- package/src/commands-guidance.ts +0 -181
- package/src/commands-harness-context.ts +0 -207
- package/src/commands-hook-shape.ts +0 -69
- package/src/commands-hooks.ts +0 -2268
- package/src/commands-init.ts +0 -878
- package/src/commands-io.ts +0 -108
- package/src/commands-logs.ts +0 -243
- package/src/commands-project.ts +0 -47
- package/src/commands-send-support.test.ts +0 -156
- package/src/commands-send-support.ts +0 -311
- package/src/commands-send.ts +0 -769
- package/src/commands-setup-proof.test.ts +0 -72
- package/src/commands-setup-proof.ts +0 -124
- package/src/commands-skill.test.ts +0 -44
- package/src/commands-skill.ts +0 -204
- package/src/commands-sounds.test.ts +0 -106
- package/src/commands-sounds.ts +0 -49
- package/src/commands.test.ts +0 -7828
- package/src/commands.ts +0 -75
- package/src/config-schema.test.ts +0 -153
- package/src/config-schema.ts +0 -349
- package/src/config.test.ts +0 -581
- package/src/config.ts +0 -475
- package/src/credentials.test.ts +0 -403
- package/src/credentials.ts +0 -403
- package/src/file-lock-process-worker.test.ts +0 -64
- package/src/file-lock.test.ts +0 -320
- package/src/file-lock.ts +0 -393
- package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
- package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
- package/src/generated-session-label.ts +0 -155
- package/src/guidance-content.ts +0 -278
- package/src/guidance-render.ts +0 -56
- package/src/guidance.test.ts +0 -327
- package/src/guidance.ts +0 -233
- package/src/harness-integration.test.ts +0 -294
- package/src/harnesses.test.ts +0 -149
- package/src/harnesses.ts +0 -164
- package/src/hook-adapter.test.ts +0 -496
- package/src/hook-adapter.ts +0 -410
- package/src/hook-input.test.ts +0 -39
- package/src/hook-input.ts +0 -72
- package/src/hooks.test.ts +0 -5549
- package/src/hooks.ts +0 -3759
- package/src/install-hooks.test.ts +0 -1614
- package/src/install-hooks.ts +0 -1501
- package/src/interactive.test.ts +0 -387
- package/src/interactive.ts +0 -858
- package/src/invocation-context.test.ts +0 -352
- package/src/invocation-context.ts +0 -197
- package/src/logging-process-worker.test.ts +0 -45
- package/src/logging.test.ts +0 -544
- package/src/logging.ts +0 -698
- package/src/main.ts +0 -36
- package/src/native-skills.test.ts +0 -115
- package/src/native-skills.ts +0 -162
- package/src/onboarding-docs.test.ts +0 -25
- package/src/openclaw-plugin.ts +0 -409
- package/src/opencode-plugin.ts +0 -298
- package/src/orca-session-title.test.ts +0 -54
- package/src/orca-session-title.ts +0 -103
- package/src/pack-feedback-slice.test.ts +0 -212
- package/src/pack-feedback-slice.ts +0 -187
- package/src/platform.test.ts +0 -152
- package/src/platform.ts +0 -173
- package/src/program.test.ts +0 -282
- package/src/program.ts +0 -818
- package/src/project-enablement.test.ts +0 -55
- package/src/project-enablement.ts +0 -99
- package/src/question-caps.test.ts +0 -49
- package/src/question-timing.ts +0 -56
- package/src/readiness.test.ts +0 -25
- package/src/readiness.ts +0 -179
- package/src/release.ts +0 -51
- package/src/send-attempts.test.ts +0 -143
- package/src/send-attempts.ts +0 -188
- package/src/send.test.ts +0 -28
- package/src/send.ts +0 -436
- package/src/session-activation.ts +0 -61
- package/src/session-end-log-lock-worker.test.ts +0 -35
- package/src/session-labels.test.ts +0 -490
- package/src/session-labels.ts +0 -474
- package/src/skill.test.ts +0 -415
- package/src/sound-ref.test.ts +0 -31
- package/src/sound-ref.ts +0 -34
- package/src/trust-policy.test.ts +0 -701
- package/src/ui/banner.ts +0 -95
- package/src/ui/config-view.test.ts +0 -90
- package/src/ui/config-view.ts +0 -138
- package/src/ui/help.test.ts +0 -16
- package/src/ui/help.ts +0 -128
- package/src/ui/theme.test.ts +0 -92
- package/src/ui/theme.ts +0 -265
- package/src/url-policy.ts +0 -399
- package/tsconfig.json +0 -10
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process'
|
|
2
|
-
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import os from 'node:os'
|
|
4
|
-
import path from 'node:path'
|
|
5
|
-
import { describe, expect, it } from 'vitest'
|
|
6
|
-
import {
|
|
7
|
-
disableProject,
|
|
8
|
-
enableProject,
|
|
9
|
-
projectBinding,
|
|
10
|
-
projectEnabled,
|
|
11
|
-
} from './project-enablement.js'
|
|
12
|
-
|
|
13
|
-
function git(cwd: string, ...args: string[]): void {
|
|
14
|
-
execFileSync('git', ['-C', cwd, ...args], { stdio: 'ignore' })
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
describe('Project enablement', () => {
|
|
18
|
-
it('is disabled by default and changes only through its User-owned marker', () => {
|
|
19
|
-
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-enablement-'))
|
|
20
|
-
const env = { XDG_CONFIG_HOME: path.join(root, 'config') }
|
|
21
|
-
const binding = projectBinding(root, env)
|
|
22
|
-
expect(binding).not.toBeNull()
|
|
23
|
-
expect(projectEnabled(binding)).toBe(false)
|
|
24
|
-
|
|
25
|
-
enableProject(binding!, new Date('2026-08-27T00:00:00.000Z'))
|
|
26
|
-
expect(projectEnabled(binding)).toBe(true)
|
|
27
|
-
disableProject(binding!)
|
|
28
|
-
expect(projectEnabled(binding)).toBe(false)
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('shares one decision across linked worktrees but not unrelated checkouts with the same name', () => {
|
|
32
|
-
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-enablement-git-'))
|
|
33
|
-
const repo = path.join(root, 'same-name')
|
|
34
|
-
const linked = path.join(root, 'linked')
|
|
35
|
-
const unrelatedParent = path.join(root, 'other')
|
|
36
|
-
const unrelated = path.join(unrelatedParent, 'same-name')
|
|
37
|
-
mkdirSync(repo, { recursive: true })
|
|
38
|
-
mkdirSync(unrelated, { recursive: true })
|
|
39
|
-
for (const cwd of [repo, unrelated]) {
|
|
40
|
-
git(cwd, 'init')
|
|
41
|
-
git(cwd, 'config', 'user.email', 'test@example.invalid')
|
|
42
|
-
git(cwd, 'config', 'user.name', 'Test')
|
|
43
|
-
writeFileSync(path.join(cwd, 'file'), 'x')
|
|
44
|
-
git(cwd, 'add', 'file')
|
|
45
|
-
git(cwd, 'commit', '-m', 'fixture')
|
|
46
|
-
}
|
|
47
|
-
git(repo, 'worktree', 'add', linked, '-b', 'linked')
|
|
48
|
-
const env = { XDG_CONFIG_HOME: path.join(root, 'config') }
|
|
49
|
-
const mainBinding = projectBinding(repo, env)
|
|
50
|
-
const linkedBinding = projectBinding(linked, env)
|
|
51
|
-
const unrelatedBinding = projectBinding(unrelated, env)
|
|
52
|
-
expect(linkedBinding?.markerPath).toBe(mainBinding?.markerPath)
|
|
53
|
-
expect(unrelatedBinding?.markerPath).not.toBe(mainBinding?.markerPath)
|
|
54
|
-
})
|
|
55
|
-
})
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process'
|
|
2
|
-
import { createHash } from 'node:crypto'
|
|
3
|
-
import { existsSync, readFileSync, realpathSync, unlinkSync } from 'node:fs'
|
|
4
|
-
import path from 'node:path'
|
|
5
|
-
import { atomicWriteFileSync } from './atomic-file.js'
|
|
6
|
-
import { globalConfigDir } from './config.js'
|
|
7
|
-
import { inferInvocationContext } from './invocation-context.js'
|
|
8
|
-
|
|
9
|
-
interface ProjectEnablementMarker {
|
|
10
|
-
version: 1
|
|
11
|
-
project: string
|
|
12
|
-
enabled: true
|
|
13
|
-
updated_at: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface ProjectBinding {
|
|
17
|
-
project: string
|
|
18
|
-
markerPath: string
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function gitCommonDirectory(cwd: string): string | null {
|
|
22
|
-
try {
|
|
23
|
-
const raw = execFileSync('git', ['-C', cwd, 'rev-parse', '--git-common-dir'], {
|
|
24
|
-
encoding: 'utf8',
|
|
25
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
26
|
-
timeout: 2_000,
|
|
27
|
-
windowsHide: true,
|
|
28
|
-
}).trim()
|
|
29
|
-
if (raw === '') return null
|
|
30
|
-
const resolved = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw)
|
|
31
|
-
try {
|
|
32
|
-
return realpathSync(resolved)
|
|
33
|
-
} catch {
|
|
34
|
-
return path.resolve(resolved)
|
|
35
|
-
}
|
|
36
|
-
} catch {
|
|
37
|
-
return null
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Resolve the User-owned enablement record for this exact local Project.
|
|
43
|
-
*
|
|
44
|
-
* The opaque key binds the semantic Project id to Git's shared common
|
|
45
|
-
* directory. Linked worktrees therefore share one decision, while another
|
|
46
|
-
* checkout cannot borrow it merely by committing the same Project id.
|
|
47
|
-
*/
|
|
48
|
-
export function projectBinding(
|
|
49
|
-
cwd: string,
|
|
50
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
51
|
-
explicitProject?: string | null,
|
|
52
|
-
): ProjectBinding | null {
|
|
53
|
-
const project = explicitProject ?? inferInvocationContext(cwd).project
|
|
54
|
-
if (project === null) return null
|
|
55
|
-
const repository = gitCommonDirectory(cwd) ?? path.resolve(cwd)
|
|
56
|
-
const key = createHash('sha256')
|
|
57
|
-
.update('notifai-project-enablement-v1\0')
|
|
58
|
-
.update(repository)
|
|
59
|
-
.update('\0')
|
|
60
|
-
.update(project)
|
|
61
|
-
.digest('hex')
|
|
62
|
-
return {
|
|
63
|
-
project,
|
|
64
|
-
markerPath: path.join(globalConfigDir(env), 'project-enablement', `${key}.json`),
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function projectEnabled(binding: ProjectBinding | null): boolean {
|
|
69
|
-
if (binding === null || !existsSync(binding.markerPath)) return false
|
|
70
|
-
try {
|
|
71
|
-
const marker = JSON.parse(readFileSync(binding.markerPath, 'utf8')) as Partial<ProjectEnablementMarker>
|
|
72
|
-
return marker.version === 1 && marker.enabled === true && marker.project === binding.project
|
|
73
|
-
} catch {
|
|
74
|
-
return false
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function enableProject(binding: ProjectBinding, now: Date = new Date()): void {
|
|
79
|
-
const marker: ProjectEnablementMarker = {
|
|
80
|
-
version: 1,
|
|
81
|
-
project: binding.project,
|
|
82
|
-
enabled: true,
|
|
83
|
-
updated_at: now.toISOString(),
|
|
84
|
-
}
|
|
85
|
-
atomicWriteFileSync(binding.markerPath, `${JSON.stringify(marker, null, 2)}\n`, {
|
|
86
|
-
mode: 0o600,
|
|
87
|
-
preserveMode: false,
|
|
88
|
-
requireCurrentUserOwner: true,
|
|
89
|
-
directoryMode: 0o700,
|
|
90
|
-
})
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function disableProject(binding: ProjectBinding): void {
|
|
94
|
-
try {
|
|
95
|
-
unlinkSync(binding.markerPath)
|
|
96
|
-
} catch (err) {
|
|
97
|
-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
98
|
-
}
|
|
99
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync } from 'node:fs'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { describe, expect, it } from 'vitest'
|
|
5
|
-
import { MAX_LIVE_QUESTIONS, MAX_PENDING_QUESTIONS, registerQuestion } from './hooks.js'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* `registerQuestion` decides how many questions one session may hold, and these
|
|
9
|
-
* exercise it directly against a bare state directory — no harness, no server,
|
|
10
|
-
* no clock. They live apart from `hooks.test.ts` because nothing here needs
|
|
11
|
-
* what that file spends its setup on.
|
|
12
|
-
*/
|
|
13
|
-
const state = (): NodeJS.ProcessEnv => {
|
|
14
|
-
const dir = mkdtempSync(path.join(os.tmpdir(), 'notifai-caps-'))
|
|
15
|
-
return { HOME: dir, XDG_STATE_HOME: path.join(dir, 'state') } as NodeJS.ProcessEnv
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
describe('how many questions one session may hold', () => {
|
|
19
|
-
it('stops an agent registering a fifth question nobody has asked yet', () => {
|
|
20
|
-
const env = state()
|
|
21
|
-
for (let i = 0; i < MAX_PENDING_QUESTIONS; i += 1) {
|
|
22
|
-
registerQuestion('loop', env, { question: `Q${i}?` })
|
|
23
|
-
}
|
|
24
|
-
expect(() => registerQuestion('loop', env, { question: 'One more?' })).toThrow(
|
|
25
|
-
/already waiting to be asked/,
|
|
26
|
-
)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('does not count a question the user simply has not answered yet', () => {
|
|
30
|
-
// An answer stays accepted for a day. Counting questions already on the
|
|
31
|
-
// user's devices against the loop guard would let a patient user stop the
|
|
32
|
-
// agent from asking anything at all.
|
|
33
|
-
const env = state()
|
|
34
|
-
for (let i = 0; i < MAX_PENDING_QUESTIONS; i += 1) {
|
|
35
|
-
registerQuestion('live', env, { question: `Asked ${i}?`, request_id: `req_${i}` })
|
|
36
|
-
}
|
|
37
|
-
expect(() => registerQuestion('live', env, { question: 'A new one?' })).not.toThrow()
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('still bounds what one session can put on the lock screen', () => {
|
|
41
|
-
const env = state()
|
|
42
|
-
for (let i = 0; i < MAX_LIVE_QUESTIONS; i += 1) {
|
|
43
|
-
registerQuestion('flood', env, { question: `Asked ${i}?`, request_id: `req_${i}` })
|
|
44
|
-
}
|
|
45
|
-
expect(() => registerQuestion('flood', env, { question: 'One too many?' })).toThrow(
|
|
46
|
-
/already open/,
|
|
47
|
-
)
|
|
48
|
-
})
|
|
49
|
-
})
|
package/src/question-timing.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { REPLY_MAX_WINDOW_SECONDS } from '@raidiant/notifai-protocol'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Time before reply-window admission that the Stop owner may spend reading its
|
|
5
|
-
* input, serving terminal-first grace, resolving devices, and submitting.
|
|
6
|
-
*
|
|
7
|
-
* Grace is capped at six minutes and every network operation is independently
|
|
8
|
-
* bounded. Twenty minutes lets setup spend up to ten while preserving the
|
|
9
|
-
* separate completion margin below; neither may borrow from the answer window
|
|
10
|
-
* the service actually commits.
|
|
11
|
-
*/
|
|
12
|
-
export const QUESTION_WAITER_STARTUP_HEADROOM_SECONDS = 20 * 60
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Minimum lifetime still available when a question is admitted.
|
|
16
|
-
*
|
|
17
|
-
* The server window starts during submission, not when the hook process
|
|
18
|
-
* started. Keeping ten minutes beyond the requested window covers the bounded
|
|
19
|
-
* submit, clock skew, close fence, and route handoff. If setup used more of the
|
|
20
|
-
* allowance, the frozen question remains local for a successor owner instead
|
|
21
|
-
* of publishing a window this process cannot observe completely.
|
|
22
|
-
*/
|
|
23
|
-
export const QUESTION_SUBMISSION_COMPLETION_HEADROOM_SECONDS = 10 * 60
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* The longest one Stop owner may live from process start.
|
|
27
|
-
*
|
|
28
|
-
* The service can accept an answer for up to 72 hours. Starting this budget
|
|
29
|
-
* before submission is deliberate: the startup allowance above ensures that
|
|
30
|
-
* even the maximum reply window still ends before the owner does.
|
|
31
|
-
*/
|
|
32
|
-
export const QUESTION_WAITER_CEILING_SECONDS =
|
|
33
|
-
REPLY_MAX_WINDOW_SECONDS + QUESTION_WAITER_STARTUP_HEADROOM_SECONDS
|
|
34
|
-
|
|
35
|
-
/** Time for final fencing, diagnostics, stdout handoff, and clean process exit. */
|
|
36
|
-
export const QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS = 60
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Declared harness timeout for every Stop handler that owns Question Routing.
|
|
40
|
-
* It must outlive the runtime owner; otherwise the harness silently kills the
|
|
41
|
-
* only process capable of continuing the exact Agent Session.
|
|
42
|
-
*/
|
|
43
|
-
export const QUESTION_STOP_TIMEOUT_SECONDS =
|
|
44
|
-
QUESTION_WAITER_CEILING_SECONDS + QUESTION_STOP_TEARDOWN_HEADROOM_SECONDS
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Short Stop budget for harnesses that cannot own asynchronous Question
|
|
48
|
-
* Routing. They retire/refuse promptly and never wait through an answer window.
|
|
49
|
-
*/
|
|
50
|
-
export const NON_ROUTING_STOP_TIMEOUT_SECONDS = 540
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Pre-PID claim files came only from the former eight-minute owner. Retain that
|
|
54
|
-
* recovery bound instead of making a corrupt legacy file block for 72 hours.
|
|
55
|
-
*/
|
|
56
|
-
export const LEGACY_QUESTION_CLAIM_TTL_SECONDS = 480
|
package/src/readiness.test.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { refreshAfterMenuAction } from './readiness.js'
|
|
3
|
-
|
|
4
|
-
describe('refreshAfterMenuAction', () => {
|
|
5
|
-
it('does not re-assess after doctor, a test send, or a device list', () => {
|
|
6
|
-
expect(refreshAfterMenuAction('doctor', true)).toBeNull()
|
|
7
|
-
expect(refreshAfterMenuAction('test', true)).toBeNull()
|
|
8
|
-
expect(refreshAfterMenuAction('devices', true)).toBeNull()
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
it('does not re-assess when the action changed nothing', () => {
|
|
12
|
-
expect(refreshAfterMenuAction('account', false)).toBeNull()
|
|
13
|
-
expect(refreshAfterMenuAction('settings', false)).toBeNull()
|
|
14
|
-
expect(refreshAfterMenuAction('routing', false)).toBeNull()
|
|
15
|
-
expect(refreshAfterMenuAction('setup', false)).toBeNull()
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it('re-probes the service only after an action that can change it', () => {
|
|
19
|
-
expect(refreshAfterMenuAction('setup', true)).toEqual(['local', 'remote'])
|
|
20
|
-
expect(refreshAfterMenuAction('account', true)).toEqual(['local', 'remote'])
|
|
21
|
-
expect(refreshAfterMenuAction('settings', true)).toEqual(['local'])
|
|
22
|
-
expect(refreshAfterMenuAction('routing', true)).toEqual(['local'])
|
|
23
|
-
expect(refreshAfterMenuAction('settings', true, { remote: true })).toEqual(['local', 'remote'])
|
|
24
|
-
})
|
|
25
|
-
})
|
package/src/readiness.ts
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What "set up" means, in one place.
|
|
3
|
-
*
|
|
4
|
-
* `init` and `doctor` used to each carry their own idea of this — a fixed
|
|
5
|
-
* five-step script in one, a flat list of checks in the other — which is two
|
|
6
|
-
* copies of the same knowledge that can disagree, and did: doctor called
|
|
7
|
-
* missing hooks fine while init offered to install them, and only doctor knew
|
|
8
|
-
* what a revoked credential looked like.
|
|
9
|
-
*
|
|
10
|
-
* So the model lives here and both commands render it. `doctor` shows every
|
|
11
|
-
* state at once, because a report is what it is for. `init` walks the same
|
|
12
|
-
* list in order and stops at the first thing standing in the way, because
|
|
13
|
-
* someone handed five tasks does none of them. An agent gets the same states
|
|
14
|
-
* through a third renderer that never prompts.
|
|
15
|
-
*
|
|
16
|
-
* The ordering is a dependency chain, not a preference: there is no point
|
|
17
|
-
* asking the server about devices before there is a credential to ask with,
|
|
18
|
-
* and no point reporting a stale contract to someone who cannot reach the
|
|
19
|
-
* server at all. Assessment stops descending when a prerequisite is missing
|
|
20
|
-
* and marks what follows `unknown` rather than guessing.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
/** Whether the gap can be closed from here, and by whom. */
|
|
24
|
-
export type Remedy =
|
|
25
|
-
/**
|
|
26
|
-
* The CLI can do it unattended — no prompt, no human. `command` is how a
|
|
27
|
-
* reader would ask for it later, for the case where it was declined or
|
|
28
|
-
* skipped; it is never needed to perform the action.
|
|
29
|
-
*/
|
|
30
|
-
| { by: 'cli'; summary: string; command?: string }
|
|
31
|
-
/**
|
|
32
|
-
* A human at this terminal. `interactive` marks the ones the CLI can drive
|
|
33
|
-
* on their behalf when someone is actually watching — a browser sign-in is
|
|
34
|
-
* the CLI's to launch but the human's to complete, so an agent must still
|
|
35
|
-
* treat it as out of reach.
|
|
36
|
-
*/
|
|
37
|
-
| {
|
|
38
|
-
by: 'user-here'
|
|
39
|
-
summary: string
|
|
40
|
-
command: string
|
|
41
|
-
interactive?: boolean
|
|
42
|
-
/** Stable agent-facing description of an unavoidable harness-owned action. */
|
|
43
|
-
user_action?: { code: string; harness: string; action: string; message: string }
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* A human somewhere else entirely — phone in hand, app store, permission
|
|
47
|
-
* dialog. The class the old init could only print a sentence about, and the
|
|
48
|
-
* one most likely to end an onboarding.
|
|
49
|
-
*/
|
|
50
|
-
| { by: 'user-elsewhere'; summary: string }
|
|
51
|
-
|
|
52
|
-
export type StateStatus =
|
|
53
|
-
/** Satisfied. */
|
|
54
|
-
| 'ready'
|
|
55
|
-
/** Not satisfied, and it stands in the way. */
|
|
56
|
-
| 'gap'
|
|
57
|
-
/** Not satisfied, and that is a legitimate choice. */
|
|
58
|
-
| 'optional-gap'
|
|
59
|
-
/** A prerequisite is missing, so this was never evaluated. */
|
|
60
|
-
| 'unknown'
|
|
61
|
-
|
|
62
|
-
export interface ReadinessState {
|
|
63
|
-
id: string
|
|
64
|
-
/** Human words for what this is, not the field name. */
|
|
65
|
-
title: string
|
|
66
|
-
status: StateStatus
|
|
67
|
-
/** The current condition, phrased for the human renderer. */
|
|
68
|
-
detail: string
|
|
69
|
-
/** Structured diagnostics emitted only by --json / non-human surfaces. */
|
|
70
|
-
technical?: unknown
|
|
71
|
-
remedy?: Remedy
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export interface Readiness {
|
|
75
|
-
states: ReadinessState[]
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** The first thing standing in the way, or null when nothing is. */
|
|
79
|
-
export function firstBlocker(readiness: Readiness): ReadinessState | null {
|
|
80
|
-
return readiness.states.find((s) => s.status === 'gap') ?? null
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Ready enough to be useful, which is not the same as every box ticked.
|
|
85
|
-
*
|
|
86
|
-
* Hooks and the agent skill are genuinely optional — `send` works without
|
|
87
|
-
* them — so an install that declined both is finished, not half-done. Saying
|
|
88
|
-
* otherwise trains people to ignore the summary line.
|
|
89
|
-
*/
|
|
90
|
-
export function isReady(readiness: Readiness): boolean {
|
|
91
|
-
return firstBlocker(readiness) === null
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const SEND_PREREQUISITES = new Set([
|
|
95
|
-
'cli-bin',
|
|
96
|
-
'credential',
|
|
97
|
-
'server',
|
|
98
|
-
'contract',
|
|
99
|
-
'auth',
|
|
100
|
-
'devices',
|
|
101
|
-
])
|
|
102
|
-
|
|
103
|
-
/** Baseline Notification Requests can be submitted in the final assessed state. */
|
|
104
|
-
export function canSend(readiness: Readiness): boolean {
|
|
105
|
-
return [...SEND_PREREQUISITES].every((id) => {
|
|
106
|
-
const state = readiness.states.find((candidate) => candidate.id === id)
|
|
107
|
-
return state?.status === 'ready' || state?.status === 'optional-gap'
|
|
108
|
-
})
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** This exact invocation has a fully evidenced asynchronous question route. */
|
|
112
|
-
export function questionRoutingReady(readiness: Readiness): boolean {
|
|
113
|
-
const relevant = readiness.states.filter(
|
|
114
|
-
(state) =>
|
|
115
|
-
state.id === 'question-routing-settings' ||
|
|
116
|
-
(state.id === 'hooks' || state.id.startsWith('hooks-')) && state.id !== 'hooks-detected',
|
|
117
|
-
)
|
|
118
|
-
return relevant.length > 0 && relevant.every((state) => state.status === 'ready')
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Stable machine renderer shared by init and doctor. */
|
|
122
|
-
export function readinessJson(readiness: Readiness): object {
|
|
123
|
-
return {
|
|
124
|
-
ready: isReady(readiness),
|
|
125
|
-
can_send: canSend(readiness),
|
|
126
|
-
question_routing_ready: questionRoutingReady(readiness),
|
|
127
|
-
states: readiness.states.map((state) => ({
|
|
128
|
-
id: state.id,
|
|
129
|
-
title: state.title,
|
|
130
|
-
status: state.status,
|
|
131
|
-
detail: state.detail,
|
|
132
|
-
technical: state.technical ?? null,
|
|
133
|
-
remedy: state.remedy ?? null,
|
|
134
|
-
})),
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Everything that could still be done, blocking or not, in dependency order. */
|
|
139
|
-
export function openItems(readiness: Readiness): ReadinessState[] {
|
|
140
|
-
return readiness.states.filter((s) => s.status === 'gap' || s.status === 'optional-gap')
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Work an assessment can do.
|
|
145
|
-
*
|
|
146
|
-
* `local` is this machine's files: project config, hook installs, skill lock
|
|
147
|
-
* files. `remote` is everything that leaves the process — the keychain, the
|
|
148
|
-
* service, the account, devices, and delivery evidence. The split exists so a
|
|
149
|
-
* redraw after a settings change does not pay for a health check.
|
|
150
|
-
*/
|
|
151
|
-
export type ReadinessRefresh = 'local' | 'remote'
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* After a menu action, which readiness work is worth doing.
|
|
155
|
-
*
|
|
156
|
-
* `null` means keep the assessment already in hand. Doctor is the report, not
|
|
157
|
-
* a mutation: the check it just ran is the redraw. A test send and a device
|
|
158
|
-
* list cannot change setup. Settings and hook wiring stay on this machine
|
|
159
|
-
* unless the caller says the service URL itself moved.
|
|
160
|
-
*/
|
|
161
|
-
export function refreshAfterMenuAction(
|
|
162
|
-
action: 'setup' | 'account' | 'test' | 'devices' | 'settings' | 'routing' | 'doctor',
|
|
163
|
-
changed: boolean,
|
|
164
|
-
options: { remote?: boolean } = {},
|
|
165
|
-
): readonly ReadinessRefresh[] | null {
|
|
166
|
-
switch (action) {
|
|
167
|
-
case 'doctor':
|
|
168
|
-
case 'test':
|
|
169
|
-
case 'devices':
|
|
170
|
-
return null
|
|
171
|
-
case 'setup':
|
|
172
|
-
case 'account':
|
|
173
|
-
return changed ? ['local', 'remote'] : null
|
|
174
|
-
case 'settings':
|
|
175
|
-
case 'routing':
|
|
176
|
-
if (!changed) return null
|
|
177
|
-
return options.remote === true ? ['local', 'remote'] : ['local']
|
|
178
|
-
}
|
|
179
|
-
}
|
package/src/release.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import { fileURLToPath } from 'node:url'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* What this build is, according to the manifest npm actually shipped.
|
|
7
|
-
*
|
|
8
|
-
* Read from disk rather than compiled in, because a constant baked into
|
|
9
|
-
* `dist/` is only ever as fresh as the last build. A stale `dist/` sitting
|
|
10
|
-
* next to a newer `package.json` is a real failure mode — it produces a CLI
|
|
11
|
-
* that reports the new version perfectly while running the old code — and the
|
|
12
|
-
* only defence that survives it is refusing to carry a second copy of the
|
|
13
|
-
* number at all.
|
|
14
|
-
*
|
|
15
|
-
* The manifest sits one level above both `src/` and `dist/`, so a single
|
|
16
|
-
* relative path resolves in a source checkout and in a published install.
|
|
17
|
-
*
|
|
18
|
-
* Returns null rather than guessing: a build that cannot establish its own
|
|
19
|
-
* identity has to say so, so callers can degrade honestly instead of
|
|
20
|
-
* asserting something they do not know.
|
|
21
|
-
*/
|
|
22
|
-
export function packageVersion(): string | null {
|
|
23
|
-
try {
|
|
24
|
-
const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
|
25
|
-
const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
|
|
26
|
-
if (typeof parsed === 'object' && parsed !== null) {
|
|
27
|
-
const { version } = parsed as { version?: unknown }
|
|
28
|
-
if (typeof version === 'string' && version.length > 0) return version
|
|
29
|
-
}
|
|
30
|
-
} catch {
|
|
31
|
-
// Fall through: an unreadable manifest must not stop the CLI running.
|
|
32
|
-
}
|
|
33
|
-
return null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Where `npx skills add` fetches the agent skill from, as printed to users.
|
|
38
|
-
*
|
|
39
|
-
* Always an immutable tag naming this exact build. The skill and the CLI ship
|
|
40
|
-
* from one repository at one version, and the skill describes how to drive the
|
|
41
|
-
* CLI — so a moving ref would hand someone guidance for a tool they do not
|
|
42
|
-
* have. Deriving it from the version is what keeps the two from disagreeing;
|
|
43
|
-
* it used to be maintained by hand in three places, and the copies drifted.
|
|
44
|
-
*
|
|
45
|
-
* In the skills CLI grammar `owner/repo#ref` selects a Git ref, while
|
|
46
|
-
* `owner/repo@name` selects a skill — so the `#` here is load-bearing.
|
|
47
|
-
*/
|
|
48
|
-
export function skillsSource(): string | null {
|
|
49
|
-
const version = packageVersion()
|
|
50
|
-
return version === null ? null : `Raidiant-io/notifai#v${version}`
|
|
51
|
-
}
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import os from 'node:os'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
-
import type { NotificationDraftT } from '@raidiant/notifai-protocol'
|
|
6
|
-
import type { MachineCredential } from './credentials.js'
|
|
7
|
-
import {
|
|
8
|
-
SEND_ATTEMPT_TTL_MS,
|
|
9
|
-
beginSendAttempt,
|
|
10
|
-
semanticMediaIds,
|
|
11
|
-
sendDraftFingerprint,
|
|
12
|
-
settleSendAttempt,
|
|
13
|
-
} from './send-attempts.js'
|
|
14
|
-
|
|
15
|
-
const roots: string[] = []
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
function fixture() {
|
|
21
|
-
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-send-attempt-'))
|
|
22
|
-
roots.push(root)
|
|
23
|
-
const env = { XDG_STATE_HOME: path.join(root, 'state') }
|
|
24
|
-
const credential: MachineCredential = {
|
|
25
|
-
machineId: 'mac_attempt',
|
|
26
|
-
machineName: 'Attempt Machine',
|
|
27
|
-
baseUrl: 'https://app.notifai.test',
|
|
28
|
-
secret: 'private-machine-secret',
|
|
29
|
-
}
|
|
30
|
-
const draft = {
|
|
31
|
-
schema_version: 1,
|
|
32
|
-
kind: 'done',
|
|
33
|
-
project: 'private-project',
|
|
34
|
-
presentation: { title: 'Sensitive result', body: 'Private body text' },
|
|
35
|
-
targets: { mode: 'all' },
|
|
36
|
-
} as NotificationDraftT
|
|
37
|
-
return { root, env, credential, draft }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
describe('opaque interrupted-send recovery', () => {
|
|
41
|
-
it('matches one exact semantic retry without storing notification content', () => {
|
|
42
|
-
const { env, credential, draft } = fixture()
|
|
43
|
-
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
44
|
-
const first = beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
45
|
-
expect(first.ok).toBe(true)
|
|
46
|
-
if (!first.ok) return
|
|
47
|
-
|
|
48
|
-
const files = readdirSync(path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts'))
|
|
49
|
-
expect(files).toHaveLength(1)
|
|
50
|
-
const file = path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts', files[0]!)
|
|
51
|
-
const stored = readFileSync(file, 'utf8')
|
|
52
|
-
expect(stored).not.toContain('Sensitive result')
|
|
53
|
-
expect(stored).not.toContain('Private body text')
|
|
54
|
-
if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
55
|
-
if (process.platform !== 'win32') {
|
|
56
|
-
expect(statSync(path.dirname(file)).mode & 0o777).toBe(0o700)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const retry = beginSendAttempt({ env, credential, fingerprint, retry: true, now: 11 })
|
|
60
|
-
expect(retry).toMatchObject({
|
|
61
|
-
ok: true,
|
|
62
|
-
attemptId: first.attemptId,
|
|
63
|
-
idempotencyKey: first.idempotencyKey,
|
|
64
|
-
replay: true,
|
|
65
|
-
})
|
|
66
|
-
settleSendAttempt(env, first.attemptId)
|
|
67
|
-
expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 12 })).toMatchObject({
|
|
68
|
-
ok: false,
|
|
69
|
-
code: 'retry_not_found',
|
|
70
|
-
})
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('does not follow a symlink presented as an attempt record', () => {
|
|
74
|
-
const { root, env, credential, draft } = fixture()
|
|
75
|
-
if (process.platform === 'win32') return
|
|
76
|
-
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
77
|
-
const first = beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
78
|
-
expect(first.ok).toBe(true)
|
|
79
|
-
if (!first.ok) return
|
|
80
|
-
const directory = path.join(env.XDG_STATE_HOME, 'notifai', 'send-attempts')
|
|
81
|
-
const file = path.join(directory, `${first.attemptId}.json`)
|
|
82
|
-
const target = path.join(root, 'foreign.json')
|
|
83
|
-
writeFileSync(target, readFileSync(file, 'utf8'))
|
|
84
|
-
rmSync(file)
|
|
85
|
-
symlinkSync(target, file)
|
|
86
|
-
|
|
87
|
-
expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 11 })).toMatchObject({
|
|
88
|
-
ok: false,
|
|
89
|
-
code: 'retry_not_found',
|
|
90
|
-
})
|
|
91
|
-
expect(readFileSync(target, 'utf8')).toContain(first.idempotencyKey)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it('refuses ambiguity instead of choosing between identical unresolved events', () => {
|
|
95
|
-
const { env, credential, draft } = fixture()
|
|
96
|
-
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
97
|
-
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
98
|
-
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 11 })
|
|
99
|
-
expect(beginSendAttempt({ env, credential, fingerprint, retry: true, now: 12 })).toMatchObject({
|
|
100
|
-
ok: false,
|
|
101
|
-
code: 'retry_ambiguous',
|
|
102
|
-
})
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('invalidates attempts after expiry or credential rotation', () => {
|
|
106
|
-
const { env, credential, draft } = fixture()
|
|
107
|
-
const fingerprint = sendDraftFingerprint(draft, credential)
|
|
108
|
-
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 10 })
|
|
109
|
-
expect(
|
|
110
|
-
beginSendAttempt({
|
|
111
|
-
env,
|
|
112
|
-
credential,
|
|
113
|
-
fingerprint,
|
|
114
|
-
retry: true,
|
|
115
|
-
now: 10 + SEND_ATTEMPT_TTL_MS + 1,
|
|
116
|
-
}),
|
|
117
|
-
).toMatchObject({ ok: false, code: 'retry_not_found' })
|
|
118
|
-
|
|
119
|
-
beginSendAttempt({ env, credential, fingerprint, retry: false, now: 20 })
|
|
120
|
-
const rotated = { ...credential, secret: 'rotated-secret' }
|
|
121
|
-
expect(
|
|
122
|
-
beginSendAttempt({
|
|
123
|
-
env,
|
|
124
|
-
credential: rotated,
|
|
125
|
-
fingerprint: sendDraftFingerprint(draft, rotated),
|
|
126
|
-
retry: true,
|
|
127
|
-
now: 21,
|
|
128
|
-
}),
|
|
129
|
-
).toMatchObject({ ok: false, code: 'retry_not_found' })
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
it('fingerprints local media bytes without retaining paths or bytes', () => {
|
|
133
|
-
const { root } = fixture()
|
|
134
|
-
const file = path.join(root, 'private.png')
|
|
135
|
-
writeFileSync(file, 'first bytes')
|
|
136
|
-
const first = semanticMediaIds([file], root)
|
|
137
|
-
writeFileSync(file, 'different bytes')
|
|
138
|
-
const second = semanticMediaIds([file], root)
|
|
139
|
-
expect(first).not.toEqual(second)
|
|
140
|
-
expect(first[0]).not.toContain(file)
|
|
141
|
-
expect(first[0]).not.toContain('first bytes')
|
|
142
|
-
})
|
|
143
|
-
})
|