7cc5c30e71
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
444 lines
21 KiB
TypeScript
444 lines
21 KiB
TypeScript
/**
|
|
* Integration tests for the Skill routes: library catalog structure (any logged-in user), member
|
|
* install/uninstall with 404 for outsiders, 404 for unknown skills, installed
|
|
* files matching the library content, idempotent update on reinstall, the
|
|
* directory disappearing after uninstall, default_agent starting with all
|
|
* skills installed while a newly created plain Agent has none, and the zip
|
|
* archive install/export (layouts, zip-slip and limit rejections, 409
|
|
* skill_exists + overwrite replace, byte-identical export round-trip).
|
|
*/
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { strToU8, unzipSync, zipSync } from "fflate";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { skillsDir } from "@prismshadow/penguin-core";
|
|
import { librarySkill, loadLibrarySkills } from "@prismshadow/penguin-skills";
|
|
import type {
|
|
AgentSkillsResponse,
|
|
ProjectCreateResponse,
|
|
SkillLibraryResponse,
|
|
} from "../src/api/types.js";
|
|
import { apiClient, createTestApp, provisionUser } from "./helpers.js";
|
|
import type { TestApp } from "./helpers.js";
|
|
|
|
describe("skills api", () => {
|
|
let t: TestApp;
|
|
let owner: ReturnType<typeof apiClient>;
|
|
let member: ReturnType<typeof apiClient>;
|
|
let outsider: ReturnType<typeof apiClient>;
|
|
let projectId: string;
|
|
const base = (agentId: string) => `/api/projects/${projectId}/agents/${agentId}/skills`;
|
|
|
|
beforeEach(async () => {
|
|
t = await createTestApp();
|
|
const a = await provisionUser(t.app, "owner_s");
|
|
const b = await provisionUser(t.app, "member_s");
|
|
const c = await provisionUser(t.app, "outsider_s");
|
|
owner = apiClient(t.app, a.cookie);
|
|
member = apiClient(t.app, b.cookie);
|
|
outsider = apiClient(t.app, c.cookie);
|
|
const created = (await (
|
|
await owner.post("/api/projects", { projectId: "owner_s-skills", name: "skills project" })
|
|
).json()) as ProjectCreateResponse;
|
|
projectId = created.project.projectId;
|
|
expect(
|
|
(await owner.post(`/api/projects/${projectId}/members`, { userId: "member_s" })).status,
|
|
).toBe(201);
|
|
});
|
|
afterEach(async () => {
|
|
await t.cleanup();
|
|
});
|
|
|
|
/** Creates a plain Agent with no Skills preinstalled. */
|
|
async function createPlainAgent(agentId: string): Promise<void> {
|
|
const res = await owner.post(`/api/projects/${projectId}/agents`, { agentId });
|
|
expect(res.status).toBe(201);
|
|
}
|
|
|
|
it("GET /api/skills: groups with metadata, short descriptions, and icons, without sending bodies", async () => {
|
|
const res = await member.get("/api/skills");
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as SkillLibraryResponse;
|
|
expect(body.groups.map((g) => g.id)).toEqual([
|
|
"office-productivity",
|
|
"software-development",
|
|
"ai-app-development",
|
|
"agent-tuning",
|
|
]);
|
|
for (const group of body.groups) {
|
|
expect(group.title.length).toBeGreaterThan(0);
|
|
// The Chinese group title is passed through from the skills package (the UI
|
|
// picks a language); groups no longer carry a description.
|
|
expect(group.titleZh).toBeTruthy();
|
|
expect("description" in group).toBe(false);
|
|
}
|
|
// Members within a group follow the SKILL_GROUPS list order (as ungrouped by loadSkillGroups).
|
|
expect(body.groups[0]!.skills.map((s) => s.name)).toEqual([
|
|
"data-analysis",
|
|
"firecrawl",
|
|
"bento-slides",
|
|
]);
|
|
expect(body.groups[1]!.skills.map((s) => s.name)).toEqual([
|
|
"web-design",
|
|
"software-engineering",
|
|
]);
|
|
expect(body.groups[2]!.skills.map((s) => s.name)).toEqual([
|
|
"penguin-sdk",
|
|
"penguin-cli",
|
|
"agenthub-models",
|
|
"vllm",
|
|
"ollama",
|
|
"llamafactory",
|
|
"skill-porting",
|
|
]);
|
|
expect(body.groups[3]!.skills.map((s) => s.name)).toEqual([
|
|
"agent-creation",
|
|
"benchmark-design",
|
|
"agent-evaluation",
|
|
"agent-optimization",
|
|
]);
|
|
const skills = body.groups.flatMap((g) => g.skills);
|
|
for (const skill of skills) {
|
|
expect(skill.name.length).toBeGreaterThan(0);
|
|
expect(skill.description.length).toBeGreaterThan(0);
|
|
// The short description (preferred in compact spots like cards) and custom
|
|
// icon (raw icon.svg) are passed through conditionally for every returned skill.
|
|
expect(skill.shortDescription, skill.name).toBeTruthy();
|
|
expect(skill.shortDescriptionZh, skill.name).toBeTruthy();
|
|
expect(skill.icon, skill.name).toContain("<svg");
|
|
expect(skill.icon).not.toContain("<script");
|
|
expect(skill.version).toBeGreaterThanOrEqual(1);
|
|
expect(skill.updated).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/);
|
|
// The library catalog sends only metadata: the SKILL.md body is written to disk on install and read by the model on demand.
|
|
expect("content" in skill).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("members can install and uninstall; installs land verbatim on disk, the directory disappears after uninstall", async () => {
|
|
await createPlainAgent("bare_agent");
|
|
const url = base("bare_agent");
|
|
|
|
// Member installs two Skills: 201 returns the updated list (sorted by name).
|
|
const res = await member.post(url, { names: ["penguin-sdk", "agent-creation"] });
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as AgentSkillsResponse;
|
|
expect(body.skills.map((s) => s.name)).toEqual(["agent-creation", "penguin-sdk"]);
|
|
// The installed list likewise passes through the short description and icon
|
|
// (icon.svg is copied on install, identical to the library's original).
|
|
const installed = body.skills.find((s) => s.name === "penguin-sdk")!;
|
|
expect(installed.shortDescription).toBeTruthy();
|
|
expect(installed.icon).toBe(librarySkill("penguin-sdk")!.icon);
|
|
|
|
// The on-disk content matches the library's SKILL.md verbatim (including
|
|
// frontmatter), and icon.svg is written alongside it.
|
|
const skillFile = (name: string) =>
|
|
path.join(skillsDir(t.root, projectId, "bare_agent"), name, "SKILL.md");
|
|
expect(await fs.readFile(skillFile("penguin-sdk"), "utf8")).toBe(
|
|
librarySkill("penguin-sdk")!.content,
|
|
);
|
|
expect(
|
|
await fs.readFile(
|
|
path.join(skillsDir(t.root, projectId, "bare_agent"), "penguin-sdk", "icon.svg"),
|
|
"utf8",
|
|
),
|
|
).toBe(librarySkill("penguin-sdk")!.icon);
|
|
|
|
// Member uninstalls: 204, the whole skills/<name>/ directory disappears, and the list is updated.
|
|
expect((await member.delete(`${url}/penguin-sdk`)).status).toBe(204);
|
|
await expect(fs.access(path.dirname(skillFile("penguin-sdk")))).rejects.toThrow();
|
|
const after = (await (await member.get(url)).json()) as AgentSkillsResponse;
|
|
expect(after.skills.map((s) => s.name)).toEqual(["agent-creation"]);
|
|
|
|
// Deleting a Skill that isn't installed (or was already uninstalled) → 404.
|
|
expect((await member.delete(`${url}/penguin-sdk`)).status).toBe(404);
|
|
});
|
|
|
|
it("reinstall is an idempotent update: hand-edited on-disk content is restored to the library content", async () => {
|
|
await createPlainAgent("update_agent");
|
|
const url = base("update_agent");
|
|
expect((await owner.post(url, { names: ["penguin-cli"] })).status).toBe(201);
|
|
|
|
// Simulate stale/tampered on-disk content.
|
|
const file = path.join(skillsDir(t.root, projectId, "update_agent"), "penguin-cli", "SKILL.md");
|
|
await fs.writeFile(file, "---\nname: penguin-cli\nversion: 0\n---\nstale\n", "utf8");
|
|
|
|
const res = await owner.post(url, { names: ["penguin-cli"] });
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as AgentSkillsResponse;
|
|
expect(body.skills.map((s) => s.name)).toEqual(["penguin-cli"]);
|
|
expect(await fs.readFile(file, "utf8")).toBe(librarySkill("penguin-cli")!.content);
|
|
});
|
|
|
|
it("unknown skill 404 unknown_skill, with no half-installed state", async () => {
|
|
await createPlainAgent("strict_agent");
|
|
const url = base("strict_agent");
|
|
const res = await owner.post(url, { names: ["penguin-sdk", "no-such-skill"] });
|
|
expect(res.status).toBe(404);
|
|
const err = (await res.json()) as { error: { code: string; message: string } };
|
|
expect(err.error.code).toBe("unknown_skill");
|
|
expect(err.error.message).toContain("no-such-skill");
|
|
// Whole request rejected: even the valid library skill was not written to disk.
|
|
const list = (await (await owner.get(url)).json()) as AgentSkillsResponse;
|
|
expect(list.skills).toEqual([]);
|
|
});
|
|
|
|
it("request body validation 400: names missing / empty array / non-string entries", async () => {
|
|
await createPlainAgent("valid_agent");
|
|
const url = base("valid_agent");
|
|
for (const body of [{}, { names: [] }, { names: ["penguin-sdk", 1] }, { names: [""] }]) {
|
|
expect((await owner.post(url, body)).status, JSON.stringify(body)).toBe(400);
|
|
}
|
|
});
|
|
|
|
it("outsiders always get 404 (read, install, uninstall); a missing Agent is 404", async () => {
|
|
const url = base("default_agent");
|
|
expect((await outsider.get(url)).status).toBe(404);
|
|
expect((await outsider.post(url, { names: ["penguin-sdk"] })).status).toBe(404);
|
|
expect((await outsider.post(`${url}/archive`, { dataBase64: "AAAA" })).status).toBe(404);
|
|
expect((await outsider.get(`${url}/penguin-sdk/archive`)).status).toBe(404);
|
|
expect((await outsider.delete(`${url}/penguin-sdk`)).status).toBe(404);
|
|
// The library catalog isn't scoped under a Project prefix: any logged-in user can read it.
|
|
expect((await outsider.get("/api/skills")).status).toBe(200);
|
|
// Agent doesn't exist: even a member gets 404.
|
|
expect((await member.get(base("no_such_agent"))).status).toBe(404);
|
|
});
|
|
|
|
it("default_agent starts with every library skill installed; a new plain Agent has none", async () => {
|
|
const res = await member.get(base("default_agent"));
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as AgentSkillsResponse;
|
|
// loadLibrarySkills itself sorts by name, matching the installed-list ordering.
|
|
expect(body.skills.map((s) => s.name)).toEqual(loadLibrarySkills().map((s) => s.name));
|
|
// The installed list likewise passes through the Chinese description and the
|
|
// short description/icon (listInstalledSkills parses these from the on-disk
|
|
// frontmatter and icon.svg).
|
|
for (const skill of body.skills) {
|
|
expect(skill.shortDescription, skill.name).toBeTruthy();
|
|
expect(skill.shortDescriptionZh, skill.name).toBeTruthy();
|
|
expect(skill.icon, skill.name).toContain("<svg");
|
|
}
|
|
|
|
await createPlainAgent("fresh_agent");
|
|
const fresh = (await (await member.get(base("fresh_agent"))).json()) as AgentSkillsResponse;
|
|
expect(fresh.skills).toEqual([]);
|
|
});
|
|
|
|
// ---- POST .../skills/archive: install one skill from an uploaded zip ----
|
|
|
|
const ZIP_SKILL_MD =
|
|
"---\nname: zip-skill\ndescription: Zip demo skill\nshort_description: Zip demo\nversion: 2\nupdated: 2026-08-01\n---\n\n# Zip skill\nBody.\n";
|
|
|
|
/** Builds an in-memory zip and returns it base64-encoded (the request wire format). */
|
|
const zipB64 = (files: Record<string, Uint8Array>): string =>
|
|
Buffer.from(zipSync(files)).toString("base64");
|
|
|
|
it("archive: nested top-dir layout — all files written (subdirs preserved), directory name wins over frontmatter", async () => {
|
|
await createPlainAgent("zip_agent");
|
|
const url = `${base("zip_agent")}/archive`;
|
|
// Frontmatter says zip-skill, but the top-level directory is dir-skill: the directory
|
|
// name is the identity (same rule as listInstalledSkills). The explicit directory
|
|
// entry ("dir-skill/") must be ignored, not treated as a file.
|
|
const res = await member.post(url, {
|
|
dataBase64: zipB64({
|
|
"dir-skill/": new Uint8Array(0),
|
|
"dir-skill/SKILL.md": strToU8(ZIP_SKILL_MD),
|
|
"dir-skill/ref/notes.md": strToU8("notes\n"),
|
|
}),
|
|
});
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as AgentSkillsResponse;
|
|
expect(body.skills.map((s) => s.name)).toEqual(["dir-skill"]);
|
|
expect(body.skills[0]!.version).toBe(2);
|
|
expect(body.skills[0]!.shortDescription).toBe("Zip demo");
|
|
const dir = path.join(skillsDir(t.root, projectId, "zip_agent"), "dir-skill");
|
|
expect(await fs.readFile(path.join(dir, "SKILL.md"), "utf8")).toBe(ZIP_SKILL_MD);
|
|
expect(await fs.readFile(path.join(dir, "ref", "notes.md"), "utf8")).toBe("notes\n");
|
|
});
|
|
|
|
it("archive: root layout takes the name from frontmatter; uninstall works on the archive-installed skill", async () => {
|
|
await createPlainAgent("zip_root_agent");
|
|
const url = base("zip_root_agent");
|
|
const res = await member.post(`${url}/archive`, {
|
|
dataBase64: zipB64({ "SKILL.md": strToU8(ZIP_SKILL_MD) }),
|
|
});
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as AgentSkillsResponse;
|
|
expect(body.skills.map((s) => s.name)).toEqual(["zip-skill"]);
|
|
// Uninstall goes through the same DELETE route as library skills: 204, directory gone.
|
|
expect((await member.delete(`${url}/zip-skill`)).status).toBe(204);
|
|
await expect(
|
|
fs.access(path.join(skillsDir(t.root, projectId, "zip_root_agent"), "zip-skill")),
|
|
).rejects.toThrow();
|
|
const after = (await (await member.get(url)).json()) as AgentSkillsResponse;
|
|
expect(after.skills).toEqual([]);
|
|
});
|
|
|
|
it("archive: zip-slip and unsafe entry paths are rejected with 400, nothing written", async () => {
|
|
await createPlainAgent("zip_slip_agent");
|
|
const url = base("zip_slip_agent");
|
|
const unsafe = ["../evil.md", "/abs.md", "C:/win.md", "a\\b.md"];
|
|
for (const entry of unsafe) {
|
|
const res = await owner.post(`${url}/archive`, {
|
|
dataBase64: zipB64({
|
|
"zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD),
|
|
[entry]: strToU8("x"),
|
|
}),
|
|
});
|
|
expect(res.status, entry).toBe(400);
|
|
}
|
|
const list = (await (await owner.get(url)).json()) as AgentSkillsResponse;
|
|
expect(list.skills).toEqual([]);
|
|
});
|
|
|
|
it("archive: invalid skill names are rejected (top-level dir and frontmatter name)", async () => {
|
|
await createPlainAgent("zip_name_agent");
|
|
const url = `${base("zip_name_agent")}/archive`;
|
|
// Top-level directory name with a space fails SKILL_NAME_PATTERN.
|
|
const badDir = await owner.post(url, {
|
|
dataBase64: zipB64({ "bad name/SKILL.md": strToU8(ZIP_SKILL_MD) }),
|
|
});
|
|
expect(badDir.status).toBe(400);
|
|
// Root layout: the frontmatter name is the skill name and must pass the same rule.
|
|
const badMeta = await owner.post(url, {
|
|
dataBase64: zipB64({
|
|
"SKILL.md": strToU8("---\nname: bad/name\ndescription: d\n---\nbody\n"),
|
|
}),
|
|
});
|
|
expect(badMeta.status).toBe(400);
|
|
});
|
|
|
|
it("archive: malformed bodies and layouts are rejected with 400", async () => {
|
|
await createPlainAgent("zip_shape_agent");
|
|
const url = `${base("zip_shape_agent")}/archive`;
|
|
const cases: Array<[string, Record<string, unknown>]> = [
|
|
["dataBase64 missing", {}],
|
|
["not a zip", { dataBase64: Buffer.from("not a zip").toString("base64") }],
|
|
[
|
|
"two top-level directories",
|
|
{
|
|
dataBase64: zipB64({
|
|
"one/SKILL.md": strToU8(ZIP_SKILL_MD),
|
|
"two/readme.md": strToU8("x"),
|
|
}),
|
|
},
|
|
],
|
|
["no SKILL.md anywhere", { dataBase64: zipB64({ "sub/readme.md": strToU8("x") }) }],
|
|
[
|
|
"frontmatter without name",
|
|
{ dataBase64: zipB64({ "SKILL.md": strToU8("no frontmatter here\n") }) },
|
|
],
|
|
];
|
|
for (const [label, body] of cases) {
|
|
expect((await owner.post(url, body)).status, label).toBe(400);
|
|
}
|
|
});
|
|
|
|
it("archive: uncompressed limits — file count, per-file size, total size", async () => {
|
|
await createPlainAgent("zip_limit_agent");
|
|
const url = `${base("zip_limit_agent")}/archive`;
|
|
// > 200 files.
|
|
const many: Record<string, Uint8Array> = { "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD) };
|
|
for (let i = 0; i < 201; i++) many[`zip-skill/f${i}.txt`] = strToU8("x");
|
|
expect((await owner.post(url, { dataBase64: zipB64(many) })).status).toBe(400);
|
|
// Per-file > 5MB uncompressed (zeros compress tiny, so the wire stays small).
|
|
const big: Record<string, Uint8Array> = {
|
|
"zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD),
|
|
"zip-skill/big.bin": new Uint8Array(5 * 1024 * 1024 + 1),
|
|
};
|
|
expect((await owner.post(url, { dataBase64: zipB64(big) })).status).toBe(400);
|
|
// Total > 20MB uncompressed across files that each stay under the per-file cap.
|
|
const total: Record<string, Uint8Array> = { "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD) };
|
|
for (let i = 0; i < 5; i++) {
|
|
total[`zip-skill/part${i}.bin`] = new Uint8Array(4200 * 1024);
|
|
}
|
|
expect((await owner.post(url, { dataBase64: zipB64(total) })).status).toBe(400);
|
|
});
|
|
|
|
it("archive: already installed is 409 skill_exists; overwrite replaces the directory (stale files removed)", async () => {
|
|
await createPlainAgent("zip_over_agent");
|
|
const url = `${base("zip_over_agent")}/archive`;
|
|
const first = await member.post(url, {
|
|
dataBase64: zipB64({
|
|
"zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD),
|
|
"zip-skill/old.txt": strToU8("old\n"),
|
|
}),
|
|
});
|
|
expect(first.status).toBe(201);
|
|
|
|
// Same name again without overwrite: 409 with the name in the message (the web tab
|
|
// reads it from there for the overwrite confirmation copy).
|
|
const again = await member.post(url, {
|
|
dataBase64: zipB64({ "zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD) }),
|
|
});
|
|
expect(again.status).toBe(409);
|
|
const err = (await again.json()) as { error: { code: string; message: string } };
|
|
expect(err.error.code).toBe("skill_exists");
|
|
expect(err.error.message).toMatch(/: zip-skill$/);
|
|
|
|
// overwrite: true replaces the whole directory: old.txt is gone, new.txt appears.
|
|
const updatedMd = ZIP_SKILL_MD.replace("version: 2", "version: 3");
|
|
const res = await member.post(url, {
|
|
dataBase64: zipB64({
|
|
"zip-skill/SKILL.md": strToU8(updatedMd),
|
|
"zip-skill/new.txt": strToU8("new\n"),
|
|
}),
|
|
overwrite: true,
|
|
});
|
|
expect(res.status).toBe(201);
|
|
const body = (await res.json()) as AgentSkillsResponse;
|
|
expect(body.skills.find((s) => s.name === "zip-skill")!.version).toBe(3);
|
|
const dir = path.join(skillsDir(t.root, projectId, "zip_over_agent"), "zip-skill");
|
|
expect(await fs.readFile(path.join(dir, "SKILL.md"), "utf8")).toBe(updatedMd);
|
|
expect(await fs.readFile(path.join(dir, "new.txt"), "utf8")).toBe("new\n");
|
|
await expect(fs.access(path.join(dir, "old.txt"))).rejects.toThrow();
|
|
});
|
|
|
|
it("archive export: single-top-dir zip round-trips byte-identically; a non-installed name is 404", async () => {
|
|
await createPlainAgent("zip_export_agent");
|
|
const url = base("zip_export_agent");
|
|
// Install a multi-file skill through the archive route (nested subdir + icon.svg).
|
|
const files: Record<string, Uint8Array> = {
|
|
"zip-skill/SKILL.md": strToU8(ZIP_SKILL_MD),
|
|
"zip-skill/icon.svg": strToU8('<svg viewBox="0 0 24 24"><path d="M2 2h20"/></svg>\n'),
|
|
"zip-skill/ref/notes.md": strToU8("notes\n"),
|
|
};
|
|
expect((await member.post(`${url}/archive`, { dataBase64: zipB64(files) })).status).toBe(201);
|
|
|
|
// Export it: a direct binary attachment (application/zip), like the snapshot export.
|
|
// The frontmatter declares version: 2 explicitly, so the filename carries -v2.
|
|
const res = await member.get(`${url}/zip-skill/archive`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type")).toBe("application/zip");
|
|
expect(res.headers.get("content-disposition")).toBe(
|
|
"attachment; filename*=UTF-8''zip-skill-v2.zip",
|
|
);
|
|
const entries = unzipSync(new Uint8Array(await res.arrayBuffer()));
|
|
// Single-top-dir layout with every installed file, byte-identical to the upload — the
|
|
// export feeds back into the POST archive route unchanged.
|
|
const fileNames = Object.keys(entries).filter((n) => !n.endsWith("/"));
|
|
expect(fileNames.sort()).toEqual(Object.keys(files).sort());
|
|
for (const [name, data] of Object.entries(files)) {
|
|
expect(Buffer.from(entries[name]!)).toEqual(Buffer.from(data));
|
|
}
|
|
|
|
// Exporting a skill that isn't installed → 404 (same criterion as uninstall).
|
|
expect((await member.get(`${url}/no-such-skill/archive`)).status).toBe(404);
|
|
|
|
// Without an explicit frontmatter version: field the filename stays <name>.zip —
|
|
// parseSkillFrontmatter's defaulted 1 must not be presented as a declared version.
|
|
const noVersion = "---\nname: nover-skill\ndescription: No version field\n---\nbody\n";
|
|
expect(
|
|
(
|
|
await member.post(`${url}/archive`, {
|
|
dataBase64: zipB64({ "SKILL.md": strToU8(noVersion) }),
|
|
})
|
|
).status,
|
|
).toBe(201);
|
|
const plain = await member.get(`${url}/nover-skill/archive`);
|
|
expect(plain.status).toBe(200);
|
|
expect(plain.headers.get("content-disposition")).toBe(
|
|
"attachment; filename*=UTF-8''nover-skill.zip",
|
|
);
|
|
});
|
|
});
|