fix: require svglide bundle receipts before publish

This commit is contained in:
songtianyi.theo
2026-07-09 13:08:07 +08:00
parent 6be69a89c6
commit fa204e5d6b
5 changed files with 136 additions and 12 deletions

View File

@@ -43,6 +43,11 @@ type svglideManifestSize struct {
type svglideManifestReceipts struct {
ValidateSVGDeck string `json:"validate_svg_deck"`
PromptReadTrace string `json:"prompt_read_trace"`
AssetReadiness string `json:"asset_readiness"`
TypographyPlan string `json:"typography_plan"`
FrameDiscipline string `json:"frame_discipline"`
ParserContract string `json:"parser_contract"`
}
type svglideManifestPage struct {
@@ -160,6 +165,11 @@ type svglideValidateSVGDeckReceipt struct {
TotalErrors *int `json:"totalErrors"`
}
type svglideZeroErrorReceipt struct {
TotalErrors *int `json:"totalErrors"`
OK *bool `json:"ok"`
}
func validateSVGlideBundleReceipt(runtime *common.RuntimeContext, baseDir string, receipts svglideManifestReceipts) error {
receiptPath, err := svglideManifestChildPath("receipts.validate_svg_deck", baseDir, receipts.ValidateSVGDeck)
if err != nil {
@@ -176,6 +186,42 @@ func validateSVGlideBundleReceipt(runtime *common.RuntimeContext, baseDir string
if receipt.TotalErrors == nil || *receipt.TotalErrors != 0 {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest validate_svg_deck receipt must have totalErrors=0").WithParam("--manifest")
}
required := []struct {
label string
rel string
}{
{"receipts.prompt_read_trace", receipts.PromptReadTrace},
{"receipts.asset_readiness", receipts.AssetReadiness},
{"receipts.typography_plan", receipts.TypographyPlan},
{"receipts.frame_discipline", receipts.FrameDiscipline},
{"receipts.parser_contract", receipts.ParserContract},
}
for _, item := range required {
label, rel := item.label, item.rel
if strings.TrimSpace(rel) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s is required", label).WithParam("--manifest")
}
joined, err := svglideManifestChildPath(label, baseDir, rel)
if err != nil {
return err
}
raw, err := readSVGlideManifestInput(runtime, joined, "--manifest %s receipt not found: %s", label, rel)
if err != nil {
return err
}
if label == "receipts.frame_discipline" || label == "receipts.parser_contract" {
var zero svglideZeroErrorReceipt
if err := json.Unmarshal(raw, &zero); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s receipt invalid JSON: %v", label, err).WithParam("--manifest").WithCause(err)
}
if zero.TotalErrors != nil && *zero.TotalErrors != 0 {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest %s receipt must have totalErrors=0", label).WithParam("--manifest")
}
if zero.OK != nil && !*zero.OK {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "--manifest %s receipt must have ok=true", label).WithParam("--manifest")
}
}
}
return nil
}

View File

@@ -22,13 +22,19 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const (
svglideValidateOnlyReceiptsJSON = `"validate_svg_deck":"receipts/validate_svg_deck.json"`
svglideRequiredReceiptsJSON = svglideValidateOnlyReceiptsJSON + `,"prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","frame_discipline":"receipts/frame_discipline.json","parser_contract":"receipts/parser_contract.json"`
)
func TestPrepareSVGlidePublishSpecReadsManifestAndPages(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
page := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" id="page-001" viewBox="0 0 960 540"><rect slide:role="background" x="0" y="0" width="960" height="540" fill="rgba(255,255,255,1)"/></svg>`
mustWriteSVGlideTestFile(t, "pages/page-001.svg", page)
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
mustWriteSVGlideTestFile(t, "manifest.json", fmt.Sprintf(`{
mustWriteSVGlideRequiredNonValidateReceipts(t)
mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{
"version": "svglide.manifest.v1",
"protocol": "svg-slides.v1",
"title": "Manifest Title",
@@ -37,7 +43,7 @@ func TestPrepareSVGlidePublishSpecReadsManifestAndPages(t *testing.T) {
"published": false,
"pages": [{"id": "page-001", "index": 1, "file": "pages/page-001.svg", "sha256": %q}],
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
}`, sha256Hex([]byte(page))))
}`, sha256Hex([]byte(page)))))
runtime, _ := newSVGlideTestRuntime(t)
spec, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "")
@@ -64,7 +70,8 @@ func TestPrepareSVGlidePublishSpecTitleOverride(t *testing.T) {
page := `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`
mustWriteSVGlideTestFile(t, "page.svg", page)
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
mustWriteSVGlideTestFile(t, "manifest.json", fmt.Sprintf(`{
mustWriteSVGlideRequiredNonValidateReceipts(t)
mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{
"version": "svglide.manifest.v1",
"protocol": "svg-slides.v1",
"title": "Manifest Title",
@@ -73,7 +80,7 @@ func TestPrepareSVGlidePublishSpecTitleOverride(t *testing.T) {
"published": false,
"pages": [{"id": "p1", "index": 1, "file": "page.svg", "sha256": %q}],
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
}`, sha256Hex([]byte(page))))
}`, sha256Hex([]byte(page)))))
runtime, _ := newSVGlideTestRuntime(t)
spec, err := prepareSVGlidePublishSpec(runtime, "manifest.json", "CLI Title")
@@ -87,10 +94,11 @@ func TestPrepareSVGlidePublishSpecTitleOverride(t *testing.T) {
func TestPrepareSVGlidePublishSpecValidationErrors(t *testing.T) {
tests := []struct {
name string
manifest string
pageFiles map[string]string
wantMessage string
name string
manifest string
pageFiles map[string]string
skipDefaultReceipts bool
wantMessage string
}{
{
name: "wrong version",
@@ -140,6 +148,20 @@ func TestPrepareSVGlidePublishSpecValidationErrors(t *testing.T) {
pageFiles: map[string]string{"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`, "receipts/validate_svg_deck.json": `{"totalErrors":1}`},
wantMessage: "--manifest validate_svg_deck receipt must have totalErrors=0",
},
{
name: "missing parser contract receipt",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"published":false,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json","prompt_read_trace":"receipts/prompt_read_trace.json","asset_readiness":"receipts/asset_readiness.json","typography_plan":"receipts/typography_plan.json","frame_discipline":"receipts/frame_discipline.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"<valid-sha>"}]}`,
pageFiles: map[string]string{
"page.svg": `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"></svg>`,
"receipts/validate_svg_deck.json": `{"totalErrors":0}`,
"receipts/prompt_read_trace.json": `{"ok":true}`,
"receipts/asset_readiness.json": `{"ok":true}`,
"receipts/typography_plan.json": `{"ok":true}`,
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
},
skipDefaultReceipts: true,
wantMessage: "--manifest receipts.parser_contract is required",
},
{
name: "sha mismatch",
manifest: `{"version":"svglide.manifest.v1","protocol":"svg-slides.v1","title":"x","size":{"width":960,"height":540},"publish_ready":true,"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"},"pages":[{"id":"p1","index":1,"file":"page.svg","sha256":"bad"}]}`,
@@ -161,6 +183,10 @@ func TestPrepareSVGlidePublishSpecValidationErrors(t *testing.T) {
for path, body := range tt.pageFiles {
mustWriteSVGlideTestFile(t, path, body)
}
if !tt.skipDefaultReceipts {
mustWriteSVGlideRequiredNonValidateReceipts(t)
tt.manifest = withSVGlideRequiredReceipts(tt.manifest)
}
if body, ok := tt.pageFiles["page.svg"]; ok {
tt.manifest = strings.ReplaceAll(tt.manifest, "<valid-sha>", sha256Hex([]byte(body)))
}
@@ -194,6 +220,29 @@ func mustWriteSVGlideTestFile(t *testing.T, path string, body string) {
}
}
func withSVGlideRequiredReceipts(manifest string) string {
manifest = strings.ReplaceAll(manifest, `"receipts":{`+svglideValidateOnlyReceiptsJSON+`}`, `"receipts":{`+svglideRequiredReceiptsJSON+`}`)
manifest = strings.ReplaceAll(manifest, `"receipts":{"validate_svg_deck":"receipts/validate_svg_deck.json"}`, `"receipts":{`+svglideRequiredReceiptsJSON+`}`)
manifest = strings.ReplaceAll(manifest, `"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}`, `"receipts": {`+svglideRequiredReceiptsJSON+`}`)
return strings.ReplaceAll(manifest, `"receipts": {`+svglideValidateOnlyReceiptsJSON+`}`, `"receipts": {`+svglideRequiredReceiptsJSON+`}`)
}
func mustWriteSVGlideRequiredNonValidateReceipts(t *testing.T) {
t.Helper()
for path, body := range map[string]string{
"receipts/prompt_read_trace.json": `{"ok":true}`,
"receipts/asset_readiness.json": `{"ok":true}`,
"receipts/typography_plan.json": `{"ok":true}`,
"receipts/frame_discipline.json": `{"ok":true,"totalErrors":0}`,
"receipts/parser_contract.json": `{"ok":true,"totalErrors":0}`,
} {
if _, err := os.Stat(path); err == nil {
continue
}
mustWriteSVGlideTestFile(t, path, body)
}
}
func TestPlanSVGlideImageRewritesResolvesAndDedupes(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
@@ -415,7 +464,8 @@ func mustWriteSVGlideBundle(t *testing.T, title string, page string) {
t.Helper()
mustWriteSVGlideTestFile(t, "pages/page-001.svg", page)
mustWriteSVGlideTestFile(t, "receipts/validate_svg_deck.json", `{"totalErrors":0}`)
mustWriteSVGlideTestFile(t, "manifest.json", fmt.Sprintf(`{
mustWriteSVGlideRequiredNonValidateReceipts(t)
mustWriteSVGlideTestFile(t, "manifest.json", withSVGlideRequiredReceipts(fmt.Sprintf(`{
"version": "svglide.manifest.v1",
"protocol": "svg-slides.v1",
"title": %q,
@@ -424,7 +474,7 @@ func mustWriteSVGlideBundle(t *testing.T, title string, page string) {
"published": false,
"pages": [{"id": "page-001", "index": 1, "file": "pages/page-001.svg", "sha256": %q}],
"receipts": {"validate_svg_deck": "receipts/validate_svg_deck.json"}
}`, title, sha256Hex([]byte(page))))
}`, title, sha256Hex([]byte(page)))))
}
func newSVGlideTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {

View File

@@ -60,6 +60,13 @@ if (!validate.stdout.trim()) {
const receipt = JSON.parse(validate.stdout);
fs.mkdirSync(path.join(root, "receipts"), { recursive: true });
fs.writeFileSync(path.join(root, "receipts", "validate_svg_deck.json"), `${JSON.stringify(receipt, null, 2)}\n`);
const parserContractReceipt = {
version: "svg-slides.parser-contract.v1",
ok: receipt.totalErrors === 0,
source: "validate_svg_deck.mjs",
totalErrors: receipt.totalErrors,
};
fs.writeFileSync(path.join(root, "receipts", "parser_contract.json"), `${JSON.stringify(parserContractReceipt, null, 2)}\n`);
if (receipt.totalErrors !== 0) {
fail(`SVG deck is not publish-ready: ${receipt.totalErrors} validation error(s)`, 1);
}
@@ -141,7 +148,8 @@ const manifest = {
prompt_read_trace: "receipts/prompt_read_trace.json",
asset_readiness: "receipts/asset_readiness.json",
typography_plan: "receipts/typography_plan.json",
frame_discipline: "receipts/frame_discipline.json"
frame_discipline: "receipts/frame_discipline.json",
parser_contract: "receipts/parser_contract.json"
}
};

View File

@@ -172,11 +172,15 @@ test("bundle builder writes manifest and validation receipt", () => {
assert.equal(manifest.receipts.asset_readiness, "receipts/asset_readiness.json");
assert.equal(manifest.receipts.typography_plan, "receipts/typography_plan.json");
assert.equal(manifest.receipts.frame_discipline, "receipts/frame_discipline.json");
assert.equal(manifest.receipts.parser_contract, "receipts/parser_contract.json");
assert.equal(manifest.pages.length, 1);
assert.match(manifest.pages[0].sha256, /^[a-f0-9]{64}$/);
const receipt = JSON.parse(fs.readFileSync(path.join(root, "receipts", "validate_svg_deck.json"), "utf8"));
assert.equal(receipt.totalErrors, 0);
assert.equal(fs.existsSync(path.join(root, "receipts", "frame_discipline.json")), true);
const parserContract = JSON.parse(fs.readFileSync(path.join(root, "receipts", "parser_contract.json"), "utf8"));
assert.equal(parserContract.totalErrors, 0);
assert.equal(parserContract.ok, true);
});
test("bundle builder rejects missing asset readiness receipt", () => {
@@ -327,4 +331,8 @@ test("bundle builder accepts chart deck with deck and chart prompt reads", () =>
assert.equal(manifest.receipts.asset_readiness, "receipts/asset_readiness.json");
assert.equal(manifest.receipts.typography_plan, "receipts/typography_plan.json");
assert.equal(manifest.receipts.frame_discipline, "receipts/frame_discipline.json");
assert.equal(manifest.receipts.parser_contract, "receipts/parser_contract.json");
const parserContract = JSON.parse(fs.readFileSync(path.join(root, "receipts", "parser_contract.json"), "utf8"));
assert.equal(parserContract.totalErrors, 0);
assert.equal(parserContract.ok, true);
});

View File

@@ -43,6 +43,11 @@ func prepareSVGlideImageSmokeBundle(t *testing.T, title string) string {
validateOut := runSVGlideNodeTool(t, root, "skills/lark-slides/scripts/validate_svg_deck.mjs", dir, "--json")
require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "validate_svg_deck.json"), validateOut, 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "prompt_read_trace.json"), []byte(`{"ok":true}`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "asset_readiness.json"), []byte(`{"ok":true}`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "typography_plan.json"), []byte(`{"ok":true}`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "frame_discipline.json"), []byte(`{"ok":true,"totalErrors":0}`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "receipts", "parser_contract.json"), []byte(`{"ok":true,"totalErrors":0}`), 0o644))
manifest := map[string]any{
"version": "svglide.manifest.v1",
@@ -57,7 +62,14 @@ func prepareSVGlideImageSmokeBundle(t *testing.T, title string) string {
"file": "slides/page-001.svg",
"sha256": sha256HexForE2E([]byte(page)),
}},
"receipts": map[string]any{"validate_svg_deck": "receipts/validate_svg_deck.json"},
"receipts": map[string]any{
"validate_svg_deck": "receipts/validate_svg_deck.json",
"prompt_read_trace": "receipts/prompt_read_trace.json",
"asset_readiness": "receipts/asset_readiness.json",
"typography_plan": "receipts/typography_plan.json",
"frame_discipline": "receipts/frame_discipline.json",
"parser_contract": "receipts/parser_contract.json",
},
}
raw, err := json.MarshalIndent(manifest, "", " ")
require.NoError(t, err)