packages.wenpai.net/internal/wporg/svn_test.go
Scott Walkinshaw 82974e38de
Pin dev-trunk to SVN revision and remove unpinnable dist (#69)
* Pin dev-trunk to SVN revision and remove unpinnable dist (#68)

Extract per-slug revision numbers from SVN changelog (already available
in the DAV log response) and store as trunk_revision on each package.
The build step now emits `"reference": "trunk@<rev>"` in ~dev.json,
making composer.lock reproducible for dev-trunk installs. The
unversioned dist URL is removed since it always points to latest trunk.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add backfill-revisions command and sanitize SVN log XML

New `backfill-revisions` command scans SVN changelog backwards in
chunks to populate trunk_revision for plugins that don't have one
yet. Retries failed chunks up to 3 times and skips on persistent
failure. Also sanitize illegal XML characters from SVN log responses
to handle malformed commit messages in older revisions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update dev-trunk notices to reflect SVN revision pinning

With trunk_revision support, Composer now pins dev-trunk installs
to a specific SVN revision in composer.lock. Replace mutability
warnings with messaging about revision pinning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add untagged plugin install comparison to vs page

Highlight that WP Packages pins dev-trunk to SVN revisions for
reproducible installs, while WPackagist uses mutable trunk zips.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ben Word <ben@benword.com>
2026-03-26 12:10:49 -05:00

161 lines
4 KiB
Go

package wporg
import (
"context"
"log/slog"
"strings"
"testing"
)
func TestParseSVNHTML(t *testing.T) {
html := `<html><head><title> - Revision 123: /</title></head>
<body>
<h2> - Revision 123: /</h2>
<ul>
<li><a href="akismet/">akismet/</a></li>
<li><a href="jetpack/">jetpack/</a></li>
</ul>
</body></html>`
var entries []SVNEntry
result, err := parseSVNHTML(context.Background(), strings.NewReader(html), func(e SVNEntry) error {
entries = append(entries, e)
return nil
}, slog.Default())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0].Slug != "akismet" {
t.Errorf("first entry slug = %q, want akismet", entries[0].Slug)
}
if entries[1].Slug != "jetpack" {
t.Errorf("second entry slug = %q, want jetpack", entries[1].Slug)
}
if result.Revision != 123 {
t.Errorf("revision = %d, want 123", result.Revision)
}
}
func TestParseSVNHTML_SkipsNonEntries(t *testing.T) {
html := `<html><body><ul>
<li><a href="../">..</a></li>
<li><a href="plugin-a/">plugin-a/</a></li>
</ul></body></html>`
var entries []SVNEntry
_, err := parseSVNHTML(context.Background(), strings.NewReader(html), func(e SVNEntry) error {
entries = append(entries, e)
return nil
}, slog.Default())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
}
func TestParseSVNHTML_ContextCancelled(t *testing.T) {
html := `<html><body><ul>
<li><a href="a/">a/</a></li>
<li><a href="b/">b/</a></li>
</ul></body></html>`
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := parseSVNHTML(ctx, strings.NewReader(html), func(e SVNEntry) error {
return nil
}, slog.Default())
if err == nil {
t.Fatal("expected error for cancelled context")
}
}
func TestParseSVNRevision(t *testing.T) {
tests := []struct {
line string
want int64
}{
{`<title> - Revision 3483213: /</title>`, 3483213},
{`<h2> - Revision 3483213: /</h2>`, 3483213},
{`<title>Revision 999: /</title>`, 999},
{`<li><a href="akismet/">akismet/</a></li>`, 0},
{`no revision here`, 0},
}
for _, tt := range tests {
got := parseSVNRevision(tt.line)
if got != tt.want {
t.Errorf("parseSVNRevision(%q) = %d, want %d", tt.line, got, tt.want)
}
}
}
func TestParseSVNLogSlugs(t *testing.T) {
xml := `<?xml version="1.0" encoding="utf-8"?>
<S:log-report xmlns:S="svn:" xmlns:D="DAV:">
<S:log-item>
<D:version-name>100</D:version-name>
<S:date>2026-03-15T10:00:00.000000Z</S:date>
<S:modified-path node-kind="file">/akismet/trunk/akismet.php</S:modified-path>
<S:added-path node-kind="dir">/akismet/tags/5.0</S:added-path>
</S:log-item>
<S:log-item>
<D:version-name>101</D:version-name>
<S:date>2026-03-15T11:00:00.000000Z</S:date>
<S:modified-path node-kind="file">/jetpack/trunk/jetpack.php</S:modified-path>
<S:modified-path node-kind="file">/akismet/trunk/readme.txt</S:modified-path>
</S:log-item>
</S:log-report>`
slugRevisions, err := parseSVNLogSlugs([]byte(xml))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(slugRevisions) != 2 {
t.Fatalf("expected 2 unique slugs, got %d: %v", len(slugRevisions), slugRevisions)
}
if rev, ok := slugRevisions["akismet"]; !ok {
t.Error("expected akismet in slugs")
} else if rev != 101 {
t.Errorf("akismet revision = %d, want 101 (highest revision that touched it)", rev)
}
if rev, ok := slugRevisions["jetpack"]; !ok {
t.Error("expected jetpack in slugs")
} else if rev != 101 {
t.Errorf("jetpack revision = %d, want 101", rev)
}
}
func TestSlugFromPath(t *testing.T) {
tests := []struct {
path string
want string
}{
{"/akismet/trunk/akismet.php", "akismet"},
{"/jetpack/tags/1.0/jetpack.php", "jetpack"},
{"/my-plugin/trunk/readme.txt", "my-plugin"},
{"akismet/trunk/file.php", "akismet"},
{"/", ""},
{"", ""},
{"..", ""},
}
for _, tt := range tests {
got := slugFromPath(tt.path)
if got != tt.want {
t.Errorf("slugFromPath(%q) = %q, want %q", tt.path, got, tt.want)
}
}
}