Repository: kopia/kopia
Pull request #3157 (2023-07-18 09:38:53)

fix(ci): add test for theme selection
Dear all, 

this PR contains:

- Added test for changing themes
- Encapsulated the creation of a test repository in a function
- Added test for changing the byte representation
- Added test for changing the pagination

The PR is currently a draft. While the test for selecting different themes works, the tests for testing pagination and byte representation still needs to be finished. I was not able to extract the text within the cell, where the size of the snapshot is located. 

The idea was to get the size in both byte representations and compare them. 
Feedback is welcomed. 

Cheers, 

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/kopia/kopia/pull/3157?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) Report
Patch coverage: **`100.00`**% and project coverage change: **`+0.02`** :tada:
> Comparison is base [(`9f6b86f`)](https://app.codecov.io/gh/kopia/kopia/commit/9f6b86ff33f80f742c625483fe2e5e7e6ec0c5b7?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) 75.39% compared to head [(`bbb374a`)](https://app.codecov.io/gh/kopia/kopia/pull/3157?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) 75.41%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #3157      +/-   ##
==========================================
+ Coverage   75.39%   75.41%   +0.02%     
==========================================
  Files         459      459              
  Lines       36340    36341       +1     
==========================================
+ Hits        27399    27408       +9     
+ Misses       7028     7025       -3     
+ Partials     1913     1908       -5     
```


| [Impacted Files](https://app.codecov.io/gh/kopia/kopia/pull/3157?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia) | Coverage Δ | |
|---|---|---|
| [internal/server/server.go](https://app.codecov.io/gh/kopia/kopia/pull/3157?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia#diff-aW50ZXJuYWwvc2VydmVyL3NlcnZlci5nbw==) | `73.69% <100.00%> (+0.03%)` | :arrow_up: |

... and [9 files with indirect coverage changes](https://app.codecov.io/gh/kopia/kopia/pull/3157/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/kopia/kopia/pull/3157?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kopia).


Patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 209429e1..eab22a42 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -13,6 +13,7 @@ updates:
         patterns:
         - "cloud.google.com/*"
         - "github.com/aws/aws-sdk-go/*"
+        - "github.com/Azure/azure-sdk-for-go/sdk/*"
         - "github.com/minio/minio-go/*"
         - "golang.org/x/*"
         - "google.golang.org/*"
diff --git a/.github/workflows/providers-extra.yml b/.github/workflows/providers-extra.yml
new file mode 100644
index 00000000..4e9384fc
--- /dev/null
+++ b/.github/workflows/providers-extra.yml
@@ -0,0 +1,62 @@
+name: Providers - Extra
+on:
+  push:
+    branches: [ test/providers ]
+    tags:
+      - v*
+  schedule:
+    # twice a week on Tuesday & Thursday (UTC time)
+    - cron:  '9 7 * * 2,4'
+  workflow_dispatch:
+    inputs:
+      ref:
+        description: 'branch or git ref to use for the build'
+        required: true
+        default: 'test/providers'
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+jobs:
+  provider-test:
+    name: Extra Providers Test
+    if: ${{ github.repository == 'kopia/kopia' && !github.event.pull_request.head.repo.fork }}
+    runs-on: ubuntu-latest
+    steps:
+    - name: Check out repository
+      uses: actions/checkout@v3
+      with:
+        fetch-depth: 0
+        ref: ${{ github.event.inputs.ref_name || github.ref }}
+    - name: Set up Go
+      uses: actions/setup-go@v4
+      with:
+        go-version: '1.20'
+      id: go
+    - name: Install Dependencies
+      run: make provider-tests-deps
+    - name: B2
+      run: make provider-tests PROVIDER_TEST_TARGET=b2
+      env:
+        KOPIA_B2_TEST_BUCKET: ${{ secrets.KOPIA_B2_TEST_BUCKET }}
+        KOPIA_B2_TEST_KEY: ${{ secrets.KOPIA_B2_TEST_KEY }}
+        KOPIA_B2_TEST_KEY_ID: ${{ secrets.KOPIA_B2_TEST_KEY_ID }}
+      if: ${{ success() || failure() }}
+    - name: GDrive
+      run: make provider-tests PROVIDER_TEST_TARGET=gdrive
+      env:
+        KOPIA_GDRIVE_CREDENTIALS_JSON_GZIP: ${{ secrets.KOPIA_GDRIVE_CREDENTIALS_JSON_GZIP }}
+        KOPIA_GDRIVE_TEST_FOLDER_ID: ${{ secrets.KOPIA_GDRIVE_TEST_FOLDER_ID }}
+      if: ${{ success() || failure() }}
+    - name: Rclone
+      run: make provider-tests PROVIDER_TEST_TARGET=rclone
+      env:
+        KOPIA_RCLONE_EMBEDDED_CONFIG_B64: ${{ secrets.KOPIA_RCLONE_EMBEDDED_CONFIG_B64 }}
+      if: ${{ success() || failure() }}
+    - name: WebDAV
+      run: make provider-tests PROVIDER_TEST_TARGET=webdav
+      env:
+        KOPIA_WEBDAV_TEST_URL: ${{ secrets.KOPIA_WEBDAV_TEST_URL }}
+        KOPIA_WEBDAV_TEST_USERNAME: ${{ secrets.KOPIA_WEBDAV_TEST_USERNAME }}
+        KOPIA_WEBDAV_TEST_PASSWORD: ${{ secrets.KOPIA_WEBDAV_TEST_PASSWORD }}
+      if: ${{ success() || failure() }}
diff --git a/cli/command_policy_set_scheduling.go b/cli/command_policy_set_scheduling.go
index 2f858c92..a6c19a5f 100644
--- a/cli/command_policy_set_scheduling.go
+++ b/cli/command_policy_set_scheduling.go
@@ -14,12 +14,14 @@
 type policySchedulingFlags struct {
 	policySetInterval   []time.Duration // not a list, just optional duration
 	policySetTimesOfDay []string
+	policySetCron       string
 	policySetManual     bool
 }
 
 func (c *policySchedulingFlags) setup(cmd *kingpin.CmdClause) {
 	cmd.Flag("snapshot-interval", "Interval between snapshots").DurationListVar(&c.policySetInterval)
 	cmd.Flag("snapshot-time", "Comma-separated times of day when to take snapshot (HH:mm,HH:mm,...) or 'inherit' to remove override").StringsVar(&c.policySetTimesOfDay)
+	cmd.Flag("snapshot-time-crontab", "Semicolon-separated crontab-compatible expressions (or 'inherit')").StringVar(&c.policySetCron)
 	cmd.Flag("manual", "Only create snapshots manually").BoolVar(&c.policySetManual)
 }
 
@@ -71,6 +73,24 @@ func (c *policySchedulingFlags) setScheduleFromFlags(ctx context.Context, sp *po
 		}
 	}
 
+	if c.policySetCron != "" {
+		ce := splitCronExpressions(c.policySetCron)
+
+		if ce == nil {
+			log(ctx).Infof(" - resetting cron snapshot times to default")
+		} else {
+			log(ctx).Infof(" - setting cron snapshot times to %v", ce)
+		}
+
+		*changeCount++
+
+		sp.Cron = ce
+
+		if err := policy.ValidateSchedulingPolicy(*sp); err != nil {
+			return errors.Wrap(err, "invalid scheduling policy")
+		}
+	}
+
 	if sp.Manual {
 		*changeCount++
 
@@ -82,9 +102,32 @@ func (c *policySchedulingFlags) setScheduleFromFlags(ctx context.Context, sp *po
 	return nil
 }
 
+// splitCronExpressions splits the provided string into a list of cron expressions.
+// Individual items are separated by semi-colons. As a special case, the string "inherit"
+// returns a nil slice.
+func splitCronExpressions(expr string) []string {
+	if expr == inheritPolicyString || expr == defaultPolicyString {
+		return nil
+	}
+
+	var result []string
+
+	parts := strings.Split(expr, ";")
+	for _, part := range parts {
+		part = strings.TrimSpace(part)
+		if part == "" {
+			continue
+		}
+
+		result = append(result, part)
+	}
+
+	return result
+}
+
 func (c *policySchedulingFlags) setManualFromFlags(ctx context.Context, sp *policy.SchedulingPolicy, changeCount *int) error {
 	// Cannot set both schedule and manual setting
-	if len(c.policySetInterval) > 0 || len(c.policySetTimesOfDay) > 0 {
+	if len(c.policySetInterval) > 0 || len(c.policySetTimesOfDay) > 0 || len(c.policySetCron) > 0 {
 		return errors.New("cannot set manual field when scheduling snapshots")
 	}
 
@@ -105,6 +148,14 @@ func (c *policySchedulingFlags) setManualFromFlags(ctx context.Context, sp *poli
 		log(ctx).Infof(" - resetting snapshot times of day to default\n")
 	}
 
+	if len(sp.Cron) > 0 {
+		*changeCount++
+
+		sp.Cron = nil
+
+		log(ctx).Infof(" - resetting cron snapshot times to default\n")
+	}
+
 	*changeCount++
 
 	sp.Manual = c.policySetManual
diff --git a/cli/command_policy_show.go b/cli/command_policy_show.go
index 9d1d11c1..c44637fa 100644
--- a/cli/command_policy_show.go
+++ b/cli/command_policy_show.go
@@ -301,6 +301,16 @@ func appendSchedulingPolicyRows(rows []policyTableRow, p *policy.Policy, def *po
 		hasAny = true
 	}
 
+	if len(p.SchedulingPolicy.Cron) > 0 {
+		rows = append(rows, policyTableRow{"  Crontab expressions:", "", definitionPointToString(p.Target(), def.SchedulingPolicy.Cron)})
+
+		for _, cron := range p.SchedulingPolicy.Cron {
+			rows = append(rows, policyTableRow{"    " + cron, "", ""})
+		}
+
+		hasAny = true
+	}
+
 	if !hasAny {
 		rows = append(rows, policyTableRow{"    None.", "", ""})
 	}
diff --git a/cli/command_snapshot_create.go b/cli/command_snapshot_create.go
index be6eca39..96f7b25d 100644
--- a/cli/command_snapshot_create.go
+++ b/cli/command_snapshot_create.go
@@ -349,11 +349,10 @@ func (c *commandSnapshotCreate) reportSnapshotStatus(ctx context.Context, manife
 
 	if c.jo.jsonOutput {
 		c.out.printStdout("%s\n", c.jo.jsonIndentedBytes(manifest, "  "))
-		return nil
+	} else {
+		log(ctx).Infof("Created%v snapshot with root %v and ID %v in %v", maybePartial, manifest.RootObjectID(), snapID, manifest.EndTime.Sub(manifest.StartTime).Truncate(time.Second))
 	}
 
-	log(ctx).Infof("Created%v snapshot with root %v and ID %v in %v", maybePartial, manifest.RootObjectID(), snapID, manifest.EndTime.Sub(manifest.StartTime).Truncate(time.Second))
-
 	if ds := manifest.RootEntry.DirSummary; ds != nil {
 		if ds.IgnoredErrorCount > 0 {
 			log(ctx).Warnf("Ignored %v error(s) while snapshotting %v.", ds.IgnoredErrorCount, sourceInfo)
diff --git a/go.mod b/go.mod
index 63498e1c..e7248175 100644
--- a/go.mod
+++ b/go.mod
@@ -24,10 +24,11 @@ require (
 	github.com/google/uuid v1.3.0
 	github.com/gorilla/mux v1.8.0
 	github.com/hanwen/go-fuse/v2 v2.3.0
+	github.com/hashicorp/cronexpr v1.1.2
 	github.com/klauspost/compress v1.16.7
 	github.com/klauspost/pgzip v1.2.6
 	github.com/klauspost/reedsolomon v1.11.8
-	github.com/kopia/htmluibuild v0.0.0-20230714030926-ff7ed652d883
+	github.com/kopia/htmluibuild v0.0.0-20230716183504-d78b44b3a9bd
 	github.com/kylelemons/godebug v1.1.0
 	github.com/mattn/go-colorable v0.1.13
 	github.com/minio/minio-go/v7 v7.0.61
@@ -61,14 +62,14 @@ require (
 	golang.org/x/sys v0.10.0
 	golang.org/x/term v0.10.0
 	golang.org/x/text v0.11.0
-	google.golang.org/api v0.131.0
+	google.golang.org/api v0.132.0
 	google.golang.org/grpc v1.56.2
 	google.golang.org/protobuf v1.31.0
 	gopkg.in/kothar/go-backblaze.v0 v0.0.0-20210124194846-35409b867216
 )
 
 require (
-	cloud.google.com/go v0.110.2 // indirect
+	cloud.google.com/go v0.110.4 // indirect
 	cloud.google.com/go/compute v1.20.1 // indirect
 	cloud.google.com/go/compute/metadata v0.2.3 // indirect
 	cloud.google.com/go/iam v1.1.0 // indirect
@@ -121,9 +122,9 @@ require (
 	go.uber.org/atomic v1.11.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20230629202037-9506855d4529 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 // indirect
+	google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 5f9b8b0a..d8e5e6d0 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,7 @@
 cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA=
-cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw=
+cloud.google.com/go v0.110.4 h1:1JYyxKMN9hd5dR2MYTPWkGUgcoxVVhg0LKNKEo0qvmk=
+cloud.google.com/go v0.110.4/go.mod h1:+EYjdK8e5RME/VY/qLCAtuyALQ9q67dvuum8i+H5xsI=
 cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZNbg=
 cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM=
 cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
@@ -177,6 +177,8 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7
 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
 github.com/hanwen/go-fuse/v2 v2.3.0 h1:t5ivNIH2PK+zw4OBul/iJjsoG9K6kXo4nMDoBpciC8A=
 github.com/hanwen/go-fuse/v2 v2.3.0/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
+github.com/hashicorp/cronexpr v1.1.2 h1:wG/ZYIKT+RT3QkOdgYc+xsKWVRgnxJ1OJtjjy84fJ9A=
+github.com/hashicorp/cronexpr v1.1.2/go.mod h1:P4wA0KBl9C5q2hABiMO7cp6jcIg96CDh1Efb3g1PWA4=
 github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
@@ -192,8 +194,8 @@ github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU
 github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
 github.com/klauspost/reedsolomon v1.11.8 h1:s8RpUW5TK4hjr+djiOpbZJB4ksx+TdYbRH7vHQpwPOY=
 github.com/klauspost/reedsolomon v1.11.8/go.mod h1:4bXRN+cVzMdml6ti7qLouuYi32KHJ5MGv0Qd8a47h6A=
-github.com/kopia/htmluibuild v0.0.0-20230714030926-ff7ed652d883 h1:LX3CQvH12mNYXt+rBUqxwbwAieb9QmDrMXjKVAuMxvQ=
-github.com/kopia/htmluibuild v0.0.0-20230714030926-ff7ed652d883/go.mod h1:eWer4rx9P8lJo2eKc+Q7AZ1dE1x1hJNdkbDFPzMu1Hw=
+github.com/kopia/htmluibuild v0.0.0-20230716183504-d78b44b3a9bd h1:Vskpc00T65HkkDSWbkiXOG5yYsgWg5LN48daUfGZ+u0=
+github.com/kopia/htmluibuild v0.0.0-20230716183504-d78b44b3a9bd/go.mod h1:eWer4rx9P8lJo2eKc+Q7AZ1dE1x1hJNdkbDFPzMu1Hw=
 github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -412,8 +414,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
-google.golang.org/api v0.131.0 h1:AcgWS2edQ4chVEt/SxgDKubVu/9/idCJy00tBGuGB4M=
-google.golang.org/api v0.131.0/go.mod h1:7vtkbKv2REjJbxmHSkBTBQ5LUGvPdAqjjvt84XAfhpA=
+google.golang.org/api v0.132.0 h1:8t2/+qZ26kAOGSmOiHwVycqVaDg7q3JDILrNi/Z6rvc=
+google.golang.org/api v0.132.0/go.mod h1:AeTBC6GpJnJSRJjktDcPX0QwtS8pGYZOV6MSuSCusw0=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
@@ -422,12 +424,12 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
 google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
 google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20230629202037-9506855d4529 h1:9JucMWR7sPvCxUFd6UsOUNmA5kCcWOfORaT3tpAsKQs=
-google.golang.org/genproto v0.0.0-20230629202037-9506855d4529/go.mod h1:xZnkP7mREFX5MORlOPEzLMr+90PPZQ2QWzrVTWfAq64=
-google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529 h1:s5YSX+ZH5b5vS9rnpGymvIyMpLRJizowqDlOuyjXnTk=
-google.golang.org/genproto/googleapis/api v0.0.0-20230629202037-9506855d4529/go.mod h1:vHYtlOoi6TsQ3Uk2yxR7NI5z8uoV+3pZtR4jmHIkRig=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130 h1:2FZP5XuJY9zQyGM5N0rtovnoXjiMUEIUMvw0m9wlpLc=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:8mL13HKkDa+IuJ8yruA3ci0q+0vsUz4m//+ottjwS5o=
+google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130 h1:Au6te5hbKUV8pIYWHqOUZ1pva5qK/rwbIhoXEUB9Lu8=
+google.golang.org/genproto v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:O9kGHb51iE/nOGvQaDUuadVYqovW56s5emA88lQnj6Y=
+google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130 h1:XVeBY8d/FaK4848myy41HBqnDwvxeV3zMZhwN1TvAMU=
+google.golang.org/genproto/googleapis/api v0.0.0-20230706204954-ccb25ca9f130/go.mod h1:mPBs5jNgx2GuQGvFwUvVKqtn6HsUw9nP64BedgvqEsQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
diff --git a/internal/server/api_policies.go b/internal/server/api_policies.go
index 1a6953ca..94638b78 100644
--- a/internal/server/api_policies.go
+++ b/internal/server/api_policies.go
@@ -89,6 +89,10 @@ func handlePolicyResolve(ctx context.Context, rc requestContext) (interface{}, *
 	resp.Effective, resp.Definition = policy.MergePolicies(policies, target)
 	resp.UpcomingSnapshotTimes = []time.Time{}
 
+	if err := policy.ValidateSchedulingPolicy(policies[0].SchedulingPolicy); err != nil {
+		resp.SchedulingError = err.Error()
+	}
+
 	now := clock.Now().Local()
 
 	for i := 0; i < req.NumUpcomingSnapshotTimes; i++ {
diff --git a/internal/serverapi/serverapi.go b/internal/serverapi/serverapi.go
index 507916d0..ccbf89c1 100644
--- a/internal/serverapi/serverapi.go
+++ b/internal/serverapi/serverapi.go
@@ -269,6 +269,7 @@ type ResolvePolicyResponse struct {
 	Definition            *policy.Definition `json:"definition"`
 	Defined               *policy.Policy     `json:"defined"`
 	UpcomingSnapshotTimes []time.Time        `json:"upcomingSnapshotTimes"`
+	SchedulingError       string             `json:"schedulingError,omitempty"`
 }
 
 // ResolvePathRequest contains request to resolve a particular path to ResolvePathResponse.
diff --git a/snapshot/policy/scheduling_policy.go b/snapshot/policy/scheduling_policy.go
index ddbb63fb..7ae41914 100644
--- a/snapshot/policy/scheduling_policy.go
+++ b/snapshot/policy/scheduling_policy.go
@@ -5,8 +5,10 @@
 	"fmt"
 	"reflect"
 	"sort"
+	"strings"
 	"time"
 
+	"github.com/hashicorp/cronexpr"
 	"github.com/pkg/errors"
 
 	"github.com/kopia/kopia/repo"
@@ -59,12 +61,14 @@ type SchedulingPolicy struct {
 	TimesOfDay         []TimeOfDay `json:"timeOfDay,omitempty"`
 	NoParentTimesOfDay bool        `json:"noParentTimeOfDay,omitempty"`
 	Manual             bool        `json:"manual,omitempty"`
+	Cron               []string    `json:"cron,omitempty"`
 }
 
 // SchedulingPolicyDefinition specifies which policy definition provided the value of a particular field.
 type SchedulingPolicyDefinition struct {
 	IntervalSeconds snapshot.SourceInfo `json:"intervalSeconds,omitempty"`
 	TimesOfDay      snapshot.SourceInfo `json:"timeOfDay,omitempty"`
+	Cron            snapshot.SourceInfo `json:"cron,omitempty"`
 	Manual          snapshot.SourceInfo `json:"manual,omitempty"`
 }
 
@@ -122,6 +126,25 @@ func (p *SchedulingPolicy) NextSnapshotTime(previousSnapshotTime, now time.Time)
 		}
 	}
 
+	for _, e := range p.Cron {
+		ce, err := cronexpr.Parse(stripCronComment(e))
+		if err != nil {
+			// ignore invalid crontab entries, nothing we can do at this point
+			// we already validated cron expressions them when they were added to the policy.
+			continue
+		}
+
+		nt := ce.Next(now)
+		if nt.IsZero() {
+			continue
+		}
+
+		if !ok || nt.Before(nextSnapshotTime) {
+			nextSnapshotTime = nt
+			ok = true
+		}
+	}
+
 	return nextSnapshotTime, ok
 }
 
@@ -137,6 +160,8 @@ func (p *SchedulingPolicy) Merge(src SchedulingPolicy, def *SchedulingPolicyDefi
 		}
 	}
 
+	mergeStringList(&p.Cron, src.Cron, &def.Cron, si)
+
 	if src.NoParentTimesOfDay {
 		// prevent future merges
 		p.NoParentTimesOfDay = src.NoParentTimesOfDay
@@ -177,5 +202,17 @@ func ValidateSchedulingPolicy(p SchedulingPolicy) error {
 		return errors.New("invalid scheduling policy: manual cannot be combined with other scheduling policies")
 	}
 
+	for _, e := range p.Cron {
+		if e2 := stripCronComment(e); e2 != "" {
+			if _, err := cronexpr.Parse(e2); err != nil {
+				return errors.Errorf("invalid cron expression %q", e)
+			}
+		}
+	}
+
 	return nil
 }
+
+func stripCronComment(s string) string {
+	return strings.TrimSpace(strings.SplitN(s, "#", 2)[0]) //nolint:gomnd
+}


Test patch:
diff --git a/.github/workflows/provider-tests.yml b/.github/workflows/providers-core.yml
similarity index 64%
rename from .github/workflows/provider-tests.yml
rename to .github/workflows/providers-core.yml
index fb2662a3..6aca7161 100644
--- a/.github/workflows/provider-tests.yml
+++ b/.github/workflows/providers-core.yml
@@ -1,4 +1,4 @@
-name: Provider Test
+name: Providers - Core
 on:
   push:
     branches: [ master, test/providers ]
@@ -7,19 +7,27 @@ on:
   schedule:
     # run once per day
     - cron:  '19 7 * * *'
-concurrency: 
+  workflow_dispatch:
+    inputs:
+      ref:
+        description: 'branch or git ref to use for the build'
+        required: true
+        default: 'test/providers'
+
+concurrency:
   group: ${{ github.workflow }}-${{ github.ref }}
   cancel-in-progress: true
 jobs:
   provider-test:
-    name: Provider Test
-    if: ${{ github.repository == 'kopia/kopia' }}
+    name: Core Providers Test
+    if: ${{ github.repository == 'kopia/kopia' && !github.event.pull_request.head.repo.fork }}
     runs-on: ubuntu-latest
     steps:
     - name: Check out repository
       uses: actions/checkout@v3
       with:
         fetch-depth: 0
+        ref: ${{ github.event.inputs.ref_name || github.ref }}
     - name: Set up Go
       uses: actions/setup-go@v4
       with:
@@ -34,25 +42,12 @@ jobs:
         KOPIA_AZURE_TEST_STORAGE_ACCOUNT: ${{ secrets.KOPIA_AZURE_TEST_STORAGE_ACCOUNT }}
         KOPIA_AZURE_TEST_STORAGE_KEY: ${{ secrets.KOPIA_AZURE_TEST_STORAGE_KEY }}
         KOPIA_AZURE_TEST_SAS_TOKEN:  ${{ secrets.KOPIA_AZURE_TEST_SAS_TOKEN }}
-    - name: B2
-      run: make provider-tests PROVIDER_TEST_TARGET=b2
-      env:
-        KOPIA_B2_TEST_BUCKET: ${{ secrets.KOPIA_B2_TEST_BUCKET }}
-        KOPIA_B2_TEST_KEY: ${{ secrets.KOPIA_B2_TEST_KEY }}
-        KOPIA_B2_TEST_KEY_ID: ${{ secrets.KOPIA_B2_TEST_KEY_ID }}
-      if: ${{ success() || failure() }}
     - name: GCS
       run: make provider-tests PROVIDER_TEST_TARGET=gcs
       env:
         KOPIA_GCS_CREDENTIALS_JSON_GZIP: ${{ secrets.KOPIA_GCS_CREDENTIALS_JSON_GZIP }}
         KOPIA_GCS_TEST_BUCKET: ${{ secrets.KOPIA_GCS_TEST_BUCKET }}
       if: ${{ success() || failure() }}
-    - name: GDrive
-      run: make provider-tests PROVIDER_TEST_TARGET=gdrive
-      env:
-        KOPIA_GDRIVE_CREDENTIALS_JSON_GZIP: ${{ secrets.KOPIA_GDRIVE_CREDENTIALS_JSON_GZIP }}
-        KOPIA_GDRIVE_TEST_FOLDER_ID: ${{ secrets.KOPIA_GDRIVE_TEST_FOLDER_ID }}
-      if: ${{ success() || failure() }}
     - name: S3
       run: make provider-tests PROVIDER_TEST_TARGET=s3
       env:
@@ -69,18 +64,6 @@ jobs:
         KOPIA_S3_WASABI_CREDS: ${{ secrets.KOPIA_S3_WASABI_CREDS }}
         KOPIA_S3_WASABI_VERSIONED_CREDS: ${{ secrets.KOPIA_S3_WASABI_VERSIONED_CREDS }}
       if: ${{ success() || failure() }}
-    - name: Rclone
-      run: make provider-tests PROVIDER_TEST_TARGET=rclone
-      env:
-        KOPIA_RCLONE_EMBEDDED_CONFIG_B64: ${{ secrets.KOPIA_RCLONE_EMBEDDED_CONFIG_B64 }}
-      if: ${{ success() || failure() }}
     - name: SFTP
       run: make provider-tests PROVIDER_TEST_TARGET=sftp
       if: ${{ success() || failure() }}
-    - name: WebDAV
-      run: make provider-tests PROVIDER_TEST_TARGET=webdav
-      env:
-        KOPIA_WEBDAV_TEST_URL: ${{ secrets.KOPIA_WEBDAV_TEST_URL }}
-        KOPIA_WEBDAV_TEST_USERNAME: ${{ secrets.KOPIA_WEBDAV_TEST_USERNAME }}
-        KOPIA_WEBDAV_TEST_PASSWORD: ${{ secrets.KOPIA_WEBDAV_TEST_PASSWORD }}
-      if: ${{ success() || failure() }}
diff --git a/cli/command_policy_set_test.go b/cli/command_policy_set_test.go
index aed3d534..41241882 100644
--- a/cli/command_policy_set_test.go
+++ b/cli/command_policy_set_test.go
@@ -5,6 +5,8 @@
 	"testing"
 	"time"
 
+	"github.com/stretchr/testify/require"
+
 	"github.com/kopia/kopia/internal/testlogging"
 	"github.com/kopia/kopia/snapshot/policy"
 )
@@ -35,7 +37,6 @@ func TestSetErrorHandlingPolicyFromFlags(t *testing.T) {
 				IgnoreFileErrors:      newOptionalBool(true),
 				IgnoreDirectoryErrors: newOptionalBool(true),
 			},
-			expErr:         false,
 			expChangeCount: 0,
 		},
 		{
@@ -71,7 +72,6 @@ func TestSetErrorHandlingPolicyFromFlags(t *testing.T) {
 				IgnoreFileErrors:      nil,
 				IgnoreDirectoryErrors: nil,
 			},
-			expErr:         false,
 			expChangeCount: 2,
 		},
 		{
@@ -83,7 +83,6 @@ func TestSetErrorHandlingPolicyFromFlags(t *testing.T) {
 				IgnoreFileErrors:      newOptionalBool(true),
 				IgnoreDirectoryErrors: newOptionalBool(true),
 			},
-			expErr:         false,
 			expChangeCount: 2,
 		},
 		{
@@ -98,7 +97,6 @@ func TestSetErrorHandlingPolicyFromFlags(t *testing.T) {
 				IgnoreFileErrors:      newOptionalBool(false),
 				IgnoreDirectoryErrors: newOptionalBool(false),
 			},
-			expErr:         false,
 			expChangeCount: 2,
 		},
 		{
@@ -113,7 +111,6 @@ func TestSetErrorHandlingPolicyFromFlags(t *testing.T) {
 				IgnoreFileErrors:      newOptionalBool(false),
 				IgnoreDirectoryErrors: newOptionalBool(true),
 			},
-			expErr:         false,
 			expChangeCount: 2,
 		},
 		{
@@ -128,7 +125,6 @@ func TestSetErrorHandlingPolicyFromFlags(t *testing.T) {
 				IgnoreFileErrors:      newOptionalBool(true),
 				IgnoreDirectoryErrors: newOptionalBool(false),
 			},
-			expErr:         false,
 			expChangeCount: 2,
 		},
 		{
@@ -143,7 +139,6 @@ func TestSetErrorHandlingPolicyFromFlags(t *testing.T) {
 				IgnoreFileErrors:      nil,
 				IgnoreDirectoryErrors: newOptionalBool(true),
 			},
-			expErr:         false,
 			expChangeCount: 2,
 		},
 		{
@@ -158,7 +153,6 @@ func TestSetErrorHandlingPolicyFromFlags(t *testing.T) {
 				IgnoreFileErrors:      newOptionalBool(true),
 				IgnoreDirectoryErrors: nil,
 			},
-			expErr:         false,
 			expChangeCount: 2,
 		},
 	} {
@@ -178,8 +172,6 @@ func TestSetErrorHandlingPolicyFromFlags(t *testing.T) {
 }
 
 func TestSetSchedulingPolicyFromFlags(t *testing.T) {
-	var psf policySchedulingFlags
-
 	ctx := testlogging.Context(t)
 
 	for _, tc := range []struct {
@@ -187,16 +179,16 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 		startingPolicy *policy.SchedulingPolicy
 		intervalArg    []time.Duration
 		timesOfDayArg  []string
+		cronArg        string
 		manualArg      bool
 		expResult      *policy.SchedulingPolicy
-		expErr         bool
+		expErrMsg      string
 		expChangeCount int
 	}{
 		{
 			name:           "No flags provided, no starting policy",
 			startingPolicy: &policy.SchedulingPolicy{},
 			expResult:      &policy.SchedulingPolicy{},
-			expErr:         false,
 			expChangeCount: 0,
 		},
 		{
@@ -206,7 +198,6 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 			expResult: &policy.SchedulingPolicy{
 				Manual: true,
 			},
-			expErr:         false,
 			expChangeCount: 1,
 		},
 		{
@@ -218,7 +209,6 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 			expResult: &policy.SchedulingPolicy{
 				IntervalSeconds: 3600,
 			},
-			expErr:         false,
 			expChangeCount: 1,
 		},
 		{
@@ -235,7 +225,6 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 					},
 				},
 			},
-			expErr:         false,
 			expChangeCount: 1,
 		},
 		{
@@ -246,7 +235,7 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 			},
 			manualArg:      true,
 			expResult:      &policy.SchedulingPolicy{},
-			expErr:         true,
+			expErrMsg:      "cannot set manual field when scheduling snapshots",
 			expChangeCount: 0,
 		},
 		{
@@ -257,7 +246,16 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 			},
 			manualArg:      true,
 			expResult:      &policy.SchedulingPolicy{},
-			expErr:         true,
+			expErrMsg:      "cannot set manual field when scheduling snapshots",
+			expChangeCount: 0,
+		},
+		{
+			name:           "Manual and cron set, no starting policy",
+			startingPolicy: &policy.SchedulingPolicy{},
+			cronArg:        "* * * * *",
+			manualArg:      true,
+			expResult:      &policy.SchedulingPolicy{},
+			expErrMsg:      "cannot set manual field when scheduling snapshots",
 			expChangeCount: 0,
 		},
 		{
@@ -269,7 +267,6 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 			expResult: &policy.SchedulingPolicy{
 				Manual: true,
 			},
-			expErr:         false,
 			expChangeCount: 2,
 		},
 		{
@@ -286,7 +283,6 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 			expResult: &policy.SchedulingPolicy{
 				Manual: true,
 			},
-			expErr:         false,
 			expChangeCount: 2,
 		},
 		{
@@ -304,7 +300,6 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 			expResult: &policy.SchedulingPolicy{
 				Manual: true,
 			},
-			expErr:         false,
 			expChangeCount: 3,
 		},
 		{
@@ -318,7 +313,6 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 			expResult: &policy.SchedulingPolicy{
 				IntervalSeconds: 3600,
 			},
-			expErr:         false,
 			expChangeCount: 2,
 		},
 		{
@@ -337,7 +331,6 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 					},
 				},
 			},
-			expErr:         false,
 			expChangeCount: 2,
 		},
 		{
@@ -352,7 +345,6 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 					{Hour: 14, Minute: 0},
 				},
 			},
-			expErr:         false,
 			expChangeCount: 1,
 		},
 		{
@@ -364,36 +356,84 @@ func TestSetSchedulingPolicyFromFlags(t *testing.T) {
 			expResult: &policy.SchedulingPolicy{
 				TimesOfDay: nil,
 			},
-			expErr:         false,
+			expChangeCount: 1,
+		},
+		{
+			name:           "Set single cron expression",
+			startingPolicy: &policy.SchedulingPolicy{},
+			cronArg:        "1 2 * * *",
+			expResult: &policy.SchedulingPolicy{
+				Cron: []string{"1 2 * * *"},
+			},
+			expChangeCount: 1,
+		},
+		{
+			name:           "Set single cron expression with comment",
+			startingPolicy: &policy.SchedulingPolicy{},
+			cronArg:        "1 2 * * * # some comment",
+			expResult: &policy.SchedulingPolicy{
+				Cron: []string{"1 2 * * * # some comment"},
+			},
+			expChangeCount: 1,
+		},
+		{
+			name:           "Support comment-only cron expression",
+			startingPolicy: &policy.SchedulingPolicy{},
+			cronArg:        "# some comment;1 2 * * * ",
+			expResult: &policy.SchedulingPolicy{
+				Cron: []string{"# some comment", "1 2 * * *"},
+			},
+			expChangeCount: 1,
+		},
+		{
+			name:           "Set multiple cron expressions",
+			startingPolicy: &policy.SchedulingPolicy{},
+			cronArg:        ";1 2 * * *;;;2 1 * * *;",
+			expResult: &policy.SchedulingPolicy{
+				Cron: []string{"1 2 * * *", "2 1 * * *"},
+			},
+			expChangeCount: 1,
+		},
+		{
+			name:           "Set invalid cron expression",
+			startingPolicy: &policy.SchedulingPolicy{},
+			cronArg:        "aa bb * * *",
+			expErrMsg:      "invalid cron expression",
+			expChangeCount: 1,
+		},
+		{
+			name: "Inherit cron expressions",
+			startingPolicy: &policy.SchedulingPolicy{
+				Cron: []string{"1 2 * * *", "2 1 * * *"},
+			},
+			cronArg: "inherit",
+			expResult: &policy.SchedulingPolicy{
+				Cron: nil,
+			},
 			expChangeCount: 1,
 		},
 	} {
-		t.Log(tc.name)
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			changeCount := 0
 
-		changeCount := 0
+			var psf policySchedulingFlags
 
-		psf.policySetInterval = tc.intervalArg
-		psf.policySetTimesOfDay = tc.timesOfDayArg
-		psf.policySetManual = tc.manualArg
+			psf.policySetInterval = tc.intervalArg
+			psf.policySetTimesOfDay = tc.timesOfDayArg
+			psf.policySetManual = tc.manualArg
+			psf.policySetCron = tc.cronArg
 
-		err := psf.setSchedulingPolicyFromFlags(ctx, tc.startingPolicy, &changeCount)
-		if tc.expErr {
-			if err == nil {
-				t.Errorf("Expected error but got none")
-			}
-		} else {
-			if err != nil {
-				t.Errorf("Expected none but got err: %v", err)
+			err := psf.setSchedulingPolicyFromFlags(ctx, tc.startingPolicy, &changeCount)
+			if tc.expErrMsg != "" {
+				require.ErrorContains(t, err, tc.expErrMsg)
+				return
 			}
-		}
-
-		if !reflect.DeepEqual(tc.startingPolicy, tc.expResult) {
-			t.Errorf("Did not get expected output: (actual) %v != %v (expected)", tc.startingPolicy, tc.expResult)
-		}
 
-		if !reflect.DeepEqual(tc.expChangeCount, changeCount) {
-			t.Errorf("Did not get expected output: (actual) %v != %v (expected)", tc.expChangeCount, changeCount)
-		}
+			require.NoError(t, err)
+			require.Equal(t, tc.expResult, tc.startingPolicy)
+			require.Equal(t, tc.expChangeCount, changeCount)
+		})
 	}
 }
 
diff --git a/internal/blobtesting/object_locking_map.go b/internal/blobtesting/object_locking_map.go
index 9276d2fb..2f507b44 100644
--- a/internal/blobtesting/object_locking_map.go
+++ b/internal/blobtesting/object_locking_map.go
@@ -14,6 +14,8 @@
 	"github.com/kopia/kopia/repo/blob"
 )
 
+var ErrBlobLocked = errors.New("cannot alter object before retention period expires")
+
 type entry struct {
 	value          []byte
 	mtime          time.Time
@@ -60,7 +62,7 @@ func (s *objectLockingMap) getLatestForMutationLocked(id blob.ID) (*entry, error
 	}
 
 	if !e.retentionTime.IsZero() && e.retentionTime.After(s.timeNow()) {
-		return nil, errors.New("cannot alter object before retention period expires")
+		return nil, errors.WithStack(ErrBlobLocked)
 	}
 
 	return e, nil
diff --git a/internal/server/api_policies_test.go b/internal/server/api_policies_test.go
index 12b6e52b..13709d7e 100644
--- a/internal/server/api_policies_test.go
+++ b/internal/server/api_policies_test.go
@@ -76,6 +76,7 @@ func TestPolicies(t *testing.T) {
 		wantNeverCompress               []string
 		wantNeverCompressSource         snapshot.SourceInfo
 		wantUpcomingSnapshotTimesLength int
+		wantSchedulingError             string
 	}{
 		{
 			si:                       si0,
@@ -140,6 +141,19 @@ func TestPolicies(t *testing.T) {
 			},
 			wantUpcomingSnapshotTimesLength: 3,
 		},
+		{
+			si:                       si0,
+			wantCompressorName:       compression.Name("none"),
+			wantNeverCompress:        nil,
+			wantCompressorNameSource: policy.GlobalPolicySourceInfo,
+			wantNeverCompressSource:  policy.GlobalPolicySourceInfo,
+			updates: &policy.Policy{
+				SchedulingPolicy: policy.SchedulingPolicy{
+					Cron: []string{"invalid"},
+				},
+			},
+			wantSchedulingError: "invalid cron expression \"invalid\"",
+		},
 	}
 
 	for i, tc := range cases {
@@ -154,6 +168,7 @@ func TestPolicies(t *testing.T) {
 			require.Equal(t, tc.wantCompressorNameSource, res.Definition.CompressionPolicy.CompressorName)
 			require.Equal(t, tc.wantNeverCompressSource, res.Definition.CompressionPolicy.NeverCompress)
 			require.Len(t, res.UpcomingSnapshotTimes, tc.wantUpcomingSnapshotTimesLength)
+			require.Equal(t, tc.wantSchedulingError, res.SchedulingError)
 
 			for j, ust := range res.UpcomingSnapshotTimes {
 				require.Equal(t, ust.Truncate(60*time.Second), ust)
diff --git a/repo/blob/storage_extend_test.go b/repo/blob/storage_extend_test.go
index 1022c18a..3f48c228 100644
--- a/repo/blob/storage_extend_test.go
+++ b/repo/blob/storage_extend_test.go
@@ -6,7 +6,9 @@
 	"time"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
+	"github.com/kopia/kopia/internal/blobtesting"
 	"github.com/kopia/kopia/internal/cache"
 	"github.com/kopia/kopia/internal/faketime"
 	"github.com/kopia/kopia/internal/repotesting"
@@ -46,9 +48,7 @@ func (s *formatSpecificTestSuite) TestExtendBlobRetention(t *testing.T) {
 	env.RepositoryWriter.Flush(ctx)
 
 	blobsBefore, err := blob.ListAllBlobs(ctx, env.RepositoryWriter.BlobStorage(), "")
-	if err != nil {
-		t.Fatal(err)
-	}
+	require.NoError(t, err)
 
 	if got, want := len(blobsBefore), 4; got != want {
 		t.Fatalf("unexpected number of blobs after writing: %v", blobsBefore)
@@ -59,37 +59,31 @@ func (s *formatSpecificTestSuite) TestExtendBlobRetention(t *testing.T) {
 
 	// Verify that file is locked
 	_, err = st.TouchBlob(ctx, blobsBefore[lastBlobIdx].BlobID, time.Hour)
-	assert.EqualErrorf(t, err, "cannot alter object before retention period expires", "Altering locked object should fail")
+	assert.ErrorIs(t, err, blobtesting.ErrBlobLocked, "Altering locked object should fail")
 
 	ta.Advance(7 * 24 * time.Hour)
 
 	// Verify that file is unlocked
 	_, err = st.TouchBlob(ctx, blobsBefore[lastBlobIdx].BlobID, time.Hour)
-	if err != nil {
-		t.Fatalf("Altering expired object failed")
-	}
+	require.NoError(t, err, "Altering expired object failed")
 
 	// Relock blob
 	err = env.RepositoryWriter.BlobStorage().ExtendBlobRetention(ctx, blobsBefore[lastBlobIdx].BlobID, blob.ExtendOptions{
 		RetentionMode:   blob.Governance,
 		RetentionPeriod: 2 * time.Hour,
 	})
-	if err != nil {
-		t.Fatalf("Extending Retention time failed, got err: %v", err)
-	}
+	require.NoErrorf(t, err, "Extending Retention time failed, got err: %v", err)
 
 	// Verify Lock period
 	ta.Advance(1 * time.Hour)
 
 	_, err = st.TouchBlob(ctx, blobsBefore[lastBlobIdx].BlobID, time.Hour)
-	assert.EqualErrorf(t, err, "cannot alter object before retention period expires", "Altering locked object should fail")
+	assert.ErrorIs(t, err, blobtesting.ErrBlobLocked, "Altering locked object should fail")
 
 	ta.Advance(2 * time.Hour)
 
 	_, err = st.TouchBlob(ctx, blobsBefore[lastBlobIdx].BlobID, time.Hour)
-	if err != nil {
-		t.Fatalf("Altering expired object failed")
-	}
+	require.NoError(t, err, "Altering expired object failed")
 }
 
 func (s *formatSpecificTestSuite) TestExtendBlobRetentionUnsupported(t *testing.T) {
diff --git a/repo/maintenance/blob_retain_test.go b/repo/maintenance/blob_retain_test.go
index 9dcc4653..e72fb819 100644
--- a/repo/maintenance/blob_retain_test.go
+++ b/repo/maintenance/blob_retain_test.go
@@ -6,7 +6,9 @@
 	"time"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 
+	"github.com/kopia/kopia/internal/blobtesting"
 	"github.com/kopia/kopia/internal/cache"
 	"github.com/kopia/kopia/internal/faketime"
 	"github.com/kopia/kopia/internal/repotesting"
@@ -45,9 +47,7 @@ func (s *formatSpecificTestSuite) TestExtendBlobRetentionTime(t *testing.T) {
 	env.RepositoryWriter.Flush(ctx)
 
 	blobsBefore, err := blob.ListAllBlobs(ctx, env.RepositoryWriter.BlobStorage(), "")
-	if err != nil {
-		t.Fatal(err)
-	}
+	require.NoError(t, err)
 
 	if got, want := len(blobsBefore), 4; got != want {
 		t.Fatalf("unexpected number of blobs after writing: %v", blobsBefore)
@@ -58,17 +58,15 @@ func (s *formatSpecificTestSuite) TestExtendBlobRetentionTime(t *testing.T) {
 
 	ta.Advance(7 * 24 * time.Hour)
 
-	if _, err = st.TouchBlob(ctx, blobsBefore[lastBlobIdx].BlobID, time.Hour); err != nil {
-		t.Fatalf("Altering expired object failed")
-	}
+	_, err = st.TouchBlob(ctx, blobsBefore[lastBlobIdx].BlobID, time.Hour)
+	require.NoError(t, err, "Altering expired object failed")
 
 	// extend retention time of all blobs
-	if _, err = maintenance.ExtendBlobRetentionTime(ctx, env.RepositoryWriter, maintenance.ExtendBlobRetentionTimeOptions{}); err != nil {
-		t.Fatal(err)
-	}
+	_, err = maintenance.ExtendBlobRetentionTime(ctx, env.RepositoryWriter, maintenance.ExtendBlobRetentionTimeOptions{})
+	require.NoError(t, err)
 
 	_, err = st.TouchBlob(ctx, blobsBefore[lastBlobIdx].BlobID, time.Hour)
-	assert.EqualErrorf(t, err, "cannot alter object before retention period expires", "Altering locked object should fail")
+	assert.ErrorIs(t, err, blobtesting.ErrBlobLocked, "Altering locked object should fail")
 }
 
 func (s *formatSpecificTestSuite) TestExtendBlobRetentionTimeDisabled(t *testing.T) {
@@ -95,9 +93,7 @@ func (s *formatSpecificTestSuite) TestExtendBlobRetentionTimeDisabled(t *testing
 	env.RepositoryWriter.Flush(ctx)
 
 	blobsBefore, err := blob.ListAllBlobs(ctx, env.RepositoryWriter.BlobStorage(), "")
-	if err != nil {
-		t.Fatal(err)
-	}
+	require.NoError(t, err)
 
 	if got, want := len(blobsBefore), 4; got != want {
 		t.Fatalf("unexpected number of blobs after writing: %v", blobsBefore)
@@ -108,16 +104,13 @@ func (s *formatSpecificTestSuite) TestExtendBlobRetentionTimeDisabled(t *testing
 
 	ta.Advance(7 * 24 * time.Hour)
 
-	if _, err = st.TouchBlob(ctx, blobsBefore[lastBlobIdx].BlobID, time.Hour); err != nil {
-		t.Fatalf("Altering expired object failed")
-	}
+	_, err = st.TouchBlob(ctx, blobsBefore[lastBlobIdx].BlobID, time.Hour)
+	require.NoError(t, err, "Altering expired object failed")
 
 	// extend retention time of all blobs
-	if _, err = maintenance.ExtendBlobRetentionTime(ctx, env.RepositoryWriter, maintenance.ExtendBlobRetentionTimeOptions{}); err != nil {
-		t.Fatal(err)
-	}
+	_, err = maintenance.ExtendBlobRetentionTime(ctx, env.RepositoryWriter, maintenance.ExtendBlobRetentionTimeOptions{})
+	require.NoError(t, err)
 
-	if _, err = st.TouchBlob(ctx, blobsBefore[lastBlobIdx].BlobID, time.Hour); err != nil {
-		t.Fatalf("Altering expired object failed")
-	}
+	_, err = st.TouchBlob(ctx, blobsBefore[lastBlobIdx].BlobID, time.Hour)
+	require.NoError(t, err, "Altering expired object failed")
 }
diff --git a/snapshot/policy/scheduling_policy_test.go b/snapshot/policy/scheduling_policy_test.go
index dbbf7809..33713660 100644
--- a/snapshot/policy/scheduling_policy_test.go
+++ b/snapshot/policy/scheduling_policy_test.go
@@ -173,6 +173,24 @@ func TestNextSnapshotTime(t *testing.T) {
 			wantTime:             time.Time{},
 			wantOK:               false,
 		},
+		{
+			pol: policy.SchedulingPolicy{
+				Cron: []string{"0 23 * * *"},
+			},
+			now: time.Date(2020, time.January, 1, 10, 0, 0, 0, time.Local),
+			// matches 23:00
+			wantTime: time.Date(2020, time.January, 1, 23, 0, 0, 0, time.Local),
+			wantOK:   true,
+		},
+		{
+			pol: policy.SchedulingPolicy{
+				Cron: []string{"5 3 * Feb Thu"},
+			},
+			now: time.Date(2020, time.January, 1, 1, 0, 0, 0, time.Local),
+			// matches next Thursday in February, 3:05
+			wantTime: time.Date(2020, time.February, 6, 3, 5, 0, 0, time.Local),
+			wantOK:   true,
+		},
 	}
 
 	for i, tc := range cases {
diff --git a/tests/end_to_end_test/restore_fail_test.go b/tests/end_to_end_test/restore_fail_test.go
index da0d745a..07fd8c15 100644
--- a/tests/end_to_end_test/restore_fail_test.go
+++ b/tests/end_to_end_test/restore_fail_test.go
@@ -51,8 +51,8 @@ func TestRestoreFail(t *testing.T) {
 
 	beforeBlobList := e.RunAndExpectSuccess(t, "blob", "list")
 
-	_, errOut := e.RunAndExpectSuccessWithErrOut(t, "snapshot", "create", sourceDir)
-	parsed := parseSnapshotResult(t, errOut)
+	out, errOut := e.RunAndExpectSuccessWithErrOut(t, "snapshot", "create", sourceDir)
+	parsed := parseSnapshotResultFromLog(t, out, errOut)
 
 	afterBlobList := e.RunAndExpectSuccess(t, "blob", "list")
 
diff --git a/tests/end_to_end_test/snapshot_fail_test.go b/tests/end_to_end_test/snapshot_fail_test.go
index 993c210e..a260d098 100644
--- a/tests/end_to_end_test/snapshot_fail_test.go
+++ b/tests/end_to_end_test/snapshot_fail_test.go
@@ -14,6 +14,7 @@
 	"github.com/stretchr/testify/require"
 
 	"github.com/kopia/kopia/internal/testutil"
+	"github.com/kopia/kopia/snapshot"
 	"github.com/kopia/kopia/tests/testdirtree"
 	"github.com/kopia/kopia/tests/testenv"
 )
@@ -36,22 +37,27 @@ func TestSnapshotNonexistent(t *testing.T) {
 
 func TestSnapshotFail_Default(t *testing.T) {
 	t.Parallel()
-	testSnapshotFail(t, false, nil, nil)
+	testSnapshotFailText(t, false, nil, nil)
+}
+
+func TestSnapshotFail_DefaultJSONOutput(t *testing.T) {
+	t.Parallel()
+	testSnapshotFail(t, false, []string{"--json"}, nil, parseSnapshotResultJSON)
 }
 
 func TestSnapshotFail_EnvOverride(t *testing.T) {
 	t.Parallel()
-	testSnapshotFail(t, true, nil, map[string]string{"KOPIA_SNAPSHOT_FAIL_FAST": "true"})
+	testSnapshotFailText(t, true, nil, map[string]string{"KOPIA_SNAPSHOT_FAIL_FAST": "true"})
 }
 
 func TestSnapshotFail_NoFailFast(t *testing.T) {
 	t.Parallel()
-	testSnapshotFail(t, false, []string{"--no-fail-fast"}, nil)
+	testSnapshotFailText(t, false, []string{"--no-fail-fast"}, nil)
 }
 
 func TestSnapshotFail_FailFast(t *testing.T) {
 	t.Parallel()
-	testSnapshotFail(t, true, []string{"--fail-fast"}, nil)
+	testSnapshotFailText(t, true, []string{"--fail-fast"}, nil)
 }
 
 type expectedSnapshotResult struct {
@@ -69,8 +75,20 @@ func cond(c bool, a, b int) int {
 	return b
 }
 
+func testSnapshotFailText(t *testing.T, isFailFast bool, snapshotCreateFlags []string, snapshotCreateEnv map[string]string) {
+	t.Helper()
+
+	testSnapshotFail(t, isFailFast, snapshotCreateFlags, snapshotCreateEnv, parseSnapshotResultFromLog)
+}
+
 //nolint:thelper,cyclop
-func testSnapshotFail(t *testing.T, isFailFast bool, snapshotCreateFlags []string, snapshotCreateEnv map[string]string) {
+func testSnapshotFail(
+	t *testing.T,
+	isFailFast bool,
+	snapshotCreateFlags []string,
+	snapshotCreateEnv map[string]string,
+	parseSnapshotResultFn func(t *testing.T, stdOut, _ []string) parsedSnapshotResult,
+) {
 	if runtime.GOOS == windowsOSName {
 		t.Skip("this test does not work on Windows")
 	}
@@ -79,7 +97,7 @@ func testSnapshotFail(t *testing.T, isFailFast bool, snapshotCreateFlags []strin
 		t.Skip("this test does not work as root, because we're unable to remove permissions.")
 	}
 
-	dir0Path := "dir0"
+	const dir0Path = "dir0"
 
 	for _, ignoreFileErr := range []string{"true", "false"} {
 		for _, ignoreDirErr := range []string{"true", "false"} {
@@ -260,7 +278,7 @@ func testSnapshotFail(t *testing.T, isFailFast bool, snapshotCreateFlags []strin
 
 					e.RunAndExpectSuccess(t, "policy", "set", snapSource, "--ignore-dir-errors", tcIgnoreDirErr, "--ignore-file-errors", tcIgnoreFileErr)
 					restoreDir := fmt.Sprintf("%s%d_%v_%v", restoreDirPrefix, tcIdx, tcIgnoreDirErr, tcIgnoreFileErr)
-					testPermissions(t, e, snapSource, modifyEntry, restoreDir, tc.expectSuccess, snapshotCreateFlags, snapshotCreateEnv)
+					testPermissions(t, e, snapSource, modifyEntry, restoreDir, tc.expectSuccess, snapshotCreateFlags, snapshotCreateEnv, parseSnapshotResultFn)
 
 					e.RunAndExpectSuccess(t, "policy", "remove", snapSource)
 				})
@@ -300,7 +318,15 @@ func createSimplestFileTree(t *testing.T, dirDepth, currDepth int, currPath stri
 // It returns the number of successful snapshot operations.
 //
 //nolint:thelper
-func testPermissions(t *testing.T, e *testenv.CLITest, source, modifyEntry, restoreDir string, expect map[os.FileMode]expectedSnapshotResult, snapshotCreateFlags []string, snapshotCreateEnv map[string]string) int {
+func testPermissions(
+	t *testing.T,
+	e *testenv.CLITest,
+	source, modifyEntry, restoreDir string,
+	expect map[os.FileMode]expectedSnapshotResult,
+	snapshotCreateFlags []string,
+	snapshotCreateEnv map[string]string,
+	parseSnapshotResultFn func(_ *testing.T, _, _ []string) parsedSnapshotResult,
+) int {
 	var numSuccessfulSnapshots int
 
 	changeFile, err := os.Stat(modifyEntry)
@@ -339,13 +365,13 @@ func() {
 
 			snapshotCreateWithArgs := append([]string{"snapshot", "create", source}, snapshotCreateFlags...)
 
-			_, errOut, runErr := e.Run(t, !expected.success, snapshotCreateWithArgs...)
+			stdOut, stdErr, runErr := e.Run(t, !expected.success, snapshotCreateWithArgs...)
 
 			if got, want := (runErr == nil), expected.success; got != want {
 				t.Fatalf("unexpected success %v, want %v", got, want)
 			}
 
-			parsed := parseSnapshotResult(t, errOut)
+			parsed := parseSnapshotResultFn(t, stdOut, stdErr)
 
 			if expected.success {
 				numSuccessfulSnapshots++
@@ -362,7 +388,7 @@ func() {
 			}
 
 			if got, want := parsed.partial, expected.wantPartial; got != want {
-				t.Fatalf("unexpected partial %v, want %v (%s)", got, want, errOut)
+				t.Fatalf("unexpected partial %v, want %v (%s)", got, want, stdErr)
 			}
 		}()
 	}
@@ -384,7 +410,7 @@ type parsedSnapshotResult struct {
 	ignoredErrorCount int
 }
 
-func parseSnapshotResult(t *testing.T, lines []string) parsedSnapshotResult {
+func parseSnapshotResultFromLog(t *testing.T, _, stdErr []string) parsedSnapshotResult {
 	t.Helper()
 
 	var (
@@ -392,7 +418,7 @@ func parseSnapshotResult(t *testing.T, lines []string) parsedSnapshotResult {
 		res parsedSnapshotResult
 	)
 
-	for _, l := range lines {
+	for _, l := range stdErr {
 		if match := createdSnapshotPattern.FindStringSubmatch(l); match != nil {
 			res.partial = strings.TrimSpace(match[1]) == "partial"
 			res.rootID = match[2]
@@ -416,3 +442,23 @@ func parseSnapshotResult(t *testing.T, lines []string) parsedSnapshotResult {
 
 	return res
 }
+
+func parseSnapshotResultJSON(t *testing.T, stdOut, _ []string) parsedSnapshotResult {
+	t.Helper()
+
+	if len(stdOut) == 0 {
+		return parsedSnapshotResult{}
+	}
+
+	var m snapshot.Manifest
+
+	testutil.MustParseJSONLines(t, stdOut, &m)
+
+	return parsedSnapshotResult{
+		manifestID:        string(m.ID),
+		rootID:            m.RootEntry.ObjectID.String(),
+		errorCount:        m.RootEntry.DirSummary.FatalErrorCount,
+		ignoredErrorCount: m.RootEntry.DirSummary.IgnoredErrorCount,
+		partial:           m.IncompleteReason != "",
+	}
+}
diff --git a/tests/htmlui_e2e_test/htmlui_e2e_test.go b/tests/htmlui_e2e_test/htmlui_e2e_test.go
index b6c30094..bb9aae3e 100644
--- a/tests/htmlui_e2e_test/htmlui_e2e_test.go
+++ b/tests/htmlui_e2e_test/htmlui_e2e_test.go
@@ -111,56 +111,78 @@ func runInBrowser(t *testing.T, run func(ctx context.Context, sp *testutil.Serve
 	}
 }
 
-func TestEndToEndTest(t *testing.T) {
-	runInBrowser(t, func(ctx context.Context, sp *testutil.ServerParameters, tc *TestContext) {
-		repoPath := testutil.TempDirectory(t)
-		downloadDir := testutil.TempDirectory(t)
-		snap1Path := testutil.TempDirectory(t)
+//nolint:thelper
+func createTestSnapshot(t *testing.T, ctx context.Context, sp *testutil.ServerParameters, tc *TestContext, repoPath, snap1Path string) {
+	require.NoError(t, os.WriteFile(filepath.Join(snap1Path, "some-file.txt"), []byte("content"), 0o644))
+	f, err := os.Create(filepath.Join(snap1Path, "big.file"))
 
-		require.NoError(t, os.WriteFile(filepath.Join(snap1Path, "some-file.txt"), []byte("content"), 0o644))
+	// assert that no error occurred
+	assert.Nil(t, err)
 
-		// navigate to the base page, wait unti we're redirected to 'Repository' page
-		require.NoError(t, chromedp.Run(ctx,
-			chromedp.Navigate(sp.BaseURL),
-			chromedp.WaitVisible("button[data-testid='provider-filesystem']"),
-			tc.captureScreenshot("initial"),
+	// truncate file to 10 mb
+	err = f.Truncate(1e7)
 
-			tc.log("configuring filesystem provider"),
-			chromedp.Click("button[data-testid='provider-filesystem']"),
-			tc.captureScreenshot("filesystem-setup"),
+	// assert that no error occurred
+	assert.Nil(t, err)
 
-			tc.log("entering repo path: "+repoPath),
-			chromedp.SendKeys("input[data-testid='control-path']", repoPath+"\n"),
+	// create test repository
+	require.NoError(t, chromedp.Run(ctx,
+		chromedp.Navigate(sp.BaseURL),
+		chromedp.WaitVisible("button[data-testid='provider-filesystem']"),
+		tc.captureScreenshot("initial"),
 
-			tc.log("entering password"),
-			chromedp.SendKeys("input[data-testid='control-password']", "password1"),
-			chromedp.SendKeys("input[data-testid='control-confirmPassword']", "password1\n"),
+		tc.log("configuring filesystem provider"),
+		chromedp.Click("button[data-testid='provider-filesystem']"),
+		tc.captureScreenshot("filesystem-setup"),
 
-			tc.log("waiting for snapshot list"),
-			chromedp.WaitVisible(`a[data-testid='new-snapshot']`),
-			tc.captureScreenshot("snapshot-list"),
+		tc.log("entering repo path: "+repoPath),
+		chromedp.SendKeys("input[data-testid='control-path']", repoPath+"\n"),
 
-			tc.log("clicking new snapshot"),
-			chromedp.Click(`a[data-testid='new-snapshot']`),
+		tc.log("entering password"),
+		chromedp.SendKeys("input[data-testid='control-password']", "password1"),
+		chromedp.SendKeys("input[data-testid='control-confirmPassword']", "password1\n"),
 
-			tc.log("entering path:"+snap1Path),
-			chromedp.SendKeys(`input[name='path']`, snap1Path+"\t"),
-			chromedp.Sleep(2*time.Second),
+		tc.log("waiting for snapshot list"),
+		chromedp.WaitVisible(`a[data-testid='new-snapshot']`),
+		tc.captureScreenshot("snapshot-list"),
 
-			tc.log("clicking estimate"),
-			chromedp.Click(`button[data-testid='estimate-now']`),
-			tc.captureScreenshot("estimate-clicked"),
-			chromedp.Sleep(time.Second),
-			tc.captureScreenshot("estimate-1s"),
+		tc.log("clicking new snapshot"),
+		chromedp.Click(`a[data-testid='new-snapshot']`),
 
-			tc.log("clicking Snapshot Now"),
-			chromedp.Click(`button[data-testid='snapshot-now']`),
-			tc.captureScreenshot("snapshot-clicked"),
+		tc.log("entering path:"+snap1Path),
+		chromedp.SendKeys(`input[name='path']`, snap1Path+"\t"),
+		chromedp.Sleep(2*time.Second),
 
-			tc.log("waiting for snapshot list"),
-			chromedp.WaitVisible(`a[data-testid='new-snapshot']`),
-			tc.captureScreenshot("snapshot-list"),
+		tc.log("clicking estimate"),
+		chromedp.Click(`button[data-testid='estimate-now']`),
+		tc.captureScreenshot("estimate-clicked"),
+		chromedp.Sleep(time.Second),
+		tc.captureScreenshot("estimate-1s"),
+
+		tc.log("clicking snapshot now"),
+		chromedp.Click(`button[data-testid='snapshot-now']`),
+		tc.captureScreenshot("snapshot-clicked"),
+
+		chromedp.Navigate(sp.BaseURL),
+		chromedp.Click("a[data-testid='tab-snapshots']"),
+
+		tc.log("waiting for snapshot list"),
+		chromedp.WaitVisible(`a[data-testid='new-snapshot']`),
+		tc.captureScreenshot("snapshot-list"),
+	))
+}
+
+func TestEndToEndTest(t *testing.T) {
+	runInBrowser(t, func(ctx context.Context, sp *testutil.ServerParameters, tc *TestContext) {
+		repoPath := testutil.TempDirectory(t)
+		downloadDir := testutil.TempDirectory(t)
+		snap1Path := testutil.TempDirectory(t)
 
+		// create a test snaphot
+		createTestSnapshot(t, ctx, sp, tc, repoPath, snap1Path)
+
+		// navigate to the base page, wait unti we're redirected to 'Repository' page
+		require.NoError(t, chromedp.Run(ctx,
 			tc.log("clicking on snapshot source"),
 			chromedp.Click(`a[href*='/snapshots/single-source']`),
 			tc.captureScreenshot("snapshot-source"),
@@ -176,7 +198,7 @@ func TestEndToEndTest(t *testing.T) {
 			chromedp.Click(`a[href*='/api/v1/objects/']`),
 			tc.waitForDownload(5*time.Second),
 
-			tc.log("navigating to Repository page"),
+			tc.log("navigating to repository page"),
 			chromedp.Click("a[data-testid='tab-repo']"),
 			tc.captureScreenshot("repository"),
 
@@ -224,7 +246,7 @@ func TestConnectDisconnectReconnect(t *testing.T) {
 			chromedp.WaitVisible(`a[data-testid='new-snapshot']`),
 			tc.captureScreenshot("snapshot-list"),
 
-			tc.log("navigating to Repository page"),
+			tc.log("navigating to repository page"),
 			chromedp.Click("a[data-testid='tab-repo']"),
 			tc.captureScreenshot("repository"),
 
@@ -245,31 +267,114 @@ func TestConnectDisconnectReconnect(t *testing.T) {
 }
 
 func TestChangeTheme(t *testing.T) {
-	t.Skip("fix me")
-
 	runInBrowser(t, func(ctx context.Context, sp *testutil.ServerParameters, tc *TestContext) {
 		var nodes []*cdp.Node
 
 		require.NoError(t, chromedp.Run(ctx,
+			tc.log("navigating to base-url"),
 			chromedp.Navigate(sp.BaseURL),
+			chromedp.WaitVisible("button[data-testid='provider-filesystem']"),
+
+			tc.log("clicking on preference tab"),
+			chromedp.Click("a[data-testid='tab-preferences']", chromedp.BySearch),
+
 			chromedp.Nodes("html", &nodes),
+			tc.captureScreenshot("initial-theme"),
 		))
 
 		// ensure we start with light mode
-		if nodes[0].AttributeValue("class") == "dark" {
+		if nodes[0].AttributeValue("class") != "light" {
 			require.NoError(t, chromedp.Run(ctx,
-				chromedp.Click("button[data-testid='toggle-dark-mode']"),
+				tc.log("selecting light-theme before starting the test"),
+				chromedp.SetValue(`//select[@class="select_theme, form-select form-select-sm"]`, "light", chromedp.BySearch),
 			))
 		}
 
+		// select the theme, wait one second, take screenshot and select next theme
 		require.NoError(t, chromedp.Run(ctx,
 			chromedp.WaitVisible("html.light"),
-			chromedp.Click("button[data-testid='toggle-dark-mode']"),
+			tc.log("selecting pastel theme"),
+			chromedp.SetValue(`//select[@class="select_theme, form-select form-select-sm"]`, "pastel", chromedp.BySearch),
+			chromedp.Sleep(time.Second),
+			chromedp.WaitVisible("html.pastel"),
+			tc.captureScreenshot("theme-pastel"),
+
+			tc.log("selecting dark theme"),
+			chromedp.SetValue(`//select[@class="select_theme, form-select form-select-sm"]`, "dark", chromedp.BySearch),
 			chromedp.WaitVisible("html.dark"),
-			chromedp.Click("button[data-testid='toggle-dark-mode']"),
+			chromedp.Sleep(time.Second),
+			tc.captureScreenshot("theme-dark"),
+
+			tc.log("selecting ocean theme"),
+			chromedp.SetValue(`//select[@class="select_theme, form-select form-select-sm"]`, "ocean", chromedp.BySearch),
+			chromedp.WaitVisible("html.ocean"),
+			chromedp.Sleep(time.Second),
+			tc.captureScreenshot("theme-ocean"),
+
+			tc.log("selecting light theme"),
+			chromedp.SetValue(`//select[@class="select_theme, form-select form-select-sm"]`, "light", chromedp.BySearch),
 			chromedp.WaitVisible("html.light"),
-			chromedp.Click("button[data-testid='toggle-dark-mode']"),
-			chromedp.WaitVisible("html.dark"),
+			chromedp.Sleep(time.Second),
+			tc.captureScreenshot("theme-light"),
+		))
+	})
+}
+
+func TestByteRepresentation(t *testing.T) {
+	runInBrowser(t, func(ctx context.Context, sp *testutil.ServerParameters, tc *TestContext) {
+		repoPath := testutil.TempDirectory(t)
+		snap1Path := testutil.TempDirectory(t)
+
+		var base2 string
+		var base10 string
+
+		// create a test snaphot
+		createTestSnapshot(t, ctx, sp, tc, repoPath, snap1Path)
+
+		// begin test
+		require.NoError(t, chromedp.Run(ctx,
+			tc.log("navigating to preferences tab"),
+			chromedp.Click("a[data-testid='tab-preferences']", chromedp.BySearch),
+			tc.captureScreenshot("initial"),
+
+			tc.log("selecting representation to base-2"),
+			chromedp.SetValue(`//select[@id="bytesBaseInput"]`, "true", chromedp.BySearch),
+			tc.captureScreenshot("base-2"),
+
+			tc.log("clicking on snapshots tab"),
+			chromedp.Click("a[data-testid='tab-snapshots']", chromedp.BySearch),
+			// getting text from the third column of the first row indicating the size of the snapshot
+			chromedp.Text(`#root > div > table > tbody > tr:nth-child(1) > td:nth-child(3)`, &base2, chromedp.ByQuery),
+			tc.captureScreenshot("snapshot-base-2"),
+
+			tc.log("clicking on preferences tab"),
+			chromedp.Click("a[data-testid='tab-preferences']", chromedp.BySearch),
+
+			tc.log("selecting representation to base-10"),
+			chromedp.SetValue(`//select[@id="bytesBaseInput"]`, "false", chromedp.BySearch),
+			tc.captureScreenshot("base-10"),
+
+			tc.log("clicking on snapshots tab"),
+			chromedp.Click("a[data-testid='tab-snapshots']", chromedp.BySearch),
+			// getting text from the third column of the first row indicating the size of the snapshot
+			chromedp.Text(`#root > div > table > tbody > tr:nth-child(1) > td:nth-child(3)`, &base10, chromedp.BySearch),
+			tc.captureScreenshot("snapshot-base-10"),
 		))
+
+		// check result of base-2 representation
+		require.Equal(t, "9.5 MiB", base2, "Expected 9.5 MiB as a result.")
+		// check result of base-10 representation
+		require.Equal(t, "10 MB", base10, "Expected 10 MB as a result.")
+	})
+}
+
+func TestPagination(t *testing.T) {
+	t.Skip("Test needs proper ID for pagination button")
+	runInBrowser(t, func(ctx context.Context, sp *testutil.ServerParameters, tc *TestContext) {
+		repoPath := testutil.TempDirectory(t)
+		snap1Path := testutil.TempDirectory(t)
+
+		// create a test snaphot
+		createTestSnapshot(t, ctx, sp, tc, repoPath, snap1Path)
 	})
 }