Skip to content

Commit d6c8698

Browse files
committed
test: add coverage for file integrity verification in postinstall
Signed-off-by: leocavalcante <leo@cavalcante.dev>
1 parent 2198734 commit d6c8698

File tree

3 files changed

+390
-2
lines changed

3 files changed

+390
-2
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"author": "OpenCode Community",
4040
"license": "MIT",
4141
"dependencies": {
42-
"@opencode-ai/plugin": "^0.1.0"
42+
"@opencode-ai/plugin": "^1.1.25"
4343
},
4444
"devDependencies": {
4545
"@biomejs/biome": "^2.3.11",

tests/install.test.ts

Lines changed: 351 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
2-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
2+
import {
3+
existsSync,
4+
mkdirSync,
5+
readdirSync,
6+
readFileSync,
7+
rmSync,
8+
statSync,
9+
writeFileSync,
10+
} from "node:fs"
311
import { tmpdir } from "node:os"
412
import { join } from "node:path"
513

@@ -263,6 +271,348 @@ main()
263271
})
264272
})
265273

274+
describe("file integrity verification", () => {
275+
// Helper that creates a postinstall script with integrity validation
276+
function createPostinstallWithIntegrity(): string {
277+
return `#!/usr/bin/env node
278+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs"
279+
import { join } from "node:path"
280+
281+
const AGENTS_SOURCE_DIR = "${agentsSourceDir.replace(/\\/g, "/")}"
282+
const AGENTS_TARGET_DIR = "${agentsTargetDir.replace(/\\/g, "/")}"
283+
284+
const MIN_CONTENT_LENGTH = 100
285+
const REQUIRED_KEYWORDS = ["agent", "task"]
286+
287+
function validateAgentContent(filePath) {
288+
const content = readFileSync(filePath, "utf-8")
289+
290+
if (content.length < MIN_CONTENT_LENGTH) {
291+
return {
292+
valid: false,
293+
error: \`File too short: \${content.length} characters (minimum \${MIN_CONTENT_LENGTH})\`,
294+
}
295+
}
296+
297+
if (!content.startsWith("# ")) {
298+
return {
299+
valid: false,
300+
error: "File does not start with a markdown header (# )",
301+
}
302+
}
303+
304+
const lowerContent = content.toLowerCase()
305+
const hasKeyword = REQUIRED_KEYWORDS.some((keyword) => lowerContent.includes(keyword))
306+
if (!hasKeyword) {
307+
return {
308+
valid: false,
309+
error: \`File missing required keywords: \${REQUIRED_KEYWORDS.join(", ")}\`,
310+
}
311+
}
312+
313+
return { valid: true }
314+
}
315+
316+
function main() {
317+
console.log("opencode-plugin-opencoder: Installing agents...")
318+
319+
if (!existsSync(AGENTS_TARGET_DIR)) {
320+
mkdirSync(AGENTS_TARGET_DIR, { recursive: true })
321+
console.log(\` Created \${AGENTS_TARGET_DIR}\`)
322+
}
323+
324+
if (!existsSync(AGENTS_SOURCE_DIR)) {
325+
console.error(\` Error: Source agents directory not found at \${AGENTS_SOURCE_DIR}\`)
326+
process.exit(1)
327+
}
328+
329+
const files = readdirSync(AGENTS_SOURCE_DIR).filter((f) => f.endsWith(".md"))
330+
331+
if (files.length === 0) {
332+
console.error(" Error: No agent files found in agents/ directory")
333+
process.exit(1)
334+
}
335+
336+
const successes = []
337+
const failures = []
338+
339+
for (const file of files) {
340+
const sourcePath = join(AGENTS_SOURCE_DIR, file)
341+
const targetPath = join(AGENTS_TARGET_DIR, file)
342+
343+
try {
344+
copyFileSync(sourcePath, targetPath)
345+
346+
const sourceSize = statSync(sourcePath).size
347+
const targetSize = statSync(targetPath).size
348+
349+
if (sourceSize !== targetSize) {
350+
throw new Error(
351+
\`File size mismatch: source=\${sourceSize} bytes, target=\${targetSize} bytes\`,
352+
)
353+
}
354+
355+
const validation = validateAgentContent(targetPath)
356+
if (!validation.valid) {
357+
throw new Error(\`Invalid agent file content: \${validation.error}\`)
358+
}
359+
360+
successes.push(file)
361+
console.log(\` Installed: \${file}\`)
362+
} catch (err) {
363+
failures.push({ file, message: err.message })
364+
console.error(\` Failed: \${file} - \${err.message}\`)
365+
}
366+
}
367+
368+
console.log("")
369+
if (successes.length > 0 && failures.length === 0) {
370+
console.log(\`opencode-plugin-opencoder: Successfully installed \${successes.length} agent(s)\`)
371+
} else if (successes.length > 0 && failures.length > 0) {
372+
console.log(\`opencode-plugin-opencoder: Installed \${successes.length} of \${files.length} agent(s)\`)
373+
console.error(\` \${failures.length} file(s) failed to install:\`)
374+
for (const { file, message } of failures) {
375+
console.error(\` - \${file}: \${message}\`)
376+
}
377+
} else {
378+
console.error("opencode-plugin-opencoder: Failed to install any agents")
379+
for (const { file, message } of failures) {
380+
console.error(\` - \${file}: \${message}\`)
381+
}
382+
process.exit(1)
383+
}
384+
}
385+
386+
main()
387+
`
388+
}
389+
390+
// Valid agent content for tests (meets all requirements)
391+
const validAgentContent = `# Test Agent
392+
393+
This is a valid agent file that contains enough content to pass the minimum length requirement.
394+
395+
## Task Execution
396+
397+
The agent handles various tasks and operations in the system.
398+
`
399+
400+
beforeEach(() => {
401+
// Override the default agent files with valid content
402+
writeFileSync(join(agentsSourceDir, "opencoder.md"), validAgentContent)
403+
writeFileSync(join(agentsSourceDir, "opencoder-planner.md"), validAgentContent)
404+
writeFileSync(join(agentsSourceDir, "opencoder-builder.md"), validAgentContent)
405+
})
406+
407+
it("should reject files that are too short", async () => {
408+
// Create a file that's too short (less than 100 characters)
409+
writeFileSync(join(agentsSourceDir, "short-agent.md"), "# Short\nToo short content")
410+
411+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
412+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
413+
414+
const proc = Bun.spawn(["node", scriptPath], {
415+
cwd: mockProjectDir,
416+
stdout: "pipe",
417+
stderr: "pipe",
418+
})
419+
420+
const exitCode = await proc.exited
421+
// Should succeed partially (other files are valid)
422+
expect(exitCode).toBe(0)
423+
424+
const stderr = await new Response(proc.stderr).text()
425+
expect(stderr).toContain("short-agent.md")
426+
expect(stderr).toContain("File too short")
427+
})
428+
429+
it("should reject files without markdown header", async () => {
430+
// Create a file without markdown header
431+
const contentWithoutHeader = `This file does not start with a markdown header.
432+
It has enough content and contains the word agent and task.
433+
This should fail the validation because it doesn't start with # symbol.`
434+
writeFileSync(join(agentsSourceDir, "no-header.md"), contentWithoutHeader)
435+
436+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
437+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
438+
439+
const proc = Bun.spawn(["node", scriptPath], {
440+
cwd: mockProjectDir,
441+
stdout: "pipe",
442+
stderr: "pipe",
443+
})
444+
445+
await proc.exited
446+
447+
const stderr = await new Response(proc.stderr).text()
448+
expect(stderr).toContain("no-header.md")
449+
expect(stderr).toContain("does not start with a markdown header")
450+
})
451+
452+
it("should reject files missing required keywords", async () => {
453+
// Create a file with header and length but missing keywords
454+
const contentWithoutKeywords = `# Valid Header
455+
456+
This file has a valid markdown header and enough content length.
457+
However, it does not contain any of the required keywords.
458+
It talks about processes and workflows but not the specific terms.
459+
This should fail the validation check for missing keywords.`
460+
writeFileSync(join(agentsSourceDir, "no-keywords.md"), contentWithoutKeywords)
461+
462+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
463+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
464+
465+
const proc = Bun.spawn(["node", scriptPath], {
466+
cwd: mockProjectDir,
467+
stdout: "pipe",
468+
stderr: "pipe",
469+
})
470+
471+
await proc.exited
472+
473+
const stderr = await new Response(proc.stderr).text()
474+
expect(stderr).toContain("no-keywords.md")
475+
expect(stderr).toContain("missing required keywords")
476+
})
477+
478+
it("should accept files with valid content structure", async () => {
479+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
480+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
481+
482+
const proc = Bun.spawn(["node", scriptPath], {
483+
cwd: mockProjectDir,
484+
stdout: "pipe",
485+
stderr: "pipe",
486+
})
487+
488+
const exitCode = await proc.exited
489+
expect(exitCode).toBe(0)
490+
491+
const stdout = await new Response(proc.stdout).text()
492+
expect(stdout).toContain("Successfully installed 3 agent(s)")
493+
494+
// Verify all files were copied
495+
expect(existsSync(join(agentsTargetDir, "opencoder.md"))).toBe(true)
496+
expect(existsSync(join(agentsTargetDir, "opencoder-planner.md"))).toBe(true)
497+
expect(existsSync(join(agentsTargetDir, "opencoder-builder.md"))).toBe(true)
498+
})
499+
500+
it("should fail completely when all files are invalid", async () => {
501+
// Replace all agent files with invalid content
502+
writeFileSync(join(agentsSourceDir, "opencoder.md"), "Too short")
503+
writeFileSync(join(agentsSourceDir, "opencoder-planner.md"), "Also short")
504+
writeFileSync(join(agentsSourceDir, "opencoder-builder.md"), "Short too")
505+
506+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
507+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
508+
509+
const proc = Bun.spawn(["node", scriptPath], {
510+
cwd: mockProjectDir,
511+
stdout: "pipe",
512+
stderr: "pipe",
513+
})
514+
515+
const exitCode = await proc.exited
516+
expect(exitCode).toBe(1)
517+
518+
const stderr = await new Response(proc.stderr).text()
519+
expect(stderr).toContain("Failed to install any agents")
520+
})
521+
522+
it("should report partial success when some files are invalid", async () => {
523+
// Keep one valid, make others invalid
524+
writeFileSync(join(agentsSourceDir, "opencoder-planner.md"), "Too short")
525+
writeFileSync(join(agentsSourceDir, "opencoder-builder.md"), "Also short")
526+
527+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
528+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
529+
530+
const proc = Bun.spawn(["node", scriptPath], {
531+
cwd: mockProjectDir,
532+
stdout: "pipe",
533+
stderr: "pipe",
534+
})
535+
536+
const exitCode = await proc.exited
537+
expect(exitCode).toBe(0)
538+
539+
const stdout = await new Response(proc.stdout).text()
540+
expect(stdout).toContain("Installed 1 of 3 agent(s)")
541+
542+
const stderr = await new Response(proc.stderr).text()
543+
expect(stderr).toContain("2 file(s) failed to install")
544+
})
545+
546+
it("should verify file size matches after copy", async () => {
547+
// This test verifies the size check logic is present
548+
// The actual size mismatch is hard to simulate without mocking fs
549+
// So we verify that valid files pass the size check
550+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
551+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
552+
553+
const proc = Bun.spawn(["node", scriptPath], {
554+
cwd: mockProjectDir,
555+
stdout: "pipe",
556+
stderr: "pipe",
557+
})
558+
559+
await proc.exited
560+
561+
// Verify files have matching sizes
562+
const sourceSize = statSync(join(agentsSourceDir, "opencoder.md")).size
563+
const targetSize = statSync(join(agentsTargetDir, "opencoder.md")).size
564+
expect(targetSize).toBe(sourceSize)
565+
})
566+
567+
it("should accept keyword 'agent' case-insensitively", async () => {
568+
const contentWithUpperAgent = `# Test File
569+
570+
This file contains the word AGENT in uppercase and has enough content.
571+
The validation should accept this because keyword matching is case-insensitive.
572+
Adding more text to ensure minimum length requirement is satisfied here.`
573+
writeFileSync(join(agentsSourceDir, "upper-agent.md"), contentWithUpperAgent)
574+
575+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
576+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
577+
578+
const proc = Bun.spawn(["node", scriptPath], {
579+
cwd: mockProjectDir,
580+
stdout: "pipe",
581+
stderr: "pipe",
582+
})
583+
584+
const exitCode = await proc.exited
585+
expect(exitCode).toBe(0)
586+
587+
// The file should be installed
588+
expect(existsSync(join(agentsTargetDir, "upper-agent.md"))).toBe(true)
589+
})
590+
591+
it("should accept keyword 'task' as alternative to 'agent'", async () => {
592+
const contentWithTask = `# Test File
593+
594+
This file contains the word TASK but not the other keyword.
595+
The validation should accept this because either keyword is sufficient.
596+
Adding more text to ensure minimum length requirement is satisfied here.`
597+
writeFileSync(join(agentsSourceDir, "task-only.md"), contentWithTask)
598+
599+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
600+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
601+
602+
const proc = Bun.spawn(["node", scriptPath], {
603+
cwd: mockProjectDir,
604+
stdout: "pipe",
605+
stderr: "pipe",
606+
})
607+
608+
const exitCode = await proc.exited
609+
expect(exitCode).toBe(0)
610+
611+
// The file should be installed
612+
expect(existsSync(join(agentsTargetDir, "task-only.md"))).toBe(true)
613+
})
614+
})
615+
266616
describe("preuninstall script", () => {
267617
it("should remove agent files from target directory", async () => {
268618
// First install the agents

0 commit comments

Comments
 (0)