Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
1289 lines
42 KiB
JavaScript
1289 lines
42 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
chmod,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
utimes,
|
|
writeFile
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
import {
|
|
buildTuttiAppRelease,
|
|
validateCLIManifest,
|
|
validateManifest
|
|
} from "./build-tutti-app-release.mjs";
|
|
import { buildTuttiAppCatalog } from "./build-tutti-app-catalog.mjs";
|
|
import { buildTuttiAppVersions } from "../../packages/workspace/app-release-tools/bin/build-tutti-app-versions.mjs";
|
|
import { verifyTuttiAppReleaseArtifacts } from "../../packages/workspace/app-release-tools/bin/verify-tutti-app-release-artifacts.mjs";
|
|
|
|
const reusableWorkflowPath = new URL(
|
|
"../../.github/workflows/publish-tutti-app-release.yml",
|
|
import.meta.url
|
|
);
|
|
const catalogWorkflowPath = new URL(
|
|
"../../.github/workflows/publish-tutti-app-catalog.yml",
|
|
import.meta.url
|
|
);
|
|
const stagingCatalogWorkflowPath = new URL(
|
|
"../../.github/workflows/publish-tutti-app-catalog-staging.yml",
|
|
import.meta.url
|
|
);
|
|
const publishCatalogToolPath = new URL(
|
|
"../../packages/workspace/app-release-tools/bin/publish-tutti-app-catalog.mjs",
|
|
import.meta.url
|
|
);
|
|
const releaseReferencePath = new URL(
|
|
"../../services/tuttid/service/workspace/agent_workspace_app_reference/references/github-actions-release.md",
|
|
import.meta.url
|
|
);
|
|
|
|
test("buildTuttiAppRelease writes immutable release and latest metadata", async () => {
|
|
const packageDir = await createPackageForTest("vibe-design");
|
|
const outputDir = await mkdtemp(path.join(tmpdir(), "tutti-release-"));
|
|
|
|
const result = await buildTuttiAppRelease({
|
|
appId: "vibe-design",
|
|
packageDir,
|
|
outputDir,
|
|
baseUrl: "https://cdn.example.test/tutti-apps/",
|
|
version: "0.1.0+abc123",
|
|
gitSha: "abc123",
|
|
publishedAt: "2026-06-04T00:00:00Z"
|
|
});
|
|
|
|
assert.equal(result.release.appId, "vibe-design");
|
|
assert.equal(result.release.version, "0.1.0+abc123");
|
|
assert.equal(result.release.manifest.version, "0.1.0+abc123");
|
|
assert.deepEqual(result.release.manifest.authors, [
|
|
{
|
|
name: "Tutti Developer",
|
|
avatarUrl: "https://github.com/tutti-os.png",
|
|
url: "https://github.com/tutti-os"
|
|
}
|
|
]);
|
|
assert.deepEqual(result.release.manifest.source, {
|
|
type: "github",
|
|
url: "https://github.com/tutti-os/vibe-design"
|
|
});
|
|
assert.equal(
|
|
result.release.artifactUrl,
|
|
"https://cdn.example.test/tutti-apps/apps/vibe-design/0.1.0%2Babc123/vibe-design-0.1.0%2Babc123.zip"
|
|
);
|
|
assert.match(result.release.artifactSha256, /^[a-f0-9]{64}$/);
|
|
assert.ok(result.release.artifactSizeBytes > 0);
|
|
assert.equal(
|
|
result.release.iconUrl,
|
|
"https://cdn.example.test/tutti-apps/apps/vibe-design/0.1.0%2Babc123/icon.svg"
|
|
);
|
|
|
|
const latest = JSON.parse(await readFile(result.latestJsonPath, "utf8"));
|
|
assert.deepEqual(latest, result.release);
|
|
|
|
const manifest = JSON.parse(
|
|
await readFile(path.join(packageDir, "tutti.app.json"), "utf8")
|
|
);
|
|
assert.equal(manifest.version, "0.1.0+abc123");
|
|
});
|
|
|
|
test("buildTuttiAppRelease includes package manifest localizations", async () => {
|
|
const packageDir = await createPackageForTest("localized-app", {
|
|
localizations: [
|
|
{
|
|
locale: "zh-CN",
|
|
metadata: {
|
|
name: "本地化应用",
|
|
description: "本地化描述",
|
|
tags: ["本地化", "工作区", "工作区"]
|
|
}
|
|
}
|
|
]
|
|
});
|
|
const outputDir = await mkdtemp(path.join(tmpdir(), "tutti-release-"));
|
|
|
|
const result = await buildTuttiAppRelease({
|
|
appId: "localized-app",
|
|
packageDir,
|
|
outputDir,
|
|
baseUrl: "https://cdn.example.test/tutti-apps/",
|
|
version: "0.1.0+abc123",
|
|
gitSha: "abc123",
|
|
publishedAt: "2026-06-04T00:00:00Z"
|
|
});
|
|
|
|
assert.deepEqual(result.release.localizations, [
|
|
{
|
|
locale: "zh-CN",
|
|
name: "本地化应用",
|
|
description: "本地化描述",
|
|
tags: ["本地化", "工作区"]
|
|
}
|
|
]);
|
|
});
|
|
|
|
test("buildTuttiAppRelease creates reproducible archives", async () => {
|
|
const packageDir = await createPackageForTest("reproducible-app");
|
|
const firstOutputDir = await mkdtemp(
|
|
path.join(tmpdir(), "tutti-release-first-")
|
|
);
|
|
const secondOutputDir = await mkdtemp(
|
|
path.join(tmpdir(), "tutti-release-second-")
|
|
);
|
|
const options = {
|
|
appId: "reproducible-app",
|
|
packageDir,
|
|
baseUrl: "https://cdn.example.test/tutti-apps",
|
|
version: "1.2.3",
|
|
gitSha: "abc123",
|
|
publishedAt: "2026-06-04T00:00:00Z"
|
|
};
|
|
|
|
const first = await buildTuttiAppRelease({
|
|
...options,
|
|
outputDir: firstOutputDir
|
|
});
|
|
const changedTimestamp = new Date("2030-01-02T03:04:06.000Z");
|
|
await utimes(
|
|
path.join(packageDir, "AGENTS.md"),
|
|
changedTimestamp,
|
|
changedTimestamp
|
|
);
|
|
const second = await buildTuttiAppRelease({
|
|
...options,
|
|
outputDir: secondOutputDir
|
|
});
|
|
|
|
assert.equal(first.release.artifactSha256, second.release.artifactSha256);
|
|
assert.deepEqual(
|
|
await readFile(first.artifactPath),
|
|
await readFile(second.artifactPath)
|
|
);
|
|
});
|
|
|
|
test("buildTuttiAppCatalog merges release files into remote catalog", async () => {
|
|
const alpha = await releaseFileForTest("alpha-app");
|
|
const beta = await releaseFileForTest("beta-app", "0.1.0", {
|
|
localizations: [
|
|
{
|
|
locale: "zh-CN",
|
|
name: "Beta 应用",
|
|
description: "Beta 描述",
|
|
tags: ["Beta"]
|
|
}
|
|
]
|
|
});
|
|
const outputDir = await mkdtemp(path.join(tmpdir(), "tutti-catalog-"));
|
|
const outputPath = path.join(outputDir, "catalog.json");
|
|
|
|
const result = await buildTuttiAppCatalog({
|
|
releaseFiles: [beta, alpha],
|
|
outputPath
|
|
});
|
|
|
|
assert.equal(result.catalog.schemaVersion, "tutti.app.catalog.v1");
|
|
assert.deepEqual(
|
|
result.catalog.apps.map((app) => app.manifest.appId),
|
|
["alpha-app", "beta-app"]
|
|
);
|
|
assert.equal(result.catalog.apps[0].distribution.kind, "remote");
|
|
assert.equal(
|
|
result.catalog.apps[0].distribution.iconUrl,
|
|
"https://cdn.example.test/apps/alpha-app/icon.svg"
|
|
);
|
|
assert.deepEqual(result.catalog.apps[1].localizations, [
|
|
{
|
|
locale: "zh-CN",
|
|
name: "Beta 应用",
|
|
description: "Beta 描述",
|
|
tags: ["Beta"]
|
|
}
|
|
]);
|
|
|
|
const written = JSON.parse(await readFile(outputPath, "utf8"));
|
|
assert.deepEqual(written, result.catalog);
|
|
});
|
|
|
|
test("buildTuttiAppCatalog merges release files into an existing catalog", async () => {
|
|
const alpha = await releaseFileForTest("alpha-app", "0.2.0");
|
|
const existingCatalog = await catalogFileForTest([
|
|
catalogAppForTest("alpha-app", "0.1.0"),
|
|
catalogAppForTest("gamma-app", "0.1.0")
|
|
]);
|
|
const outputDir = await mkdtemp(path.join(tmpdir(), "tutti-catalog-"));
|
|
const outputPath = path.join(outputDir, "catalog.json");
|
|
|
|
const result = await buildTuttiAppCatalog({
|
|
existingCatalogPath: existingCatalog,
|
|
releaseFiles: [alpha],
|
|
outputPath
|
|
});
|
|
|
|
assert.deepEqual(
|
|
result.catalog.apps.map((app) => app.manifest.appId),
|
|
["alpha-app", "gamma-app"]
|
|
);
|
|
assert.equal(result.catalog.apps[0].manifest.version, "0.2.0");
|
|
assert.equal(result.catalog.apps[1].manifest.version, "0.1.0");
|
|
});
|
|
|
|
test("buildTuttiAppCatalog can refresh an existing catalog without release files", async () => {
|
|
const existingCatalog = await catalogFileForTest([
|
|
catalogAppForTest("gamma-app", "0.1.0"),
|
|
catalogAppForTest("alpha-app", "0.1.0")
|
|
]);
|
|
const outputDir = await mkdtemp(path.join(tmpdir(), "tutti-catalog-"));
|
|
const outputPath = path.join(outputDir, "catalog.json");
|
|
|
|
const result = await buildTuttiAppCatalog({
|
|
existingCatalogPath: existingCatalog,
|
|
releaseFiles: [],
|
|
outputPath
|
|
});
|
|
|
|
assert.deepEqual(
|
|
result.catalog.apps.map((app) => app.manifest.appId),
|
|
["alpha-app", "gamma-app"]
|
|
);
|
|
});
|
|
|
|
test("buildTuttiAppCatalog rejects duplicate app ids", async () => {
|
|
const first = await releaseFileForTest("duplicate-app");
|
|
const second = await releaseFileForTest("duplicate-app");
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
buildTuttiAppCatalog({
|
|
releaseFiles: [first, second],
|
|
outputPath: path.join(tmpdir(), "unused-catalog.json")
|
|
}),
|
|
/duplicate release appId duplicate-app/
|
|
);
|
|
});
|
|
|
|
test("buildTuttiAppVersions preserves history and is idempotent", async () => {
|
|
const first = await releaseFileForTest("versioned-app", "0.1.0");
|
|
const second = await releaseFileForTest("versioned-app", "0.2.0", {
|
|
publishedAt: "2026-06-05T00:00:00Z"
|
|
});
|
|
const tempDir = await mkdtemp(path.join(tmpdir(), "tutti-versions-"));
|
|
const firstOutput = path.join(tempDir, "first.json");
|
|
const secondOutput = path.join(tempDir, "second.json");
|
|
|
|
await buildTuttiAppVersions({
|
|
releaseFiles: [first],
|
|
minTuttiVersion: "0.0.0",
|
|
outputPath: firstOutput
|
|
});
|
|
const result = await buildTuttiAppVersions({
|
|
existingVersionsPath: firstOutput,
|
|
releaseFiles: [second],
|
|
minTuttiVersion: "0.12.0",
|
|
outputPath: secondOutput
|
|
});
|
|
|
|
assert.deepEqual(
|
|
result.versions.versions.map((record) => [
|
|
record.release.version,
|
|
record.minTuttiVersion,
|
|
record.status
|
|
]),
|
|
[
|
|
["0.1.0", "0.0.0", "active"],
|
|
["0.2.0", "0.12.0", "active"]
|
|
]
|
|
);
|
|
|
|
const idempotentOutput = path.join(tempDir, "idempotent.json");
|
|
const idempotentResult = await buildTuttiAppVersions({
|
|
existingVersionsPath: secondOutput,
|
|
releaseFiles: [second],
|
|
minTuttiVersion: "0.12.0",
|
|
outputPath: idempotentOutput
|
|
});
|
|
assert.deepEqual(idempotentResult.versions, result.versions);
|
|
assert.equal(
|
|
await readFile(idempotentOutput, "utf8"),
|
|
await readFile(secondOutput, "utf8")
|
|
);
|
|
});
|
|
|
|
test("buildTuttiAppVersions rejects changed compatibility for an immutable version", async () => {
|
|
const release = await releaseFileForTest("immutable-app", "1.0.0");
|
|
const tempDir = await mkdtemp(path.join(tmpdir(), "tutti-versions-"));
|
|
const existing = path.join(tempDir, "existing.json");
|
|
await buildTuttiAppVersions({
|
|
releaseFiles: [release],
|
|
minTuttiVersion: "0.0.0",
|
|
outputPath: existing
|
|
});
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
buildTuttiAppVersions({
|
|
existingVersionsPath: existing,
|
|
releaseFiles: [release],
|
|
minTuttiVersion: "0.12.0",
|
|
outputPath: path.join(tempDir, "changed.json")
|
|
}),
|
|
/different compatibility or release metadata/
|
|
);
|
|
});
|
|
|
|
test("capability-gated releases derive immutable metadata from the app manifest", async () => {
|
|
const fallback = await releaseFileForTest("capability-app", "1.0.0");
|
|
const capable = await releaseFileForTest("capability-app", "2.0.0", {
|
|
hostCompatibility: {
|
|
requiredTuttiCapabilities: ["managed-model-cli-v1"]
|
|
}
|
|
});
|
|
const tempDir = await mkdtemp(path.join(tmpdir(), "tutti-capability-"));
|
|
const baseline = path.join(tempDir, "baseline.json");
|
|
const versions = path.join(tempDir, "versions.json");
|
|
|
|
await buildTuttiAppVersions({
|
|
releaseFiles: [fallback],
|
|
minTuttiVersion: "0.0.0",
|
|
outputPath: baseline
|
|
});
|
|
const result = await buildTuttiAppVersions({
|
|
existingVersionsPath: baseline,
|
|
releaseFiles: [capable],
|
|
outputPath: versions
|
|
});
|
|
|
|
assert.deepEqual(result.versions.versions[1].requiredTuttiCapabilities, [
|
|
"managed-model-cli-v1"
|
|
]);
|
|
assert.equal(result.versions.versions[1].minTuttiVersion, undefined);
|
|
|
|
const catalog = await buildTuttiAppCatalog({
|
|
versionsFiles: [versions],
|
|
outputPath: path.join(tempDir, "catalog.json")
|
|
});
|
|
assert.equal(catalog.catalog.apps[0].manifest.version, "1.0.0");
|
|
assert.equal(
|
|
catalog.catalog.compatibility.apps["capability-app"][0].app.manifest
|
|
.version,
|
|
"1.0.0"
|
|
);
|
|
assert.deepEqual(
|
|
catalog.catalog.compatibility.capabilityApps["capability-app"].map(
|
|
(entry) => [entry.requiredTuttiCapabilities, entry.app.manifest.version]
|
|
),
|
|
[[["managed-model-cli-v1"], "2.0.0"]]
|
|
);
|
|
});
|
|
|
|
test("capability-gated releases reject an additional version selector", async () => {
|
|
const release = await releaseFileForTest("capability-app", "2.0.0", {
|
|
hostCompatibility: {
|
|
requiredTuttiCapabilities: ["managed-model-cli-v1"]
|
|
}
|
|
});
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
buildTuttiAppVersions({
|
|
releaseFiles: [release],
|
|
minTuttiVersion: "0.12.0",
|
|
outputPath: path.join(tmpdir(), "capability-selector.json")
|
|
}),
|
|
/cannot declare both minTuttiVersion and requiredTuttiCapabilities/
|
|
);
|
|
});
|
|
|
|
test("capability declarations are canonical and immutable per release version", async () => {
|
|
const invalidManifest = manifestForTest("capability-app");
|
|
invalidManifest.hostCompatibility = {
|
|
requiredTuttiCapabilities: ["managed-model-cli-v1", "managed-model-cli-v1"]
|
|
};
|
|
assert.throws(
|
|
() => validateManifest(invalidManifest),
|
|
/must be sorted and unique/
|
|
);
|
|
|
|
const original = await releaseFileForTest("capability-app", "2.0.0", {
|
|
hostCompatibility: {
|
|
requiredTuttiCapabilities: ["managed-model-cli-v1"]
|
|
}
|
|
});
|
|
const changed = await releaseFileForTest("capability-app", "2.0.0", {
|
|
hostCompatibility: {
|
|
requiredTuttiCapabilities: ["future-host-capability-v1"]
|
|
}
|
|
});
|
|
const tempDir = await mkdtemp(path.join(tmpdir(), "tutti-capability-"));
|
|
const existing = path.join(tempDir, "versions.json");
|
|
await buildTuttiAppVersions({
|
|
releaseFiles: [original],
|
|
outputPath: existing
|
|
});
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
buildTuttiAppVersions({
|
|
existingVersionsPath: existing,
|
|
releaseFiles: [changed],
|
|
outputPath: path.join(tempDir, "changed.json")
|
|
}),
|
|
/different compatibility or release metadata/
|
|
);
|
|
});
|
|
|
|
test("buildTuttiAppCatalog emits legacy-safe apps and a compact compatibility frontier", async () => {
|
|
const releases = await Promise.all([
|
|
releaseFileForTest("frontier-app", "1.0.0"),
|
|
releaseFileForTest("frontier-app", "1.1.0", {
|
|
publishedAt: "2026-06-05T00:00:00Z"
|
|
}),
|
|
releaseFileForTest("frontier-app", "1.2.0", {
|
|
publishedAt: "2026-06-06T00:00:00Z"
|
|
})
|
|
]);
|
|
const tempDir = await mkdtemp(path.join(tmpdir(), "tutti-frontier-"));
|
|
const baseline = path.join(tempDir, "baseline.json");
|
|
const compatible = path.join(tempDir, "compatible.json");
|
|
const versions = path.join(tempDir, "versions.json");
|
|
await buildTuttiAppVersions({
|
|
releaseFiles: releases.slice(0, 2),
|
|
minTuttiVersion: "0.0.0",
|
|
outputPath: baseline
|
|
});
|
|
await buildTuttiAppVersions({
|
|
existingVersionsPath: baseline,
|
|
releaseFiles: [releases[2]],
|
|
minTuttiVersion: "0.12.0",
|
|
outputPath: compatible
|
|
});
|
|
await buildTuttiAppVersions({
|
|
existingVersionsPath: compatible,
|
|
setStatusVersion: "1.0.0",
|
|
status: "withdrawn",
|
|
outputPath: versions
|
|
});
|
|
|
|
const result = await buildTuttiAppCatalog({
|
|
versionsFiles: [versions],
|
|
outputPath: path.join(tempDir, "catalog.json")
|
|
});
|
|
assert.equal(result.catalog.apps[0].manifest.version, "1.1.0");
|
|
assert.deepEqual(
|
|
result.catalog.compatibility.apps["frontier-app"].map((entry) => [
|
|
entry.minTuttiVersion,
|
|
entry.app.manifest.version
|
|
]),
|
|
[
|
|
["0.0.0", "1.1.0"],
|
|
["0.12.0", "1.2.0"]
|
|
]
|
|
);
|
|
});
|
|
|
|
test("verifyTuttiAppReleaseArtifacts validates release artifact hash and size", async () => {
|
|
const tempDir = await mkdtemp(path.join(tmpdir(), "tutti-release-verify-"));
|
|
const artifactPath = path.join(tempDir, "app.zip");
|
|
const artifactBytes = Buffer.from("release artifact");
|
|
await writeFile(artifactPath, artifactBytes);
|
|
const releasePath = await releaseFileForTest("verified-app", "0.1.0", {
|
|
artifactUrl: pathToFileURL(artifactPath).href,
|
|
artifactSha256: createHash("sha256").update(artifactBytes).digest("hex"),
|
|
artifactSizeBytes: artifactBytes.length
|
|
});
|
|
|
|
const result = await verifyTuttiAppReleaseArtifacts({
|
|
releaseFiles: [releasePath]
|
|
});
|
|
|
|
assert.equal(result.checkedArtifactCount, 1);
|
|
});
|
|
|
|
test("verifyTuttiAppReleaseArtifacts rejects catalog artifact sha mismatches", async () => {
|
|
const tempDir = await mkdtemp(path.join(tmpdir(), "tutti-catalog-verify-"));
|
|
const artifactPath = path.join(tempDir, "app.zip");
|
|
const artifactBytes = Buffer.from("release artifact");
|
|
await writeFile(artifactPath, artifactBytes);
|
|
const releasePath = await releaseFileForTest("verified-app", "0.1.0", {
|
|
artifactUrl: pathToFileURL(artifactPath).href,
|
|
artifactSha256: createHash("sha256").update(artifactBytes).digest("hex"),
|
|
artifactSizeBytes: artifactBytes.length
|
|
});
|
|
const catalogPath = await catalogFileForTest([
|
|
catalogAppForTest("verified-app", "0.1.0", {
|
|
artifactUrl: pathToFileURL(artifactPath).href,
|
|
artifactSha256: "b".repeat(64)
|
|
})
|
|
]);
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
verifyTuttiAppReleaseArtifacts({
|
|
catalogFile: catalogPath,
|
|
releaseFiles: [releasePath]
|
|
}),
|
|
/artifactSha256 must match latest release metadata/
|
|
);
|
|
});
|
|
|
|
test("validateManifest rejects packages without manifest icon assets", () => {
|
|
assert.throws(
|
|
() =>
|
|
validateManifest({
|
|
schemaVersion: "tutti.app.manifest.v1",
|
|
appId: "bad-app",
|
|
version: "0.1.0",
|
|
name: "Bad App",
|
|
description: "Bad app",
|
|
runtime: {
|
|
bootstrap: "bootstrap.sh",
|
|
healthcheckPath: "/"
|
|
}
|
|
}),
|
|
/icon is required/
|
|
);
|
|
});
|
|
|
|
test("validateManifest accepts managed runtime manifests without launch metadata", () => {
|
|
assert.doesNotThrow(() => validateManifest(manifestForTest("managed-app")));
|
|
});
|
|
|
|
test("validateManifest accepts references list endpoints", () => {
|
|
const manifest = manifestForTest("references-app");
|
|
manifest.references = { listEndpoint: "/references/list" };
|
|
|
|
assert.doesNotThrow(() => validateManifest(manifest));
|
|
});
|
|
|
|
test("validateManifest accepts references search endpoints", () => {
|
|
const manifest = manifestForTest("references-app");
|
|
manifest.references = {
|
|
listEndpoint: "/references/list",
|
|
searchEndpoint: "/references/search"
|
|
};
|
|
|
|
assert.doesNotThrow(() => validateManifest(manifest));
|
|
});
|
|
|
|
test("validateManifest rejects unsupported references fields", () => {
|
|
const manifest = manifestForTest("references-app");
|
|
manifest.references = {
|
|
listEndpoint: "/references/list",
|
|
unknownEndpoint: "/references/unknown"
|
|
};
|
|
|
|
assert.throws(
|
|
() => validateManifest(manifest),
|
|
/references\.unknownEndpoint is unsupported/
|
|
);
|
|
});
|
|
|
|
test("validateManifest rejects invalid references list endpoints", () => {
|
|
for (const listEndpoint of [
|
|
"references/list",
|
|
"//example.test/references",
|
|
"https://example.test/references",
|
|
"/references?query=1",
|
|
"/references#section"
|
|
]) {
|
|
const manifest = manifestForTest("references-app");
|
|
manifest.references = { listEndpoint };
|
|
|
|
assert.throws(
|
|
() => validateManifest(manifest),
|
|
/references\.listEndpoint must be a relative URL path/
|
|
);
|
|
}
|
|
});
|
|
|
|
test("validateManifest rejects invalid references search endpoints", () => {
|
|
for (const searchEndpoint of [
|
|
"references/search",
|
|
"//example.test/references",
|
|
"https://example.test/references",
|
|
"/references?query=1",
|
|
"/references#section"
|
|
]) {
|
|
const manifest = manifestForTest("references-app");
|
|
manifest.references = { listEndpoint: "/references/list", searchEndpoint };
|
|
|
|
assert.throws(
|
|
() => validateManifest(manifest),
|
|
/references\.searchEndpoint must be a relative URL path/
|
|
);
|
|
}
|
|
});
|
|
|
|
test("validateCLIManifest accepts the app CLI HTTP bridge contract", () => {
|
|
assert.doesNotThrow(() =>
|
|
validateCLIManifest(cliManifestForTest(), "tutti.cli.json")
|
|
);
|
|
});
|
|
|
|
test("validateCLIManifest accepts wait execution without app-owned timeout input", () => {
|
|
const manifest = cliManifestForTest();
|
|
manifest.commands[0].path = ["runs", "wait"];
|
|
manifest.commands[0].execution = { mode: "wait" };
|
|
|
|
assert.doesNotThrow(() => validateCLIManifest(manifest, "tutti.cli.json"));
|
|
});
|
|
|
|
test("validateCLIManifest rejects app-owned timeout input on wait execution", () => {
|
|
const manifest = cliManifestForTest();
|
|
manifest.commands[0].execution = { mode: "wait" };
|
|
manifest.commands[0].inputSchema.properties["timeout-ms"] = {
|
|
type: "integer"
|
|
};
|
|
|
|
assert.throws(
|
|
() => validateCLIManifest(manifest, "tutti.cli.json"),
|
|
/reserves --timeout-ms for the total CLI wait timeout/
|
|
);
|
|
});
|
|
|
|
test("validateCLIManifest accepts typed input enum values", () => {
|
|
const manifest = cliManifestForTest();
|
|
manifest.commands[0].inputSchema.properties.mode = {
|
|
type: "string",
|
|
description: "Run mode",
|
|
enum: ["fast", "safe"],
|
|
default: "safe"
|
|
};
|
|
manifest.commands[0].inputSchema.properties.priority = {
|
|
type: "integer",
|
|
enum: [1, 2],
|
|
default: 1
|
|
};
|
|
manifest.commands[0].inputSchema.properties.enabled = {
|
|
type: "boolean",
|
|
enum: [true],
|
|
default: true
|
|
};
|
|
|
|
assert.doesNotThrow(() => validateCLIManifest(manifest, "tutti.cli.json"));
|
|
});
|
|
|
|
test("validateCLIManifest rejects enum values that do not match property type", () => {
|
|
const manifest = cliManifestForTest();
|
|
manifest.commands[0].inputSchema.properties.mode = {
|
|
type: "string",
|
|
enum: ["fast", 1]
|
|
};
|
|
|
|
assert.throws(
|
|
() => validateCLIManifest(manifest, "tutti.cli.json"),
|
|
/inputSchema\.properties\.mode\.enum\[1\] must be string/
|
|
);
|
|
});
|
|
|
|
test("validateCLIManifest rejects defaults outside declared enum values", () => {
|
|
const manifest = cliManifestForTest();
|
|
manifest.commands[0].inputSchema.properties.mode = {
|
|
type: "string",
|
|
enum: ["fast", "safe"],
|
|
default: "slow"
|
|
};
|
|
|
|
assert.throws(
|
|
() => validateCLIManifest(manifest, "tutti.cli.json"),
|
|
/inputSchema\.properties\.mode\.default must be one of the declared enum values/
|
|
);
|
|
});
|
|
|
|
test("validateCLIManifest rejects invalid command visibility", () => {
|
|
const manifest = cliManifestForTest();
|
|
manifest.commands[0].visibility = "private";
|
|
|
|
assert.throws(
|
|
() => validateCLIManifest(manifest, "tutti.cli.json"),
|
|
/visibility must be public or integration/
|
|
);
|
|
});
|
|
|
|
test("buildTuttiAppRelease validates declared CLI manifests", async () => {
|
|
const packageDir = await createPackageForTest("cli-app");
|
|
const manifest = manifestForTest("cli-app");
|
|
manifest.cli = { manifest: "tutti.cli.json" };
|
|
await writeFile(
|
|
path.join(packageDir, "tutti.app.json"),
|
|
`${JSON.stringify(manifest, null, 2)}\n`
|
|
);
|
|
await writeFile(
|
|
path.join(packageDir, "tutti.cli.json"),
|
|
`${JSON.stringify({ ...cliManifestForTest(), commands: [] }, null, 2)}\n`
|
|
);
|
|
const outputDir = path.join(
|
|
await mkdtemp(path.join(tmpdir(), "tutti-release-")),
|
|
"out"
|
|
);
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
buildTuttiAppRelease({
|
|
appId: "cli-app",
|
|
packageDir,
|
|
outputDir,
|
|
baseUrl: "https://cdn.example.test/tutti-apps/"
|
|
}),
|
|
/commands must be a non-empty array/
|
|
);
|
|
});
|
|
|
|
test("buildTuttiAppRelease rejects unsafe release path segments", async () => {
|
|
const packageDir = await createPackageForTest("vibe-design");
|
|
const outputDir = await mkdtemp(path.join(tmpdir(), "tutti-release-"));
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
buildTuttiAppRelease({
|
|
appId: "vibe-design",
|
|
packageDir,
|
|
outputDir,
|
|
baseUrl: "https://cdn.example.test/tutti-apps/",
|
|
version: "0.1.0/abc123"
|
|
}),
|
|
/version must use only/
|
|
);
|
|
});
|
|
|
|
test("Tutti app release workflow is reusable by external app repositories", async () => {
|
|
const workflow = await readFile(reusableWorkflowPath, "utf8");
|
|
|
|
assert.match(workflow, /workflow_call:/);
|
|
assert.match(workflow, /release_tag_prefix:/);
|
|
assert.match(workflow, /release_bump:/);
|
|
assert.match(workflow, /min_tutti_version:/);
|
|
assert.match(workflow, /create_release_tag:/);
|
|
assert.match(workflow, /publish_catalog:/);
|
|
assert.match(workflow, /catalog_only:/);
|
|
assert.match(workflow, /prepare_only:/);
|
|
assert.match(workflow, /candidate_run_id:/);
|
|
assert.match(workflow, /expected_version:/);
|
|
assert.match(workflow, /expected_artifact_sha256:/);
|
|
assert.match(workflow, /expected_git_sha:/);
|
|
assert.match(workflow, /catalog_cloudfront_distribution_id:/);
|
|
assert.match(workflow, /Validate release inputs/);
|
|
assert.match(
|
|
workflow,
|
|
/package_command is required unless catalog_only is true/
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/release_assets_base_url is required unless catalog_only is true/
|
|
);
|
|
assert.match(workflow, /min_tutti_version must be valid SemVer/);
|
|
assert.doesNotMatch(
|
|
workflow,
|
|
/min_tutti_version is required unless catalog_only is true/
|
|
);
|
|
assert.match(workflow, /concurrency:/);
|
|
assert.match(workflow, /tutti-app-catalog-\{0\}-\{1\}/);
|
|
assert.match(workflow, /tutti-app-release-\{0\}-\{1\}/);
|
|
assert.match(workflow, /cancel-in-progress: false/);
|
|
assert.match(workflow, /\[skip release\]/);
|
|
assert.match(workflow, /release_tools_package:/);
|
|
assert.match(workflow, /default:\s+"@tutti-os\/app-release-tools@latest"/);
|
|
assert.doesNotMatch(workflow, /auto_bump_version:/);
|
|
assert.doesNotMatch(workflow, /version_bump:/);
|
|
assert.doesNotMatch(workflow, /version_manifest_path:/);
|
|
assert.doesNotMatch(workflow, /bump-tutti-app-version/);
|
|
assert.doesNotMatch(workflow, /release_version:/);
|
|
assert.doesNotMatch(workflow, /INPUT_RELEASE_VERSION/);
|
|
assert.doesNotMatch(workflow, /Commit app version bump/);
|
|
assert.doesNotMatch(workflow, /git push origin "HEAD:\$\{GITHUB_REF_NAME\}"/);
|
|
assert.doesNotMatch(workflow, /REF_TYPE: \$\{\{ github\.ref_type \}\}/);
|
|
assert.doesNotMatch(workflow, /github\.ref_type/);
|
|
assert.match(workflow, /`\$\{process\.env\.APP_ID\}-v`/);
|
|
assert.match(workflow, /git", \["fetch", "--tags", "--force"\]/);
|
|
assert.match(workflow, /parseStableVersion/);
|
|
assert.match(workflow, /latestVersionSeed/);
|
|
assert.match(workflow, /nextVersionFromSources/);
|
|
assert.match(workflow, /manifest\.version/);
|
|
assert.match(workflow, /Package manifest version must be stable semver/);
|
|
assert.match(
|
|
workflow,
|
|
/Resolved release version seed \$\{latest\.version\} from \$\{latest\.source\}/
|
|
);
|
|
assert.match(workflow, /release_bump requires create_release_tag/);
|
|
assert.match(workflow, /\[ "\$\{PREPARE_ONLY\}" != "true" \]/);
|
|
assert.match(workflow, /create_release_tag requires release_bump/);
|
|
assert.match(
|
|
workflow,
|
|
/const seed = latestVersionSeed\(tagPrefix, manifest\.version, false\)/
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/releaseVersion = `\$\{seed\.version\}\+\$\{gitSha\.slice\(0, 12\)\}`/
|
|
);
|
|
assert.match(workflow, /name: Create release tag/);
|
|
assert.match(workflow, /git tag -a "\$\{RELEASE_TAG_NAME\}"/);
|
|
assert.match(
|
|
workflow,
|
|
/git push origin "refs\/tags\/\$\{RELEASE_TAG_NAME\}"/
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/pnpm --package "\$\{RELEASE_TOOLS_PACKAGE\}" dlx build-tutti-app-release/
|
|
);
|
|
assert.doesNotMatch(workflow, /Checkout Tutti release tools/);
|
|
assert.match(
|
|
workflow,
|
|
/aws s3 sync "tutti-app-release\/apps\/\$\{APP_ID\}\/\$\{RELEASE_VERSION\}\/"/
|
|
);
|
|
assert.doesNotMatch(
|
|
workflow,
|
|
/aws s3 cp "tutti-app-release\/apps\/\$\{APP_ID\}\/latest\.json"/
|
|
);
|
|
assert.match(workflow, /aws s3api head-object/);
|
|
assert.match(workflow, /matching immutable metadata/);
|
|
assert.match(workflow, /different immutable metadata/);
|
|
assert.match(workflow, /Repairing mutable latest\/catalog state/);
|
|
assert.match(workflow, /const comparedKeys = \[/);
|
|
assert.match(workflow, /Verify published app release artifact/);
|
|
assert.match(workflow, /verify-tutti-app-release-artifacts/);
|
|
assert.match(
|
|
workflow,
|
|
/--release-file "tutti-app-release\/apps\/\$\{APP_ID\}\/latest\.json"/
|
|
);
|
|
assert.match(workflow, /Publish app release metadata/);
|
|
assert.match(workflow, /publish-tutti-app-metadata/);
|
|
assert.match(workflow, /name: Upload production release candidate/);
|
|
assert.match(workflow, /uses: actions\/upload-artifact@v4/);
|
|
assert.match(workflow, /uses: actions\/download-artifact@v5/);
|
|
assert.match(workflow, /run-id: \$\{\{ inputs\.candidate_run_id \}\}/);
|
|
assert.match(workflow, /Approved release candidate mismatch/);
|
|
assert.match(
|
|
workflow,
|
|
/candidate\.artifactSha256 !== expectedArtifactSha256/
|
|
);
|
|
assert.match(workflow, /candidate\.gitSha !== expectedGitSha/);
|
|
assert.match(
|
|
workflow,
|
|
/catalog_only and prepare_only are mutually exclusive/
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/if: \$\{\{ inputs\.prepare_only \}\}[\s\S]*path: tutti-app-release/
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/name: Configure AWS credentials\n\s+if: \$\{\{ !inputs\.prepare_only \}\}/
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/name: Upload app release to S3\n\s+if: \$\{\{ !inputs\.catalog_only && !inputs\.prepare_only \}\}/
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/name: Publish app release metadata\n\s+if: \$\{\{ !inputs\.prepare_only \}\}/
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/name: Verify published app metadata\n\s+if: \$\{\{ !inputs\.prepare_only \}\}/
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/name: Create release tag\n\s+if: \$\{\{ !inputs\.catalog_only && !inputs\.prepare_only && inputs\.create_release_tag \}\}/
|
|
);
|
|
assert.match(
|
|
workflow,
|
|
/name: Invalidate app catalog\n\s+if: \$\{\{ !inputs\.prepare_only && \(inputs\.publish_catalog \|\| inputs\.catalog_only\)/
|
|
);
|
|
const verifyCandidateIndex = workflow.indexOf(
|
|
"name: Verify local app release candidate"
|
|
);
|
|
const uploadCandidateIndex = workflow.indexOf(
|
|
"name: Upload production release candidate"
|
|
);
|
|
const configureAwsIndex = workflow.indexOf("name: Configure AWS credentials");
|
|
const identityCheckIndex = workflow.indexOf(
|
|
"Approved release candidate mismatch"
|
|
);
|
|
assert.notEqual(verifyCandidateIndex, -1);
|
|
assert.notEqual(uploadCandidateIndex, -1);
|
|
assert.notEqual(configureAwsIndex, -1);
|
|
assert.notEqual(identityCheckIndex, -1);
|
|
assert.ok(
|
|
verifyCandidateIndex < uploadCandidateIndex,
|
|
"candidate verification must precede candidate artifact upload"
|
|
);
|
|
assert.ok(
|
|
verifyCandidateIndex < configureAwsIndex,
|
|
"candidate verification must precede AWS credentials and cloud mutation"
|
|
);
|
|
assert.ok(
|
|
identityCheckIndex < configureAwsIndex,
|
|
"approved candidate identity checks must precede AWS credentials"
|
|
);
|
|
const uploadToS3Index = workflow.indexOf("name: Upload app release to S3");
|
|
const verifyPublishedIndex = workflow.indexOf(
|
|
"name: Verify published app release artifact"
|
|
);
|
|
assert.notEqual(uploadToS3Index, -1);
|
|
assert.notEqual(verifyPublishedIndex, -1);
|
|
assert.ok(
|
|
uploadToS3Index < verifyPublishedIndex,
|
|
"remote artifact verification must run after the immutable upload"
|
|
);
|
|
assert.match(workflow, /CATALOG_ONLY:/);
|
|
assert.match(workflow, /--min-tutti-version "\$\{MIN_TUTTI_VERSION\}"/);
|
|
assert.doesNotMatch(workflow, /required_tutti_capabilities:/);
|
|
assert.match(workflow, /--versions-file tutti-app-metadata\/versions\.json/);
|
|
assert.doesNotMatch(workflow, /aws s3 cp tutti-app-catalog\/catalog\.json/);
|
|
assert.match(workflow, /Invalidate app catalog/);
|
|
assert.match(workflow, /cloudfront create-invalidation/);
|
|
});
|
|
|
|
test("production candidate verification hashes the local archive before cloud access", async () => {
|
|
const workflow = await readFile(reusableWorkflowPath, "utf8");
|
|
const script = extractWorkflowNodeScript(
|
|
workflow,
|
|
"Verify local app release candidate"
|
|
);
|
|
const root = await mkdtemp(path.join(tmpdir(), "tutti-local-candidate-"));
|
|
const appId = "candidate-test";
|
|
const version = "1.2.3";
|
|
const releaseDir = path.join(
|
|
root,
|
|
"tutti-app-release",
|
|
"apps",
|
|
appId,
|
|
version
|
|
);
|
|
const latestDir = path.dirname(releaseDir);
|
|
const artifactName = `${appId}-${version}.zip`;
|
|
const artifact = Buffer.from("approved candidate archive", "utf8");
|
|
const release = {
|
|
appId,
|
|
version,
|
|
artifactUrl: `https://example.invalid/tutti-app-releases/apps/${appId}/${version}/${artifactName}`,
|
|
artifactSha256: createHash("sha256").update(artifact).digest("hex"),
|
|
artifactSizeBytes: artifact.length
|
|
};
|
|
await mkdir(releaseDir, { recursive: true });
|
|
await writeFile(path.join(releaseDir, artifactName), artifact);
|
|
await writeFile(
|
|
path.join(releaseDir, "release.json"),
|
|
`${JSON.stringify(release)}\n`
|
|
);
|
|
await writeFile(
|
|
path.join(latestDir, "latest.json"),
|
|
`${JSON.stringify(release)}\n`
|
|
);
|
|
|
|
const verified = runWorkflowNodeScript(script, root, { appId, version });
|
|
assert.equal(verified.status, 0, verified.stderr);
|
|
assert.match(verified.stdout, /Verified local release candidate/);
|
|
|
|
await writeFile(path.join(releaseDir, artifactName), "tampered archive");
|
|
const rejected = runWorkflowNodeScript(script, root, { appId, version });
|
|
assert.notEqual(rejected.status, 0);
|
|
assert.match(rejected.stderr, /local release artifact sha256 mismatch/);
|
|
});
|
|
|
|
function extractWorkflowNodeScript(workflow, stepName) {
|
|
const normalized = workflow.replaceAll("\r\n", "\n");
|
|
const stepIndex = normalized.indexOf(`name: ${stepName}`);
|
|
assert.notEqual(stepIndex, -1, `missing workflow step ${stepName}`);
|
|
const heredocIndex = normalized.indexOf("node <<'NODE'\n", stepIndex);
|
|
assert.notEqual(heredocIndex, -1, `missing Node heredoc for ${stepName}`);
|
|
const scriptStart = heredocIndex + "node <<'NODE'\n".length;
|
|
const scriptEnd = normalized.indexOf("\n NODE", scriptStart);
|
|
assert.notEqual(scriptEnd, -1, `unterminated Node heredoc for ${stepName}`);
|
|
return normalized
|
|
.slice(scriptStart, scriptEnd)
|
|
.split("\n")
|
|
.map((line) => line.replace(/^ /u, ""))
|
|
.join("\n");
|
|
}
|
|
|
|
function runWorkflowNodeScript(script, cwd, { appId, version }) {
|
|
return spawnSync(process.execPath, ["-"], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
APP_ID: appId,
|
|
RELEASE_VERSION: version
|
|
},
|
|
input: script
|
|
});
|
|
}
|
|
|
|
test("Tutti app catalog workflow aggregates version indexes", async () => {
|
|
const workflow = await readFile(catalogWorkflowPath, "utf8");
|
|
|
|
assert.match(workflow, /workflow_dispatch:/);
|
|
assert.match(workflow, /catalog_mode:/);
|
|
assert.match(workflow, /default:\s*merge/);
|
|
assert.doesNotMatch(workflow, /app_ids_preset/);
|
|
assert.doesNotMatch(workflow, /APP_IDS_PRESET/);
|
|
assert.match(workflow, /aws s3api head-object/);
|
|
assert.match(workflow, /Refusing to publish a partial merge catalog/);
|
|
assertCatalogWorkflowRefreshesExistingAppLatestMetadata(workflow);
|
|
assert.match(workflow, /publish-tutti-app-catalog/);
|
|
assert.match(workflow, /app-ids\.txt/);
|
|
assert.match(workflow, /Verify app catalog artifacts/);
|
|
assertAwsValidationBeforeConfigure(workflow, [
|
|
"AWS_REGION_VALUE",
|
|
"AWS_ROLE_ARN_VALUE",
|
|
"S3_BUCKET_VALUE"
|
|
]);
|
|
assert.match(workflow, /verify-tutti-app-release-artifacts/);
|
|
assert.match(workflow, /--catalog-file tutti-app-catalog\/catalog\.json/);
|
|
assert.doesNotMatch(workflow, /aws s3 cp tutti-app-catalog\/catalog\.json/);
|
|
assert.match(workflow, /cloudfront create-invalidation/);
|
|
});
|
|
|
|
test("publishTuttiAppCatalog verifies before uploading catalog metadata", async () => {
|
|
const source = await readFile(publishCatalogToolPath, "utf8");
|
|
const verifyIndex = source.indexOf("await verifyTuttiAppReleaseArtifacts({");
|
|
const uploadIndex = source.indexOf("putObject({");
|
|
assert.notEqual(verifyIndex, -1);
|
|
assert.notEqual(uploadIndex, -1);
|
|
assert.ok(
|
|
verifyIndex < uploadIndex,
|
|
"catalog verification must precede upload"
|
|
);
|
|
});
|
|
|
|
test("Tutti app staging catalog workflow uses an isolated prefix", async () => {
|
|
const workflow = await readFile(stagingCatalogWorkflowPath, "utf8");
|
|
|
|
assert.match(workflow, /workflow_dispatch:/);
|
|
assert.match(workflow, /TUTTI_APP_RELEASES_STAGING_S3_PREFIX/);
|
|
assert.match(workflow, /tutti-app-releases-staging/);
|
|
assert.match(workflow, /catalog_mode:/);
|
|
assert.doesNotMatch(workflow, /app_ids_preset/);
|
|
assert.doesNotMatch(workflow, /APP_IDS_PRESET/);
|
|
assert.match(workflow, /aws s3api head-object/);
|
|
assert.match(workflow, /Refusing to publish a partial merge catalog/);
|
|
assertCatalogWorkflowRefreshesExistingAppLatestMetadata(workflow);
|
|
assert.match(workflow, /publish-tutti-app-catalog/);
|
|
assert.match(workflow, /app-ids\.txt/);
|
|
assert.match(workflow, /Verify app catalog artifacts/);
|
|
assertAwsValidationBeforeConfigure(workflow, [
|
|
"AWS_REGION_VALUE",
|
|
"AWS_ROLE_ARN_VALUE",
|
|
"S3_BUCKET_VALUE"
|
|
]);
|
|
assert.match(workflow, /verify-tutti-app-release-artifacts/);
|
|
assert.match(workflow, /--catalog-file tutti-app-catalog\/catalog\.json/);
|
|
});
|
|
|
|
test("Tutti app staging release template requires an explicit staging asset base URL", async () => {
|
|
const reference = await readFile(releaseReferencePath, "utf8");
|
|
|
|
assert.match(reference, /TUTTI_APP_RELEASES_STAGING_BASE_URL/);
|
|
assert.match(reference, /push:\n\s+branches:\n\s+- main/);
|
|
assert.ok(
|
|
reference.includes(
|
|
"release_assets_base_url: ${{ vars.TUTTI_APP_RELEASES_STAGING_BASE_URL }}"
|
|
)
|
|
);
|
|
assert.ok(
|
|
reference.includes(
|
|
"publish_catalog: ${{ github.event_name == 'push' || inputs.publish_catalog }}"
|
|
)
|
|
);
|
|
assert.ok(
|
|
reference.includes(
|
|
"catalog_only: ${{ github.event_name == 'workflow_dispatch' && inputs.catalog_only }}"
|
|
)
|
|
);
|
|
assert.doesNotMatch(
|
|
reference,
|
|
/TUTTI_APP_RELEASES_STAGING_BASE_URL \|\| vars\.TUTTI_APP_RELEASES_BASE_URL/
|
|
);
|
|
assert.match(
|
|
reference,
|
|
/private repositories that cannot read organization variables/
|
|
);
|
|
});
|
|
|
|
function assertCatalogWorkflowRefreshesExistingAppLatestMetadata(workflow) {
|
|
assert.match(
|
|
workflow,
|
|
/\[ -z "\$\{app_ids_value\}" \] && \[ "\$\{CATALOG_MODE\}" = "merge" \]/
|
|
);
|
|
assert.match(workflow, /existing-catalog\.json/);
|
|
assert.match(workflow, /manifest\??\.appId/);
|
|
assert.match(workflow, /compatibility\?\.capabilityApps/);
|
|
assert.match(workflow, /app_ids_value="\$\{existing_app_ids\}"/);
|
|
}
|
|
|
|
function assertAwsValidationBeforeConfigure(workflow, names) {
|
|
const validationIndex = workflow.indexOf("name: Validate AWS configuration");
|
|
const configureIndex = workflow.indexOf("name: Configure AWS credentials");
|
|
assert.notEqual(validationIndex, -1, "workflow should validate AWS config");
|
|
assert.notEqual(configureIndex, -1, "workflow should configure AWS");
|
|
assert.ok(
|
|
validationIndex < configureIndex,
|
|
"AWS validation must run before credentials configuration"
|
|
);
|
|
for (const name of names) {
|
|
assert.match(workflow, new RegExp(`for name in[\\s\\S]*${name}`));
|
|
}
|
|
assert.match(workflow, /Missing required AWS configuration/);
|
|
}
|
|
|
|
async function releaseFileForTest(appId, version = "0.1.0", overrides = {}) {
|
|
const tempDir = await mkdtemp(path.join(tmpdir(), "tutti-release-file-"));
|
|
const release = {
|
|
schemaVersion: "tutti.app.release.v1",
|
|
appId,
|
|
version,
|
|
name: appId,
|
|
description: `${appId} description`,
|
|
manifest: {
|
|
...manifestForTest(appId, version),
|
|
...(overrides.hostCompatibility
|
|
? { hostCompatibility: overrides.hostCompatibility }
|
|
: {})
|
|
},
|
|
...(overrides.localizations
|
|
? { localizations: overrides.localizations }
|
|
: {}),
|
|
artifactUrl:
|
|
overrides.artifactUrl ??
|
|
`https://cdn.example.test/apps/${appId}/${appId}.zip`,
|
|
artifactSha256: overrides.artifactSha256 ?? "a".repeat(64),
|
|
artifactSizeBytes: overrides.artifactSizeBytes ?? 123,
|
|
iconUrl: `https://cdn.example.test/apps/${appId}/icon.svg`,
|
|
publishedAt: overrides.publishedAt ?? "2026-06-04T00:00:00Z",
|
|
gitSha: "abc123"
|
|
};
|
|
const releasePath = path.join(tempDir, "release.json");
|
|
await writeFile(releasePath, `${JSON.stringify(release, null, 2)}\n`);
|
|
return releasePath;
|
|
}
|
|
|
|
async function catalogFileForTest(apps) {
|
|
const tempDir = await mkdtemp(path.join(tmpdir(), "tutti-catalog-file-"));
|
|
const catalogPath = path.join(tempDir, "catalog.json");
|
|
await writeFile(
|
|
catalogPath,
|
|
`${JSON.stringify(
|
|
{
|
|
schemaVersion: "tutti.app.catalog.v1",
|
|
apps
|
|
},
|
|
null,
|
|
2
|
|
)}\n`
|
|
);
|
|
return catalogPath;
|
|
}
|
|
|
|
function catalogAppForTest(appId, version, overrides = {}) {
|
|
return {
|
|
manifest: manifestForTest(appId, version),
|
|
distribution: {
|
|
kind: "remote",
|
|
artifactUrl:
|
|
overrides.artifactUrl ??
|
|
`https://cdn.example.test/apps/${appId}/${appId}.zip`,
|
|
artifactSha256: overrides.artifactSha256 ?? "b".repeat(64),
|
|
iconUrl: `https://cdn.example.test/apps/${appId}/icon.svg`
|
|
}
|
|
};
|
|
}
|
|
|
|
async function createPackageForTest(appId, options = {}) {
|
|
const packageDir = await mkdtemp(path.join(tmpdir(), "tutti-app-package-"));
|
|
await mkdir(path.join(packageDir, "web"), { recursive: true });
|
|
const manifest = manifestForTest(appId);
|
|
if (options.localizations?.length > 0) {
|
|
manifest.localizationInfo = {
|
|
defaultLocale: "en",
|
|
additionalLocales: options.localizations.map((localization) => ({
|
|
locale: localization.locale,
|
|
file: `locales/${localization.locale}/manifest.json`
|
|
}))
|
|
};
|
|
}
|
|
await writeFile(
|
|
path.join(packageDir, "tutti.app.json"),
|
|
`${JSON.stringify(manifest, null, 2)}\n`
|
|
);
|
|
for (const localization of options.localizations ?? []) {
|
|
const localeDir = path.join(packageDir, "locales", localization.locale);
|
|
await mkdir(localeDir, { recursive: true });
|
|
await writeFile(
|
|
path.join(localeDir, "manifest.json"),
|
|
`${JSON.stringify(localization.metadata, null, 2)}\n`
|
|
);
|
|
}
|
|
await writeFile(path.join(packageDir, "AGENTS.md"), "App instructions\n");
|
|
await writeFile(path.join(packageDir, "icon.svg"), `<${"svg"}></${"svg"}>\n`);
|
|
await writeFile(path.join(packageDir, "web", "index.html"), "<div></div>\n");
|
|
const bootstrapPath = path.join(packageDir, "bootstrap.sh");
|
|
await writeFile(bootstrapPath, "#!/usr/bin/env bash\nexit 0\n");
|
|
await chmod(bootstrapPath, 0o755);
|
|
return packageDir;
|
|
}
|
|
|
|
function manifestForTest(appId, version = "0.1.0") {
|
|
return {
|
|
schemaVersion: "tutti.app.manifest.v1",
|
|
appId,
|
|
version,
|
|
name: appId,
|
|
description: `${appId} description`,
|
|
icon: {
|
|
type: "asset",
|
|
src: "icon.svg"
|
|
},
|
|
runtime: {
|
|
bootstrap: "bootstrap.sh",
|
|
healthcheckPath: "/"
|
|
},
|
|
authors: [
|
|
{
|
|
name: "Tutti Developer",
|
|
avatarUrl: "https://github.com/tutti-os.png",
|
|
url: "https://github.com/tutti-os"
|
|
}
|
|
],
|
|
source: {
|
|
type: "github",
|
|
url: `https://github.com/tutti-os/${appId}`
|
|
}
|
|
};
|
|
}
|
|
|
|
function cliManifestForTest() {
|
|
return {
|
|
schemaVersion: "tutti.app.cli.v1",
|
|
scope: "automation",
|
|
commands: [
|
|
{
|
|
path: ["run"],
|
|
summary: "Run automation",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
"dry-run": { type: "boolean" }
|
|
},
|
|
required: ["name"]
|
|
},
|
|
output: {
|
|
defaultMode: "json",
|
|
json: true
|
|
},
|
|
handler: {
|
|
kind: "http",
|
|
method: "POST",
|
|
path: "/tutti/cli/run",
|
|
timeoutMs: 30000
|
|
}
|
|
}
|
|
]
|
|
};
|
|
}
|