Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ jobs:
# in somebody else's file.
run: |
set -euo pipefail
watched='internal/format/registry.go cmd/tfg/main.go internal/gui/window/run.go internal/audit/parallel.go internal/engine/parallel.go go.mod'
watched='internal/format/registry.go internal/damage/damage.go cmd/tfg/main.go internal/gui/window/run.go internal/audit/parallel.go internal/engine/parallel.go go.mod'
# On a pull request there is no "before" - the field belongs to a push
# - so this asked for something empty and every pull request answered
# "touched". That quietly undid the decision of 2026-08-20, because
Expand Down
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ because it turns other people's test suites red.

### Added

- **Files can be broken on purpose.** A target takes `damage`, and the files it
produces are ones a reader refuses:

```yaml
targets:
- id: broken-uploads
format: png
size: 20kb
count: 10
damage: [zero-head]
```

or from the command line, repeatable and applied in the order given:

```
tfg generate --format png --size 20kb --damage zero-head:bytes=16
```

Until now every file this tool wrote was well formed, so the third question
an upload validator asks - "does it open" - was one nothing here could put to
it.

The file still comes out **exactly** the size you asked for. What changes is
the content, and the manifest records what was done to it and says the file
is expected to be rejected.

There is one damage in this release, `zero-head`, which overwrites the
opening bytes with zeros. It was chosen because all twenty four formats have
something that refuses the result - measured, not assumed - and because it
does not change the length. `tfg formats` is unchanged and no existing file
moves a byte: a run that does not ask for damage goes down the path it always
did.

Two things it refuses rather than doing quietly. A file smaller than the
damage is refused before anything is written, naming a size that would work.
And a damage that would leave the bytes untouched stops the run, because a
whole file described as broken is worse than no file at all.

- **HTML files can be a fragment instead of a whole page.** A new setting on
`html`: `structure`, which takes `document` or `fragment`. It defaults to the
whole page these files have always been, so a recipe that says nothing gets
Expand Down
65 changes: 65 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"strings"
"syscall"

"github.com/donislawdev/TestingFilesGenerator/internal/damage"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/all"
Expand Down Expand Up @@ -268,6 +269,70 @@ func (p propertyFlag) Set(v string) error {
return nil
}

// repeatedFlags are the flags a person may write more than once.
//
// One piece rather than two fields on the options struct, because the type was
// at the crowding band and the answer to that is to move state out. They belong
// together anyway: both map onto a block of a recipe rather than onto a single
// line of one.
type repeatedFlags struct {
props propertyFlag
damage damageFlag
}

// damageFlag collects repeated --damage entries, in the order they were given.
//
// A list rather than a map, which is the difference from --set beside it:
// order is part of what a chain of damages means, and the same damage twice
// with different settings is a legitimate thing to ask for. --set refuses a
// repeat because one of two values would be lost silently, and here neither is.
//
// The settings ride after a colon so one entry stays one argument:
//
// --damage zero-head
// --damage zero-head:bytes=16
// --damage zero-head:bytes=16,other=2
type damageFlag struct{ chain damage.Chain }

func (d *damageFlag) String() string { return d.chain.String() }

func (d *damageFlag) Set(v string) error {
id, settings, hasSettings := strings.Cut(v, ":")
if id == "" {
return fmt.Errorf("expected the name of a damage, got %q", v)
}
values, err := damageSettings(id, settings, hasSettings)
if err != nil {
return err
}
d.chain = append(d.chain, damage.Spec{ID: id, Values: values})
return nil
}

// damageSettings reads the name=value pairs after the colon.
//
// Its own function rather than a block inside Set, because the shape gates
// count how deep a reader has to follow and this was the third level.
func damageSettings(id, settings string, stated bool) (damage.Values, error) {
values := damage.Values{}
if !stated {
return values, nil
}
for _, pair := range strings.Split(settings, ",") {
key, value, found := strings.Cut(pair, "=")
if !found || key == "" {
return nil, fmt.Errorf("expected name=value after the colon, got %q", pair)
}
if _, exists := values[key]; exists {
// The same reason --set gives: one of the two would be lost and
// nobody would know which.
return nil, fmt.Errorf("%s is set more than once on %s", key, id)
}
values[key] = value
}
return values, nil
}

// args2 rebuilds the command as it would have to be typed to run again.
//
// It goes into the manifest, where its whole job is to be re-runnable, and it
Expand Down
16 changes: 16 additions & 0 deletions internal/cli/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"syscall"

"github.com/donislawdev/TestingFilesGenerator/internal/audit"
"github.com/donislawdev/TestingFilesGenerator/internal/damage"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
Expand Down Expand Up @@ -201,6 +202,21 @@ func classifyRequest(err error) (int, bool) {
if errors.As(err, &unknownPreset) {
return ExitUsage, true
}
// A damage this build does not know is a typo in the invocation, the same
// class as an unknown preset - a recipe naming one is refused while the
// recipe is read, so anything reaching here came off the command line.
var unknownDamage *damage.UnknownError
if errors.As(err, &unknownDamage) {
return ExitUsage, true
}
// A file smaller than the damage it was given is the same class as a size
// below a format's minimum: the request is well formed and nothing here can
// deliver it. It gets the same code for that reason rather than by
// resemblance.
var tooSmall *damage.TooSmallError
if errors.As(err, &tooSmall) {
return ExitFormat, true
}
if code, ok := classifyFormat(err); ok {
return code, true
}
Expand Down
34 changes: 24 additions & 10 deletions internal/cli/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"

"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/damage"
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
Expand All @@ -37,7 +38,11 @@ type generateOpts struct {
clean bool
dryRun bool
asJSON bool
props propertyFlag
// The flags a person may write more than once, in one piece rather than
// two fields. They are one thing on the screen and one thing in a recipe -
// what was stated repeatedly - and grouping them is what moving state out
// means when a type is at the crowding band.
repeated repeatedFlags
}

func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io.Writer)) {
Expand Down Expand Up @@ -65,8 +70,19 @@ func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io.
// Twenty five formats with a dozen properties each would give a surface
// nobody reads in --help, and this maps one to one onto the properties
// block of a recipe, so both surfaces speak the same words.
g.props = propertyFlag{}
fs.Var(&g.props, "set", "format property, repeatable: --set width=1920 --set height=1080")
g.repeated.props = propertyFlag{}
fs.Var(&g.repeated.props, "set", "format property, repeatable: --set width=1920 --set height=1080")

// Repeatable like --set, and for the same reason, but a list rather than a
// map: the order damages are applied in is part of what they mean.
// The names come from the registry rather than being typed here. The first
// version of this line ended "run tfg damage to see what there is" and
// there is no such command - a sentence in shipped help promising
// something that does not exist, which is the class this project calls
// prose with an expiry date. Built from Names() it cannot say that again.
fs.Var(&g.repeated.damage, "damage", "break the files on purpose, repeatable and applied in order: "+
"--damage zero-head, or --damage zero-head:bytes=16. This build has: "+
strings.Join(damage.Names(), ", "))

usage := func(w io.Writer) {
fmt.Fprint(w, `tfg generate - produce files.
Expand Down Expand Up @@ -312,15 +328,14 @@ func targetsFromFlags(g *generateOpts, given map[string]bool, errOut io.Writer)
ID: g.id,
Format: g.formatID,
Sizes: sizes,
SizeIsRange: g.sizeRange != "",
SizeMin: rangeLow,
SizeMax: rangeHigh,
Range: engine.SizeRange{Used: g.sizeRange != "", Min: rangeLow, Max: rangeHigh},
BoundaryLimit: boundaryLimit,
NameTmpl: g.name,
Label: !g.clean,
Expected: g.expected,
ExpectedReason: g.expectedReason,
Properties: g.props,
Properties: g.repeated.props,
Damage: g.repeated.damage.chain,
}}, ExitOK
}

Expand Down Expand Up @@ -599,16 +614,15 @@ func engineTarget(t recipe.Target, label bool) engine.Target {
Sizes: t.Sizes,
Contains: contentsOf(t),
SizeFromContents: t.SizeFromContents,
SizeIsRange: t.SizeIsRange,
SizeMin: t.SizeMin,
SizeMax: t.SizeMax,
Range: engine.SizeRange(t.Range),
BoundaryLimit: t.BoundaryLimit,
NameTmpl: t.Name,
Label: label,
Expected: t.Expected,
ExpectedReason: t.ExpectedReason,
Group: t.Group,
Properties: t.Properties,
Damage: t.Damage,
}
}

Expand Down
Loading
Loading