mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
199 lines
8.9 KiB
TypeScript
199 lines
8.9 KiB
TypeScript
import { expect } from "bun:test"
|
|
import path from "path"
|
|
import fs from "fs/promises"
|
|
import { Effect, Exit, Fiber, Stream, Tracer } from "effect"
|
|
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
|
import { LayerNode } from "@opencode/util/effect/layer-node"
|
|
import { Project } from "@opencode/core/project"
|
|
import { ProjectIcon } from "@opencode/core/project/icon"
|
|
import { ProjectSchema } from "@opencode/core/project/schema"
|
|
import { ProjectTable } from "@opencode/core/project/sql"
|
|
import { Database } from "@opencode/core/database/database"
|
|
import { Bus } from "@opencode/core/bus"
|
|
import { AbsolutePath } from "@opencode/core/schema"
|
|
import { FSUtil } from "@opencode/util/fs-util"
|
|
import { tmpdirScoped } from "./fixture/tmpdir"
|
|
import { testEffect } from "./lib/effect"
|
|
|
|
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node, FSUtil.node])))
|
|
const svg = '<svg xmlns="http://www.w3.org/2000/svg"/>'
|
|
const url = `data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}`
|
|
|
|
for (const kind of ["ranked appiconset", "escaping symlink", "oversized file"] as const) {
|
|
it.live(`discovers the expected icon with a ${kind}`, () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
const root = path.join(tmp.path, "project")
|
|
const filesystem = yield* FSUtil.Service
|
|
const db = (yield* Database.Service).db
|
|
const id = Project.ID.make("icons")
|
|
yield* Effect.promise(() => Bun.write(path.join(root, "public/favicon.svg"), svg))
|
|
if (kind === "ranked appiconset") {
|
|
yield* Effect.promise(() => Bun.write(path.join(root, "Assets.xcassets/AppIcon.appiconset/000.png"), "other"))
|
|
yield* Effect.promise(() =>
|
|
Bun.write(path.join(root, "Assets.xcassets/AppIcon.appiconset/icon256.svg"), svg + " "),
|
|
)
|
|
}
|
|
if (kind === "escaping symlink") {
|
|
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "outside.svg"), "outside"))
|
|
yield* Effect.promise(() => fs.mkdir(path.join(root, "assets")))
|
|
yield* Effect.promise(() =>
|
|
fs.symlink(path.join(tmp.path, "outside.svg"), path.join(root, "assets/app-icon.svg")),
|
|
)
|
|
}
|
|
if (kind === "oversized file")
|
|
yield* Effect.promise(() => Bun.write(path.join(root, "assets/app-icon.svg"), "x".repeat(64 * 1024 + 1)))
|
|
yield* db
|
|
.insert(ProjectTable)
|
|
.values({ id, worktree: AbsolutePath.make(root), sandboxes: [] })
|
|
.run()
|
|
const row = yield* ProjectIcon.refresh(filesystem, db, id)
|
|
expect(row?.icon_url).toBe(
|
|
kind === "ranked appiconset" ? `data:image/svg+xml;base64,${Buffer.from(svg + " ").toString("base64")}` : url,
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
|
|
it.live("resolves in the background, preserves overrides, and only writes changed content", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
const project = yield* Project.Service
|
|
const bus = yield* Bus.Service
|
|
const filesystem = yield* FSUtil.Service
|
|
const db = (yield* Database.Service).db
|
|
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "favicon.svg"), svg))
|
|
const updated = yield* bus
|
|
.subscribe(ProjectSchema.Event.Updated)
|
|
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
|
|
const resolved = yield* project.resolve(AbsolutePath.make(tmp.path))
|
|
expect((yield* Fiber.join(updated))[0]?.data.icon?.url).toBe(url)
|
|
yield* project.update({ projectID: resolved.id, icon: { override: "my-icon", color: "blue" } })
|
|
const before = (yield* project.list())[0]
|
|
expect(yield* ProjectIcon.refresh(filesystem, db, resolved.id)).toBeUndefined()
|
|
expect((yield* project.list())[0]).toEqual(before)
|
|
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "favicon.svg"), svg + " "))
|
|
const changed = yield* ProjectIcon.refresh(filesystem, db, resolved.id)
|
|
expect(changed?.icon_url).not.toBe(url)
|
|
expect(changed).toMatchObject({ icon_url_override: "my-icon", icon_color: "blue" })
|
|
}),
|
|
)
|
|
|
|
for (const fixture of [
|
|
{ name: "root Composer SVG", file: "AppIcon.icon/Assets/logo.svg", manifest: "AppIcon.icon/icon.json" },
|
|
{ name: "app Composer PNG", file: "app/AppIcon.icon/Assets/logo.png", manifest: "app/AppIcon.icon/icon.json" },
|
|
{
|
|
name: "assets Composer SVG",
|
|
file: "assets/AppIcon.icon/Assets/logo.svg",
|
|
manifest: "assets/AppIcon.icon/icon.json",
|
|
},
|
|
{ name: "direct-child Xcode catalog", file: "OpenCode/Assets.xcassets/AppIcon.appiconset/icon128.svg" },
|
|
{ name: "Windows nearest usable size", file: "resources/windows/icon-96.png" },
|
|
{ name: "Scrawl oversized flat icon", file: "site/public/icon.svg" },
|
|
]) {
|
|
it.live(`discovers ${fixture.name} without a database`, () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
const filesystem = yield* FSUtil.Service
|
|
yield* Effect.promise(() =>
|
|
Bun.write(
|
|
path.join(tmp.path, fixture.file),
|
|
fixture.file.endsWith(".png")
|
|
? Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWZkAAAAASUVORK5CYII=",
|
|
"base64",
|
|
)
|
|
: svg,
|
|
),
|
|
)
|
|
if (fixture.manifest)
|
|
yield* Effect.promise(() =>
|
|
Bun.write(
|
|
path.join(tmp.path, fixture.manifest),
|
|
JSON.stringify({
|
|
groups: [{ layers: [{ "image-name": path.basename(fixture.file) }] }],
|
|
}),
|
|
),
|
|
)
|
|
if (fixture.name === "Windows nearest usable size") {
|
|
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "resources/windows/icon-128.png"), "x".repeat(65537)))
|
|
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "resources/windows/icon-32.png"), "other"))
|
|
}
|
|
if (fixture.name === "Scrawl oversized flat icon")
|
|
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "assets/app-icon.png"), "x".repeat(65537)))
|
|
expect((yield* ProjectIcon.discover(filesystem, tmp.path))?.path.split(path.sep).join("/")).toBe(fixture.file)
|
|
}),
|
|
)
|
|
}
|
|
|
|
it.live("Composer backgrounds stay inert and malformed, oversized or escaping manifests fall through", () =>
|
|
Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
const filesystem = yield* FSUtil.Service
|
|
const manifest = path.join(tmp.path, "AppIcon.icon/icon.json")
|
|
yield* Effect.promise(() => Bun.write(path.join(tmp.path, "AppIcon.icon/Assets/logo.svg"), svg))
|
|
for (const fill of [
|
|
{ solid: "display-p3:0.075,0.063,0.063,1" },
|
|
{ "linear-gradient": ["gray:0.2,1", "srgb:0,0,0,1"] },
|
|
]) {
|
|
yield* Effect.promise(() =>
|
|
Bun.write(manifest, JSON.stringify({ fill, groups: [{ layers: [{ "image-name": "logo.svg" }] }] })),
|
|
)
|
|
const icon = yield* ProjectIcon.discover(filesystem, tmp.path)
|
|
if (!icon) throw new Error("Expected Composer icon")
|
|
const markup = Buffer.from(icon.url.slice(icon.url.indexOf(",") + 1), "base64").toString()
|
|
expect(markup).toContain(`<image width="128" height="128" href="${url}"`)
|
|
expect(markup).toContain("<rect")
|
|
expect(icon.url.length).toBeLessThanOrEqual(65536)
|
|
}
|
|
for (const content of [
|
|
"{",
|
|
"x".repeat(65537),
|
|
JSON.stringify({ groups: [{ layers: [{ "image-name": "missing.svg" }] }] }),
|
|
]) {
|
|
yield* Effect.promise(() => Bun.write(manifest, content))
|
|
expect(yield* ProjectIcon.discover(filesystem, tmp.path)).toBeUndefined()
|
|
}
|
|
const outside = yield* tmpdirScoped()
|
|
yield* Effect.promise(() =>
|
|
Bun.write(
|
|
path.join(outside.path, "icon.json"),
|
|
JSON.stringify({ groups: [{ layers: [{ "image-name": "logo.svg" }] }] }),
|
|
),
|
|
)
|
|
yield* Effect.promise(() => fs.rm(manifest))
|
|
yield* Effect.promise(() => fs.symlink(path.join(outside.path, "icon.json"), manifest))
|
|
expect(yield* ProjectIcon.discover(filesystem, tmp.path)).toBeUndefined()
|
|
}),
|
|
)
|
|
|
|
it.live("returns undefined without recording span failures when the directory is missing", () => {
|
|
const spans: Tracer.NativeSpan[] = []
|
|
const tracer = Tracer.make({
|
|
span(options) {
|
|
const span = new Tracer.NativeSpan(options)
|
|
spans.push(span)
|
|
return span
|
|
},
|
|
})
|
|
return Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
const missing = path.join(tmp.path, "missing")
|
|
const filesystem = yield* FSUtil.Service
|
|
const db = (yield* Database.Service).db
|
|
const id = Project.ID.make("missing-icons")
|
|
yield* db
|
|
.insert(ProjectTable)
|
|
.values({ id, worktree: AbsolutePath.make(missing), sandboxes: [] })
|
|
.run()
|
|
|
|
expect(yield* ProjectIcon.refresh(filesystem, db, id)).toBeUndefined()
|
|
|
|
const iconSpans = spans.filter((span) => span.name.startsWith("ProjectIcon."))
|
|
expect(iconSpans.map((span) => span.name)).toEqual(["ProjectIcon.refresh", "ProjectIcon.discover"])
|
|
for (const span of iconSpans) {
|
|
expect(span.status._tag === "Ended" && Exit.isSuccess(span.status.exit)).toBe(true)
|
|
}
|
|
}).pipe(Effect.provideService(Tracer.Tracer, tracer))
|
|
})
|