Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/docs/generated/notes-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

> 自动生成,勿手改。重新生成:`node scripts/verify-docs.mjs --write`。

共 15 篇。按状态排序:提案 → 已实施 → 已否决。
共 16 篇。按状态排序:提案 → 已实施 → 已否决。

| 日期 | 状态 | 分类 | 标题 | 问题 |
|---|---|---|---|---|
| 2026-11-09 | implemented | testing | [清单行为用假清单测](../../notes/implemented/testing/2026-11-09-mock-manifest-fixtures.md) | 发布面和展平测试直接断言仓库里的真实清单路径与版本号。 |
| 2026-11-06 | implemented | feature | [WebView2 zstd 包采用双层内容指纹](../../notes/implemented/feature/2026-11-06-webview2-raw-fingerprint.md) | 旧 WebView2 清单以 Compression="zip"、HashAlgorithm="None" 描述解包目录,不能验证下载物和待安装 ZIP 的内容。 |
| 2026-11-04 | implemented | process | [Cloudflare 部署统一使用 Workers 静态资源](../../notes/implemented/process/2026-11-04-cloudflare-workers-deployment.md) | Cloudflare 后台项目是 Workers,但 npm 发布和本地服务仍调用 Pages 命令。 |
| 2026-11-04 | implemented | process | [分支预览迁移到 Worker Previews](../../notes/implemented/process/2026-11-04-worker-previews.md) | 此前部署决定使用 wrangler versions upload 创建版本预览 URL,用户现在选择 Cloudflare 新的 Worker Previews 分支预览机制。 |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Agent Note: 清单行为用假清单测

Status: implemented

## Problem

发布面和展平测试直接断言仓库里的真实清单路径与版本号。桌面端从 `1.9.9.22` 换成 `2.0.0.0` 之后,`PublishSurfaceTests` 和 `StageATests` 仍在找旧文件,CI 因此失败。清单版本会随发布变化,测试不该跟着改。

## Decision

清单的限定 ID、叶子发布、变量替换和 Skudef 保序,全部用临时目录里的假清单验证。假清单走与正式数据相同的 `Include` 链,版本号固定为测试自己的 `9.9.9` / `1.0`,不读取 `Metadata/apps` 或 `Metadata/mods` 里的现行版本。

仓库样本测试只保留与具体清单无关的断言:发布物包含登记节点指向的资源,查询结果与登记 `Source` 一致。

## Alternatives considered

- **把写死的版本号改成 2.0.0.0**:能让这次 CI 通过,但下一次发布还是会断。
- **从仓库样本动态读当前清单再断言**:不写死版本,却仍依赖线上数据的目录和字段,样本一改测试就漂。

## Consequences

发布换版本时不用再改单元测试。假清单必须自己满足 XSD 和构建校验,缺字段会在测试里失败,而不是在真实数据里被放过。

## Verification

`dotnet test Metadata.sln --filter "FullyQualifiedName~StageATests|FullyQualifiedName~PublishSurfaceTests|FullyQualifiedName~ManifestParseTests|FullyQualifiedName~QueryApiTests"`
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
<Tags>
<Commit>${TIMESTAMP}</Commit>
</Tags>
<Manifest ID="manifest-1.9.9.22" HashAlgorithm="CRC32C">
<File Hash="115944DA" Size="38813821">
<FileName>Ra3BattleNet_Setup_1.9.9.22.exe</FileName>
<Manifest ID="manifest-2.0.0.0" HashAlgorithm="CRC32C">
<File Hash="5A34BC49" Size="39380974">
<FileName>Ra3BattleNet_Setup_2.0.0.0.exe</FileName>
<RelativePath>/</RelativePath>
<KindOf>APPLICATION</KindOf>
<Sources>
<Source Type="BT" Url="https://file.cor-games.com/srv/ra3battlenet/desktop/Ra3BattleNet_Setup_1.9.9.22.exe.torrent" />
<Source Type="BT" Url="https://file.cor-games.com/srv/ra3battlenet/desktop/Ra3BattleNet_Setup_2.0.0.0.exe.torrent" />
</Sources>
</File>
</Manifest>
Expand Down
2 changes: 1 addition & 1 deletion Metadata/apps/ra3battlenet/manifests/manifests.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
<Includes>
<!-- 子模块 -->
<!-- 注册 Manifest 文件 -->
<Include Source="1.9.9.22.xml" />
<Include Source="2.0.0.0.xml" />
</Includes>
</Metadata>
42 changes: 9 additions & 33 deletions Metadata/apps/ra3battlenet/ra3battlenet.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,51 +9,27 @@
</Includes>

<Application ID="RA3BattleNet">
<Version>1.9.9.22</Version>
<Version>2.0.0.0</Version>
<DisplayName Language="zh">红色警戒3战网服务</DisplayName>
<DisplayName Language="en">Ra3BattleNet</DisplayName>
<TransferAd>false</TransferAd>
<Packages>
<Package Version="1.9.9.22">
<ReleaseDate>2026-11-03</ReleaseDate>
<Manifest>manifest-1.9.9.22</Manifest>
<Package Version="2.0.0.0">
<ReleaseDate>2026-11-09</ReleaseDate>
<Manifest>manifest-2.0.0.0</Manifest>
</Package>
</Packages>
<Posts/>

<UpdateKind ID="RA3BattleNet" DisplayName="客户端程序" Current="1.9.9.30"
<UpdateKind ID="RA3BattleNet" DisplayName="客户端程序" Current="2.0.0.0"
BaseUrl="https://update.ra3battle.net/app/RA3BattleNet/"
FallbackBaseUrl="">
<Updater Version="1.9.9.22"
Source="https://update.ra3battle.net/app/RA3BattleNet/manifests/manifest-1.9.9.22.xml"
Hash="2550EAD9DA370F396B5FC964BE6271A3"
Size="69140"
ReleaseDate="2026-11-03"
Commit="v1.9.9.22" />
<Updater Version="1.9.9.23"
Source="https://update.ra3battle.net/app/RA3BattleNet/manifests/manifest-1.9.9.23.xml"
Hash="0B844ECE83E04A8E3790D151142F8118"
Size="69140"
ReleaseDate="2026-11-04"
Commit="v1.9.9.23" />
<Updater Version="1.9.9.26"
Source="https://update.ra3battle.net/app/RA3BattleNet/manifests/manifest-1.9.9.26.xml"
Hash="B3D5CC0682E6D7DA887804A20C4B22A4"
Size="69140"
ReleaseDate="2026-11-07"
Commit="v1.9.9.26" />
<Updater Version="1.9.9.28"
Source="https://update.ra3battle.net/app/RA3BattleNet/manifests/manifest-1.9.9.28.xml"
Hash="522CFFD65D612F5FBDD25C8EF68EA39A"
Size="69382"
ReleaseDate="2026-11-08"
Commit="v1.9.9.28" />
<Updater Version="1.9.9.30"
Source="https://update.ra3battle.net/app/RA3BattleNet/manifests/manifest-1.9.9.30.xml"
Hash="45618C7EA16D9DCD90003780DBA8F08D"
<Updater Version="2.0.0.0"
Source="https://update.ra3battle.net/app/RA3BattleNet/manifests/manifest-2.0.0.0.xml"
Hash="BFF41EB904B95A71FE90B933FAC01088"
Size="69382"
ReleaseDate="2026-11-09"
Commit="v1.9.9.30" />
Commit="v2.0.0.0" />
</UpdateKind>

</Application>
Expand Down
4 changes: 2 additions & 2 deletions Ra3.BattleNet.Metadata.Tests/ManifestParseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,10 @@ public void ManifestRegistration_ResolvesRegisteredId_AndNullWhenMissing()
var root = MetadataBuilder.Load(Path.Combine(dst, "metadata.xml"));

var registered = root.GetAllElements("Manifest")
.First(m => MetadataFlattener.LocalId(m.Get("ID")) == "manifest-3259");
.First(m => !string.IsNullOrWhiteSpace(m.Get("ID")) && !string.IsNullOrWhiteSpace(m.Get("Source")));
var found = root.ManifestRegistration(registered.Get("ID")!);
found.Should().BeSameAs(registered);
found!.Get("Source").Should().EndWith("manifests/3.259.xml");
found!.Get("Source")!.Replace('\\', '/').Should().Contain("manifests/");

root.ManifestRegistration("no-such-manifest").Should().BeNull();
}
Expand Down
172 changes: 130 additions & 42 deletions Ra3.BattleNet.Metadata.Tests/PublishSurfaceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,12 @@ public void Build_RepoSample_OutputHasOnlyFlattenedAndReferencedResources()
File.ReadAllBytes(Path.Combine(dst, relative))
.Should().Equal(File.ReadAllBytes(Path.Combine(src, relative)));
}
File.Exists(Path.Combine(dst, "mods", "corona", "manifests", "3.259.xml")).Should().BeTrue();
File.Exists(Path.Combine(dst, "apps", "ra3battlenet", "manifests", "1.9.9.22.xml")).Should().BeTrue();
foreach (var manifest in loaded.GetAllElements("Manifest").Where(m => m.Get("Source") is not null))
{
var relative = manifest.Get("Source")!.Replace('/', Path.DirectorySeparatorChar);
File.Exists(Path.Combine(dst, relative)).Should().BeTrue($"登记清单必须进发布物: {relative}");
File.Exists(Path.Combine(src, relative)).Should().BeTrue();
}

// _redirects 自动带出
File.Exists(Path.Combine(dst, "_redirects")).Should().BeTrue();
Expand Down Expand Up @@ -104,16 +108,123 @@ public void Build_UrlOnlyImage_CopiesNothingForIt()
}

[TestMethod]
public void Build_RepoSample_LeafManifestIsFlattenedAndLoadable()
public void Build_MockLeafManifest_IsFlattenedAndLoadable()
{
var dst = Path.Combine(Path.GetTempPath(), $"publish-leaf-{Guid.NewGuid():N}");

var temp = Path.Combine(Path.GetTempPath(), $"publish-mock-leaf-{Guid.NewGuid():N}");
var src = Path.Combine(temp, "src");
var dst = Path.Combine(temp, "out");
SeedSchemas(src);
try
{
MetadataBuilder.Build(RepoMetadataDir, dst, schemaVersion: "1.0", contentRevision: "publish-test");
Directory.CreateDirectory(Path.Combine(src, "mods", "mockmod", "manifests"));
Directory.CreateDirectory(Path.Combine(src, "apps", "mockruntime", "manifests"));
File.WriteAllText(Path.Combine(src, "metadata.xml"), """
<?xml version="1.0" encoding="UTF-8"?>
<Metadata>
<Includes>
<Include Source="mods/mockmod/mockmod.xml" />
<Include Source="apps/mockruntime/mockruntime.xml" />
</Includes>
</Metadata>
""");
File.WriteAllText(Path.Combine(src, "mods", "mockmod", "mockmod.xml"), """
<?xml version="1.0" encoding="UTF-8"?>
<Metadata>
<Includes>
<Include Source="manifests/1.0.xml" />
</Includes>
<Mod ID="MockMod">
<CurrentVersion>1.0</CurrentVersion>
<DisplayName Language="zh">假模组</DisplayName>
<DisplayName Language="en">MockMod</DisplayName>
<Packages>
<Package Version="1.0">
<Manifest>manifest-mock</Manifest>
</Package>
</Packages>
<Posts/>
</Mod>
</Metadata>
""");
File.WriteAllText(Path.Combine(src, "mods", "mockmod", "manifests", "1.0.xml"), """
<?xml version="1.0" encoding="UTF-8"?>
<Metadata>
<Tags>
<Commit>${TIMESTAMP}</Commit>
</Tags>
<Includes/>
<Manifest ID="manifest-mock" HashAlgorithm="CRC32C">
<Skudef GameVersion="1.12">
<AddConfig LocalFile="CustomConfig.txt" Optional="true" />
<AddBig File="core.big" Package="optional-pack" />
<AddBig File="text.big" Language="en" />
<AddBig File="base.lyi" />
</Skudef>
<File Hash="3507CD54" Size="4">
<FileName>core.big</FileName>
<RelativePath>/</RelativePath>
<KindOf>MISC;</KindOf>
<Sources>
<Source Type="BT" Url="https://example.invalid/core.big.torrent" />
</Sources>
</File>
<File Hash="88BE39D6" Size="4">
<FileName>text.big</FileName>
<RelativePath>/</RelativePath>
<KindOf>MISC;</KindOf>
<Sources>
<Source Type="BT" Url="https://example.invalid/text.big.torrent" />
</Sources>
</File>
<File Hash="95DC8BF4" Size="8" DownloadName="base.zst" Compression="zstd">
<FileName>base.lyi</FileName>
<RelativePath>/</RelativePath>
<KindOf>MOD;</KindOf>
<Sources>
<Source Type="BT" Url="https://example.invalid/base.zst.torrent" />
</Sources>
</File>
</Manifest>
</Metadata>
""");
File.WriteAllText(Path.Combine(src, "apps", "mockruntime", "mockruntime.xml"), """
<?xml version="1.0" encoding="UTF-8"?>
<Metadata>
<Includes>
<Include Source="manifests/1.0.xml" />
</Includes>
<Application ID="MockRuntime">
<Version>1.0</Version>
<DisplayName Language="zh">假运行时</DisplayName>
<DisplayName Language="en">MockRuntime</DisplayName>
<Packages>
<Package Version="1.0">
<Manifest>manifest-runtime</Manifest>
</Package>
</Packages>
<Posts/>
</Application>
</Metadata>
""");
File.WriteAllText(Path.Combine(src, "apps", "mockruntime", "manifests", "1.0.xml"), """
<?xml version="1.0" encoding="UTF-8"?>
<Metadata>
<Manifest ID="manifest-runtime" HashAlgorithm="SHA256">
<File Hash="31de4e57a19017eee55e6f66ddb9f6c217f944716dee2cd14a37f72dfbbe831b" RawHash="b601d79b38720d174c17b6affed3506f01c5033d20a42d1f90fe5745f1b8d021" RawHashAlgorithm="SHA256" Size="64" DownloadName="runtime.zip.zst" Compression="zstd">
<FileName>runtime.zip</FileName>
<RelativePath>/</RelativePath>
<KindOf>RUNTIME</KindOf>
<Sources>
<Source Type="BT" Url="https://example.invalid/runtime.zip.zst.torrent" />
</Sources>
</File>
</Manifest>
</Metadata>
""");

MetadataBuilder.Build(src, dst, schemaVersion: "1.0", contentRevision: "publish-mock");

// 叶子清单不能照抄源文件:客户端解析器拒绝 Includes,也不该收到未替换的变量
var leafPath = Path.Combine(dst, "mods", "corona", "manifests", "3.259.xml");
var leafPath = Path.Combine(dst, "mods", "mockmod", "manifests", "1.0.xml");
var text = File.ReadAllText(leafPath);
text.Should().NotContain("<Includes");
text.Should().NotMatchRegex(@"\$\{[^}]+\}");
Expand All @@ -124,60 +235,37 @@ public void Build_RepoSample_LeafManifestIsFlattenedAndLoadable()
entry.Files.Should().OnlyContain(f => f.Sources.Count > 0);
entry.Files.Should().Contain(f => f.DownloadName != null);

var runtime = MetadataBuilder.Load(Path.Combine(dst, "apps", "webview2", "manifests", "156.0.4314.58.xml"))
var runtime = MetadataBuilder.Load(Path.Combine(dst, "apps", "mockruntime", "manifests", "1.0.xml"))
.Find("Manifest")!.ToManifestEntry();
runtime.HashAlgorithm.Should().Be("SHA256");
var file = runtime.Files.Should().ContainSingle().Subject;
file.FileName.Should().Be("WebView2Runtime.zip");
file.DownloadName.Should().Be("WebView2Runtime.zip.zst");
file.FileName.Should().Be("runtime.zip");
file.DownloadName.Should().Be("runtime.zip.zst");
file.Compression.Should().Be("zstd");
file.Size.Should().Be(233263294);
file.Size.Should().Be(64);
file.Hash.Should().Be("31de4e57a19017eee55e6f66ddb9f6c217f944716dee2cd14a37f72dfbbe831b");
file.RawHash.Should().Be("b601d79b38720d174c17b6affed3506f01c5033d20a42d1f90fe5745f1b8d021");
file.RawHashAlgorithm.Should().Be("SHA256");
file.Sources.Should().ContainSingle().Which.Url.Should()
.Be("https://file.cor-games.com/srv/redist/WebView2Runtime.zip.zst.torrent");
.Be("https://example.invalid/runtime.zip.zst.torrent");
var exported = MetadataXml.Write(file.Raw);
exported.Should().Contain($"RawHash=\"{file.RawHash}\"").And.Contain("RawHashAlgorithm=\"SHA256\"");
}
finally
{
if (Directory.Exists(dst))
Directory.Delete(dst, recursive: true);
}
}

[TestMethod]
public void Build_RepoSample_SkudefSurvivesFlattenInDeclaredOrder()
{
var dst = Path.Combine(Path.GetTempPath(), $"publish-skudef-{Guid.NewGuid():N}");

try
{
MetadataBuilder.Build(RepoMetadataDir, dst, schemaVersion: "1.0", contentRevision: "publish-test");

var entry = MetadataBuilder.Load(Path.Combine(dst, "mods", "corona", "manifests", "3.259.xml"))
.Find("Manifest")!.ToManifestEntry();
var skudef = entry.Skudef;

skudef.Should().NotBeNull("展平后叶子清单要保住 Skudef");
skudef!.GameVersion.Should().Be("1.12");
skudef.Commands.Select(c => c.Target).Should().ContainInOrder(
"CustomConfig.txt", "Disabler.big", "HighResShadow.big", "Cor_ENG_3.250.big",
"coronaBGM_3.228.lyi", "corona_3.259.lyi", "StaticVersion.big");
"CustomConfig.txt", "core.big", "text.big", "base.lyi");
skudef.Commands.Should().ContainSingle(c => c.Target == "CustomConfig.txt" && c.Kind == SkudefCommandKind.Config && c.Optional);
skudef.Commands.Should().Contain(c => c.Kind == SkudefCommandKind.Big && c.Target == "Disabler.big" && c.Package == "disable-sky-and-patches");
skudef.Commands.Should().Contain(c => c.Kind == SkudefCommandKind.Big && c.Target == "HighResShadow.big" && c.Package == "hd-shadow");
skudef.Commands.Should().Contain(c => c.Kind == SkudefCommandKind.Big && c.Target == "Cor_ENG_3.250.big" && c.Language == "en");
skudef.Commands.Should().Contain(c => c.Kind == SkudefCommandKind.Big && c.Target == "StaticVersion.big" && c.Package == "world-builder");

// 加载条件单源:File 上不能再留 Mount/Language/Package
skudef.Commands.Should().Contain(c => c.Kind == SkudefCommandKind.Big && c.Target == "core.big" && c.Package == "optional-pack");
skudef.Commands.Should().Contain(c => c.Kind == SkudefCommandKind.Big && c.Target == "text.big" && c.Language == "en");
entry.Files.Should().OnlyContain(f => f.Mount == "base" && f.Language == null && f.Package == null);
}
finally
{
if (Directory.Exists(dst))
Directory.Delete(dst, recursive: true);
if (Directory.Exists(temp))
Directory.Delete(temp, recursive: true);
}
}

}
Loading
Loading