diff --git a/internal/distill/distill.go b/internal/distill/distill.go
index f166ac1..a9b80be 100644
--- a/internal/distill/distill.go
+++ b/internal/distill/distill.go
@@ -529,7 +529,21 @@ func (d *Distiller) Distill(ctx context.Context, rawURL string) (*Result, error)
d.adoptServed(g, staticRes)
timing["total"] = time.Since(start)
d.progress(Progress{Stage: "done", Tier: escalate.TierFetch, Elapsed: time.Since(start)})
- return &Result{Graph: g, Freshness: freshness, Decision: decision, Timing: timing}, nil
+ // The capture and the served bytes travel with the result here as they
+ // do on every other path.
+ //
+ // This one returned neither, and it is the path most pages take: a
+ // tier-0 answer is the commonest answer sieve gives. The visible effect
+ // was that -snapshot wrote a file containing nothing but a URL, so the
+ // pages whose bugs are cheapest to reproduce were exactly the ones that
+ // could not be attached to a report -- and replay refused the file with
+ // "contains no capture", which read like a corrupt snapshot rather than
+ // a snapshot that was never given anything to hold.
+ return &Result{
+ Graph: g, Freshness: freshness, Decision: decision, Timing: timing,
+ Capture: staticRes.Merged, StaticHTML: staticRes.RawHTML,
+ Status: int64(resp.Status),
+ }, nil
}
// --- Tiers 1-3 ----------------------------------------------------------
diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go
index 6ae08a8..fa8358c 100644
--- a/internal/snapshot/snapshot.go
+++ b/internal/snapshot/snapshot.go
@@ -36,6 +36,7 @@ import (
"github.com/qcoderx/sieve/internal/capture"
"github.com/qcoderx/sieve/internal/graph"
+ "github.com/qcoderx/sieve/internal/static"
)
// FormatVersion is bumped when the snapshot layout changes incompatibly.
@@ -69,7 +70,7 @@ type Snapshot struct {
// Redacted records that content was removed before writing. A snapshot from
// an authenticated session must never be attachable to a public bug report
// with the session's contents intact.
- Redacted bool `json:"redacted,omitempty"`
+ Redacted bool `json:"redacted,omitempty"`
RedactionReason string `json:"redaction_reason,omitempty"`
}
@@ -163,8 +164,15 @@ func Read(path string) (*Snapshot, error) {
return nil, fmt.Errorf("%s was written by a newer sieve (format %d, this build understands %d)",
path, s.FormatVersion, FormatVersion)
}
- if s.Merged == nil {
- return nil, fmt.Errorf("%s contains no capture", path)
+ // A snapshot of a page answered without a browser has no capture, and that
+ // is not a broken file: tier 0 is the commonest answer sieve gives, and the
+ // graph stage runs over the served HTML there exactly as it runs over a
+ // rendered one. Refusing it meant a snapshot could not be taken of the very
+ // pages whose bugs are cheapest to reproduce -- the text welding and reading
+ // order defects fixed in this branch were all tier-0 defects, and none of
+ // them could have been attached to a report.
+ if s.Merged == nil && s.StaticHTML == "" {
+ return nil, fmt.Errorf("%s contains neither a capture nor the served HTML", path)
}
return &s, nil
}
@@ -178,6 +186,20 @@ func Replay(s *Snapshot, in graph.Input) (*graph.Graph, error) {
in.RequestedURL = s.RequestedURL
in.FinalURL = s.FinalURL
in.Merged = s.Merged
+ // A page answered at tier 0 has no capture, only the bytes the server sent.
+ // Re-running the static extraction over those bytes reproduces the same
+ // input the live run built its graph from, which is what makes a tier-0 bug
+ // reproducible offline at all.
+ if in.Merged == nil && s.StaticHTML != "" {
+ res, err := static.Extract(s.FinalURL, strings.NewReader(s.StaticHTML), len(s.StaticHTML))
+ if err != nil {
+ return nil, fmt.Errorf("re-extract served HTML: %w", err)
+ }
+ in.Merged = res.Merged
+ }
+ if in.Merged == nil {
+ return nil, fmt.Errorf("snapshot contains neither a capture nor the served HTML")
+ }
if in.Notes == nil {
in.Notes = s.Notes
}
diff --git a/internal/snapshot/snapshot_test.go b/internal/snapshot/snapshot_test.go
new file mode 100644
index 0000000..34a9dfd
--- /dev/null
+++ b/internal/snapshot/snapshot_test.go
@@ -0,0 +1,107 @@
+package snapshot_test
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/qcoderx/sieve/internal/graph"
+ "github.com/qcoderx/sieve/internal/snapshot"
+ "github.com/qcoderx/sieve/internal/static"
+)
+
+const servedPage = `
+
Halstead Instruments
+
+
+
+
Halstead Instruments
+
We restore and calibrate laboratory balances, and hold the only
+ remaining stock of knife edges for the Oertling range.
+
Workshop
+
Calibration is traceable and certificates are issued the same week.
+ A restoration takes eight to twelve weeks depending on what we find
+ once the case is open.
+
+`
+
+// TestTierZeroSnapshotRoundTrips covers the pages a snapshot was least able to
+// describe: the ones answered without a browser.
+//
+// A tier-0 answer is the commonest answer sieve gives, and the result for that
+// path carried neither the capture nor the served bytes -- so -snapshot wrote a
+// file containing little more than a URL, and replay rejected it with
+// "contains no capture", which reads like a corrupt file rather than one that
+// was never given anything to hold. The effect was that the pages whose bugs
+// are cheapest to reproduce were exactly the ones no one could attach to a
+// report; every text-welding and reading-order defect fixed in this branch was
+// a tier-0 defect.
+func TestTierZeroSnapshotRoundTrips(t *testing.T) {
+ res, err := static.Extract("https://example.com/", strings.NewReader(servedPage), len(servedPage))
+ if err != nil {
+ t.Fatalf("extract: %v", err)
+ }
+
+ in := graph.Input{
+ RequestedURL: "https://example.com/",
+ FinalURL: "https://example.com/",
+ Merged: res.Merged,
+ Generator: "sieve/test",
+ }
+ live, err := graph.Build(in)
+ if err != nil {
+ t.Fatalf("build: %v", err)
+ }
+ if len(live.Blocks) == 0 {
+ t.Fatal("fixture produced no blocks; the test would prove nothing")
+ }
+
+ // A snapshot as the tier-0 path records one: served HTML, no capture.
+ path := filepath.Join(t.TempDir(), "tier0.sieve")
+ if err := snapshot.Write(path, &snapshot.Snapshot{
+ RequestedURL: "https://example.com/",
+ FinalURL: "https://example.com/",
+ StaticHTML: servedPage,
+ }, snapshot.WriteOptions{IncludeHTML: true}); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+
+ got, err := snapshot.Read(path)
+ if err != nil {
+ t.Fatalf("read: %v", err)
+ }
+ rebuilt, err := snapshot.Replay(got, graph.Input{Generator: "sieve/test"})
+ if err != nil {
+ t.Fatalf("replay: %v", err)
+ }
+
+ if len(rebuilt.Blocks) != len(live.Blocks) {
+ t.Fatalf("replay produced %d blocks, live produced %d",
+ len(rebuilt.Blocks), len(live.Blocks))
+ }
+ for i := range live.Blocks {
+ if live.Blocks[i].Text != rebuilt.Blocks[i].Text {
+ t.Errorf("block %d differs:\n live=%q\n repl=%q",
+ i, live.Blocks[i].Text, rebuilt.Blocks[i].Text)
+ }
+ }
+ if live.ContentHash != rebuilt.ContentHash {
+ t.Errorf("content hash differs: the graph stage is not a pure function "+
+ "of the snapshot\n live=%s\n repl=%s", live.ContentHash, rebuilt.ContentHash)
+ }
+}
+
+// TestSnapshotWithNothingInItIsRefused keeps the error honest: a file that
+// carries neither a capture nor the served bytes cannot be replayed, and should
+// say so rather than appearing to work.
+func TestSnapshotWithNothingInItIsRefused(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "empty.sieve")
+ if err := snapshot.Write(path, &snapshot.Snapshot{
+ RequestedURL: "https://example.com/",
+ }, snapshot.WriteOptions{}); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ if _, err := snapshot.Read(path); err == nil {
+ t.Error("a snapshot with no capture and no HTML was accepted")
+ }
+}