tape_deck 0.1.0.beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.devcontainer/compose.yml +33 -0
- data/.devcontainer/devcontainer.json +13 -0
- data/.yardopts +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/DEVELOPMENT.md +86 -0
- data/LICENSE.txt +21 -0
- data/README.md +158 -0
- data/Rakefile +85 -0
- data/lib/tape_deck/configuration.rb +113 -0
- data/lib/tape_deck/errors.rb +54 -0
- data/lib/tape_deck/filter.rb +72 -0
- data/lib/tape_deck/patterns.rb +21 -0
- data/lib/tape_deck/recorder.rb +270 -0
- data/lib/tape_deck/session.rb +100 -0
- data/lib/tape_deck/tape.rb +152 -0
- data/lib/tape_deck/version.rb +6 -0
- data/lib/tape_deck.rb +295 -0
- data/sig/tape_deck.rbs +4 -0
- metadata +95 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e3e52134a29b1bcec60211e70c2632b5bcf6e948977738bfc3c05419517223a9
|
|
4
|
+
data.tar.gz: 377b07a9f6ead12778bda7553b314bf29f800fcdba8d182f284a67289014c2cf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b5ae3af854d5d773d37b5cba103ec38721a5e6d7ccc8a77120a24c1e305a0362f945a6e61e86bbbd918fc3ce1fb9a4b8028d72a01a03b8c5dc55a84c657be032
|
|
7
|
+
data.tar.gz: 538704dde3a6796d74365ba092156239ee4bb34a1c7478147338dfdd7410f4d6aa3e164223ef8f08c8297d2dcbb571b195c0dd3ad73a0b0cf44da58432c372c2
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: tape_deck
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
app:
|
|
5
|
+
image: ruby:4.0
|
|
6
|
+
command: sleep infinity
|
|
7
|
+
working_dir: /workspaces/tape_deck
|
|
8
|
+
volumes:
|
|
9
|
+
- ..:/workspaces/tape_deck
|
|
10
|
+
- bundle:/usr/local/bundle
|
|
11
|
+
environment:
|
|
12
|
+
BUNDLE_WITH: mysql
|
|
13
|
+
TZ: UTC
|
|
14
|
+
TAPE_DECK_MYSQL_URL: ${TAPE_DECK_MYSQL_URL:-mysql2://root:tape_deck@mysql}
|
|
15
|
+
depends_on:
|
|
16
|
+
mysql:
|
|
17
|
+
condition: service_healthy
|
|
18
|
+
|
|
19
|
+
mysql:
|
|
20
|
+
image: ${MYSQL_IMAGE:-mysql:8.4}
|
|
21
|
+
environment:
|
|
22
|
+
MYSQL_ROOT_PASSWORD: tape_deck
|
|
23
|
+
tmpfs:
|
|
24
|
+
- /var/lib/mysql
|
|
25
|
+
healthcheck:
|
|
26
|
+
# MariaDB images only have mariadb-admin
|
|
27
|
+
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -ptape_deck || mariadb-admin ping -h 127.0.0.1 -ptape_deck"]
|
|
28
|
+
interval: 2s
|
|
29
|
+
timeout: 5s
|
|
30
|
+
retries: 30
|
|
31
|
+
|
|
32
|
+
volumes:
|
|
33
|
+
bundle:
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tape_deck",
|
|
3
|
+
"dockerComposeFile": "compose.yml",
|
|
4
|
+
"service": "app",
|
|
5
|
+
"workspaceFolder": "/workspaces/tape_deck",
|
|
6
|
+
"shutdownAction": "stopCompose",
|
|
7
|
+
"postCreateCommand": "bundle install",
|
|
8
|
+
"customizations": {
|
|
9
|
+
"vscode": {
|
|
10
|
+
"extensions": ["Shopify.ruby-lsp"]
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"tape_deck" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["robert@dotless.de"](mailto:"robert@dotless.de").
|
data/DEVELOPMENT.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Development Guidelines
|
|
2
|
+
|
|
3
|
+
- Keep it simple.
|
|
4
|
+
- Keep the module boundaries clean, each piece has one job.
|
|
5
|
+
|
|
6
|
+
## Design
|
|
7
|
+
|
|
8
|
+
| Piece | Job |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `Configuration` | Plain settings: `tape_library_dir`, `databases`, `default_record_mode`, `on_schema_change`, exclude patterns, RSpec hook |
|
|
11
|
+
| `Tape` | File-like. Knows its name, path and mode (`recording?` / `verifying?`), can `read` and `write(diff, recorded:)`. Never touches a database |
|
|
12
|
+
| `Recorder` | Observes databases: `snapshot` and `diff`. Knows nothing about tapes or configuration |
|
|
13
|
+
| `Filter` | Narrows a diff to what one use of a tape wants compared (`only:` / `ignore:`). `@api private` |
|
|
14
|
+
| `Session` | One run of one tape: "before" snapshot on creation, `finish` captures the diff and records or verifies. `@api private` |
|
|
15
|
+
|
|
16
|
+
`TapeDeck.insert_tape` is the only place that wires these together. `TapeDeck.use_tape` wraps insert and eject around a block. Sessions are kept on a stack, so tapes can be nested.
|
|
17
|
+
|
|
18
|
+
Decisions made on purpose. Please don't undo them without discussing first:
|
|
19
|
+
|
|
20
|
+
- **Exclude vs ignore.** `exclude_tables` / `exclude_columns` (configuration) mean never recorded. `only:` / `ignore:` (per tape use) mean recorded but not compared, applied to both sides when verifying and not at all when recording. There is deliberately no global verify-time ignore.
|
|
21
|
+
- **Record modes** are `:once`, `:verify`, `:overwrite`. Renamed from VCR's `:once`, `:none`, `:all`.
|
|
22
|
+
- **Vocabulary.** "record", "verify", "discard", "capture". Not "playback", not "compare" as a mode name.
|
|
23
|
+
- **Errors** live directly under `TapeDeck` (`Error`, `TapeLoadingError`, `IncompatibleTapeError`, `UnexpectedDiffError`), not in an `Errors` module.
|
|
24
|
+
- **Manual `insert_tape` / `eject_tape` work like `File.open` without a block**: no automatic tear down. The caller discards a tape whose action failed, and ejects outer tapes when an inner eject raises. `use_tape` and the RSpec tag do both.
|
|
25
|
+
- **Options are keywords.** Unknown keywords raise.
|
|
26
|
+
- **A tape stores what was recorded**: the exclude patterns (once, they are global), the watched databases, and the resolved names (`<table>`, `<table>.<column>`) the patterns left out of each.
|
|
27
|
+
- A tape must cover the run that verifies it: the watched databases are a subset of the recorded ones, and everything the tape left out is still excluded under the current rules. What is excluded now but on the tape is dropped from both sides before comparing.
|
|
28
|
+
- Schema changes: the recorded patterns, applied to the current schema, must resolve to the recorded names. A difference means a matching table or column was added or removed. `on_schema_change` decides between error, warning and nothing.
|
|
29
|
+
- **Primary key columns are never excluded**, even if a pattern matches, because rows are matched by them.
|
|
30
|
+
- **Snapshots reuse unchanged tables.** `CHECKSUM TABLE` runs before and after. A table whose checksum is unchanged reuses the first snapshot object, and `diff` skips it by identity (`equal?`).
|
|
31
|
+
- **Tables without a primary key** are compared as a multiset of rows (inserted / deleted only).
|
|
32
|
+
- **Binary values**: valid UTF-8 becomes a plain UTF-8 string, anything else is hex up to 64 bytes and a `sha256:` digest above that. Always a plain `String`, never a subclass, so `YAML.safe_load` can read the tape.
|
|
33
|
+
- **TIME columns** are stored as a time of day, recognized by their database type (`Recorder::TIME_DB_TYPE`), because Sequel types `time` as `:time` but not `time(6)`. `mysql2` returns them as a `Time` on 2000-01-01, which would otherwise be stored as a timestamp.
|
|
34
|
+
|
|
35
|
+
## Code style
|
|
36
|
+
|
|
37
|
+
- [Standard](https://github.com/standardrb/standard), run with `bundle exec rake standard`
|
|
38
|
+
- YARD docs, brief, where useful. Private helpers only get a comment when the name is not enough. Keep `yard stats` at 100 percent.
|
|
39
|
+
- Comment lines up to 120 characters
|
|
40
|
+
- Prefer `if ... else` over the ternary operator unless trivial
|
|
41
|
+
- Extract dense expressions into named variables, constants or methods
|
|
42
|
+
|
|
43
|
+
## Testing
|
|
44
|
+
|
|
45
|
+
- `bundle exec rake spec` runs everything: the fake specs, then the MySQL specs once per adapter if `TAPE_DECK_MYSQL_URL` is set
|
|
46
|
+
- `bundle exec rake spec:fake` needs no server. In-memory SQLite through Sequel, `spec/support/fake_mysql.rb` answers `CHECKSUM TABLE`, everything else is real Sequel.
|
|
47
|
+
- `bundle exec rake spec:mysql2` / `spec:trilogy` run `spec/integration/mysql_spec.rb` (tagged `:mysql`) with that adapter
|
|
48
|
+
|
|
49
|
+
`TAPE_DECK_MYSQL_URL` is a server URL without a database, e.g. `mysql2://root:tape_deck@mysql`. The adapter tasks replace its scheme with their own. The MySQL specs create and drop `tape_deck_shop` and `tape_deck_billing`.
|
|
50
|
+
|
|
51
|
+
The devcontainer (`.devcontainer/`) has Ruby and MySQL 8.4, with `TAPE_DECK_MYSQL_URL` already set. Without VS Code:
|
|
52
|
+
|
|
53
|
+
docker compose -f .devcontainer/compose.yml run --rm app sh -c "bundle install && bundle exec rake spec"
|
|
54
|
+
|
|
55
|
+
The server is MySQL 8.4 by default. Set `MYSQL_IMAGE` for another one, e.g. `MYSQL_IMAGE=mariadb:11.4`.
|
|
56
|
+
|
|
57
|
+
CI runs the default task, plus `spec:mysql2` and `spec:trilogy` in the devcontainer setup, against MySQL 8.4 and MariaDB 11.4.
|
|
58
|
+
|
|
59
|
+
The adapters live in the optional `mysql` Gemfile group (`BUNDLE_WITH=mysql`), because `mysql2` needs the MySQL client library to install.
|
|
60
|
+
|
|
61
|
+
## Docs
|
|
62
|
+
|
|
63
|
+
- `bundle exec yard doc` builds the docs into `doc/`, `bundle exec yard server -r` serves them while you edit
|
|
64
|
+
- `bundle exec rake docs:pages` builds `main` and every `v*` tag into `_site/docs/<ref>`, from their commits, not the working tree
|
|
65
|
+
|
|
66
|
+
The Docs workflow runs `docs:pages` on every push to `main` and every version tag, and publishes `_site` to <https://fnordfish.github.io/tape_deck/docs/> (`main/`, `v0.1.0/`, ...). It needs GitHub Pages enabled with "GitHub Actions" as the source (Settings, Pages).
|
|
67
|
+
|
|
68
|
+
## Releasing
|
|
69
|
+
|
|
70
|
+
Releases go to rubygems.org and to the `@fnordfish` namespace on [gem.coop](https://gem.coop).
|
|
71
|
+
|
|
72
|
+
1. Bump `TapeDeck::VERSION` and add a CHANGELOG entry
|
|
73
|
+
2. `GEM_COOP_API_KEY=<push key> bundle exec rake release`
|
|
74
|
+
|
|
75
|
+
`release` tags, pushes to rubygems.org, then runs `release:gem_coop`. If the gem.coop push fails, run `bundle exec rake release:gem_coop` again on its own. Push keys come from <https://gem.coop/user/push_keys/new>.
|
|
76
|
+
|
|
77
|
+
## Tested against
|
|
78
|
+
|
|
79
|
+
MySQL 8.4 and MariaDB 11.4, each with the `mysql2` and `trilogy` adapters. CI runs all four. Other servers and versions may work, but are untested.
|
|
80
|
+
|
|
81
|
+
## Roadmap and open questions
|
|
82
|
+
|
|
83
|
+
- Replace new auto increment ids with stable placeholders, so tapes don't depend on the starting state
|
|
84
|
+
- Views are not snapshotted (`db.tables` skips them)
|
|
85
|
+
- Binary data that happens to be valid UTF-8 (short hashes, UUIDs) is shown as text
|
|
86
|
+
- `CHECKSUM TABLE` scans the whole table. Fine for test databases, worth measuring for large ones.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Schulze
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# TapeDeck
|
|
2
|
+
|
|
3
|
+
Records what an action changes in your databases, and checks later runs against that recording.
|
|
4
|
+
|
|
5
|
+
[][gem]
|
|
6
|
+
[][ci]
|
|
7
|
+
[][docs]
|
|
8
|
+
|
|
9
|
+
**[API documentation][docs]**
|
|
10
|
+
|
|
11
|
+
> [!NOTE]
|
|
12
|
+
> TapeDeck is in beta. The API and the tape format may still change, which can mean re-recording your tapes.
|
|
13
|
+
|
|
14
|
+
You run a command or request against a black-box project. TapeDeck snapshots all configured databases before and after, and stores the row-level diff as a YAML file, a "tape". On later runs the same action must cause the same diff, so you notice when a project changes its behaviour after an update.
|
|
15
|
+
|
|
16
|
+
API, naming and flow are modelled on [VCR](https://github.com/vcr/vcr), with one big difference: a tape is never played back. The action always runs for real, and the tape is only what the result is compared against.
|
|
17
|
+
|
|
18
|
+
Works with [Sequel](https://sequel.jeremyevans.net) and MySQL or MariaDB (it uses `CHECKSUM TABLE` to skip unchanged tables). Tested with MySQL 8.4 and MariaDB 11.4, using the `mysql2` or `trilogy` adapter.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add this line to your application's Gemfile. While TapeDeck is in beta, the version requirement is needed, because Bundler skips prerelease versions without it:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem "tape_deck", "~> 0.1.0.beta1", group: :test
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or get it from [gem.coop](https://gem.coop):
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
source "https://gem.coop/@fnordfish" do
|
|
32
|
+
gem "tape_deck", "~> 0.1.0.beta1", group: :test
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
And then execute:
|
|
37
|
+
|
|
38
|
+
```console
|
|
39
|
+
bundle install
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
TapeDeck.configure do |c|
|
|
46
|
+
c.tape_library_dir = "spec/db_tapes"
|
|
47
|
+
c.databases = {"shop" => SHOP_DB, "billing" => BILLING_DB}
|
|
48
|
+
c.default_record_mode = :once
|
|
49
|
+
c.exclude_tables "shop.sessions", "*.schema_migrations"
|
|
50
|
+
c.exclude_columns "*.created_at", "*.updated_at"
|
|
51
|
+
c.configure_rspec_metadata!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
TapeDeck.use_tape("checkout/creates_order") do
|
|
55
|
+
client.post("/checkout", ...)
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The first run writes `spec/db_tapes/checkout/creates_order.yml`:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
recorded:
|
|
63
|
+
exclude_tables: [shop.sessions, "*.schema_migrations"]
|
|
64
|
+
exclude_columns: ["*.created_at", "*.updated_at"]
|
|
65
|
+
databases:
|
|
66
|
+
shop:
|
|
67
|
+
excluded: [orders.created_at, orders.updated_at, schema_migrations, sessions]
|
|
68
|
+
billing:
|
|
69
|
+
excluded: [invoices.created_at, schema_migrations]
|
|
70
|
+
diff:
|
|
71
|
+
billing:
|
|
72
|
+
invoices:
|
|
73
|
+
inserted:
|
|
74
|
+
- id: 7
|
|
75
|
+
order_id: 42
|
|
76
|
+
shop:
|
|
77
|
+
orders:
|
|
78
|
+
inserted:
|
|
79
|
+
- id: 42
|
|
80
|
+
status: new
|
|
81
|
+
stock:
|
|
82
|
+
updated:
|
|
83
|
+
- key:
|
|
84
|
+
id: 3
|
|
85
|
+
changes:
|
|
86
|
+
quantity:
|
|
87
|
+
- 10
|
|
88
|
+
- 9
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`recorded` holds the exclude rules, the databases the tape watched, and what the rules left out of each.
|
|
92
|
+
|
|
93
|
+
Every later run verifies against it and raises `TapeDeck::UnexpectedDiffError` with a diff when the changes differ.
|
|
94
|
+
|
|
95
|
+
### Record modes
|
|
96
|
+
|
|
97
|
+
* `:once` records a missing tape, and verifies an existing one (default)
|
|
98
|
+
* `:verify` only verifies. A missing tape raises `TapeDeck::TapeLoadingError`. Use this in CI, with `c.default_record_mode = :verify`.
|
|
99
|
+
* `:overwrite` always records, replacing an existing tape
|
|
100
|
+
|
|
101
|
+
### RSpec
|
|
102
|
+
|
|
103
|
+
After `configure_rspec_metadata!`, tag examples with `:tape_deck`. The tape is named after the example's description, unless you pass `tape_name:`. A failing example neither records nor verifies its tape.
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
RSpec.describe "Checkout" do
|
|
107
|
+
it "creates an order", :tape_deck do
|
|
108
|
+
client.post("/checkout", ...)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "reserves stock", tape_deck: {databases: %w[shop], tape_name: "checkout/stock"} do
|
|
112
|
+
client.post("/checkout", ...)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "marks the order as paid", tape_deck: {only: "shop.orders.status"} do
|
|
116
|
+
client.post("/checkout", ...)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Excluding and ignoring
|
|
122
|
+
|
|
123
|
+
Names are `"<database>.<table>"` and `"<database>.<table>.<column>"`, matched as globs.
|
|
124
|
+
|
|
125
|
+
* `exclude_tables` / `exclude_columns` (configuration): never recorded. Meant for noise like timestamps. Excluding more later is fine, existing tapes just stop comparing it. Excluding less means re-recording, because the tapes lack that data (`TapeDeck::IncompatibleTapeError`).
|
|
126
|
+
* `only:` / `ignore:` (per use of a tape): recorded, but not compared. Can change without re-recording.
|
|
127
|
+
|
|
128
|
+
Since a tape stores its exclude rules, verifying also notices schema changes: when the rules now match tables or columns that were added or removed since recording, it raises `TapeDeck::IncompatibleTapeError`. Set `c.on_schema_change = :warn` to only print a warning, or `:ignore`.
|
|
129
|
+
|
|
130
|
+
Primary key columns are never excluded, because rows are matched by them. Tables without a primary key are compared as a multiset of rows.
|
|
131
|
+
|
|
132
|
+
### Things to know
|
|
133
|
+
|
|
134
|
+
* Your databases need the same starting state on every run. Auto increment ids and existing rows end up on the tape.
|
|
135
|
+
* Anything the action does asynchronously must finish inside the block, or only part of it is captured.
|
|
136
|
+
* When verifying, a tape watches the databases it was recorded with. `databases:` can narrow that down, but can't add a database the tape has no recording of.
|
|
137
|
+
|
|
138
|
+
The [API docs][docs] have the full story.
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
143
|
+
|
|
144
|
+
The default specs run against in-memory SQLite databases that fake MySQL's `CHECKSUM TABLE`, so you don't need a MySQL server. For the real thing, open the devcontainer, it comes with MySQL.
|
|
145
|
+
|
|
146
|
+
Please also see [DEVELOPMENT.md](DEVELOPMENT.md) for design decisions and open questions.
|
|
147
|
+
|
|
148
|
+
## Contributing
|
|
149
|
+
|
|
150
|
+
Bug reports and pull requests are welcome on GitHub at <https://github.com/fnordfish/tape_deck>. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/fnordfish/tape_deck/blob/main/CODE_OF_CONDUCT.md).
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
155
|
+
|
|
156
|
+
[gem]: https://rubygems.org/gems/tape_deck
|
|
157
|
+
[ci]: https://github.com/fnordfish/tape_deck/actions/workflows/main.yml
|
|
158
|
+
[docs]: https://fnordfish.github.io/tape_deck/docs/main/
|
data/Rakefile
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
MYSQL_ADAPTERS = %w[mysql2 trilogy].freeze
|
|
7
|
+
|
|
8
|
+
namespace :spec do
|
|
9
|
+
desc "Run the specs that need no MySQL server"
|
|
10
|
+
RSpec::Core::RakeTask.new(:fake) do |t|
|
|
11
|
+
t.rspec_opts = "--tag ~mysql"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
MYSQL_ADAPTERS.each do |adapter|
|
|
15
|
+
desc "Run the MySQL specs with #{adapter}, needs TAPE_DECK_MYSQL_URL"
|
|
16
|
+
task adapter do
|
|
17
|
+
server_url = ENV.fetch("TAPE_DECK_MYSQL_URL") { abort "Set TAPE_DECK_MYSQL_URL, see DEVELOPMENT.md" }
|
|
18
|
+
adapter_url = server_url.sub(%r{\A\w+://}, "#{adapter}://")
|
|
19
|
+
sh({"TAPE_DECK_MYSQL_URL" => adapter_url}, "rspec", "--tag", "mysql")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
desc "Run all specs, the MySQL ones with every adapter if TAPE_DECK_MYSQL_URL is set"
|
|
25
|
+
task :spec do
|
|
26
|
+
Rake::Task["spec:fake"].invoke
|
|
27
|
+
if ENV["TAPE_DECK_MYSQL_URL"]
|
|
28
|
+
MYSQL_ADAPTERS.each { Rake::Task["spec:#{_1}"].invoke }
|
|
29
|
+
else
|
|
30
|
+
warn "Skipping the MySQL specs, TAPE_DECK_MYSQL_URL is not set"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
require "standard/rake"
|
|
35
|
+
|
|
36
|
+
task default: %i[spec standard]
|
|
37
|
+
|
|
38
|
+
GEM_COOP_HOST = "https://gem.coop/@fnordfish"
|
|
39
|
+
|
|
40
|
+
namespace :release do
|
|
41
|
+
desc "Push the built gem to #{GEM_COOP_HOST}, needs GEM_COOP_API_KEY"
|
|
42
|
+
task gem_coop: :build do
|
|
43
|
+
api_key = ENV.fetch("GEM_COOP_API_KEY") { abort "Set GEM_COOP_API_KEY to a push key from https://gem.coop/user/push_keys/new" }
|
|
44
|
+
gem_file = "pkg/#{Bundler::GemHelper.gemspec.full_name}.gem"
|
|
45
|
+
|
|
46
|
+
# Only this push gets the key, so it is never sent to rubygems.org
|
|
47
|
+
sh({"GEM_HOST_API_KEY" => api_key}, "gem", "push", gem_file, "--host", GEM_COOP_HOST)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
namespace :docs do
|
|
52
|
+
desc "Build the YARD docs of main and every version tag into _site, like GitHub Pages publishes them"
|
|
53
|
+
task :pages do
|
|
54
|
+
refs = ["main", *`git tag --list "v*" --sort=-v:refname`.split]
|
|
55
|
+
refs.each do |ref|
|
|
56
|
+
# Without a local main branch (e.g. CI building a tag), use the remote one
|
|
57
|
+
commit = ref
|
|
58
|
+
commit = "origin/#{ref}" if ref == "main" && !system("git", "rev-parse", "--verify", "--quiet", ref, out: File::NULL)
|
|
59
|
+
worktree = File.expand_path("tmp/pages/#{ref}")
|
|
60
|
+
output = File.expand_path("_site/docs/#{ref}")
|
|
61
|
+
|
|
62
|
+
sh "git", "worktree", "add", "--detach", "--force", worktree, commit
|
|
63
|
+
begin
|
|
64
|
+
Dir.chdir(worktree) { sh "yard", "doc", "--no-save", "--no-stats", "--output-dir", output }
|
|
65
|
+
ensure
|
|
66
|
+
sh "git", "worktree", "remove", "--force", worktree
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
links = refs.map { %(<li><a href="#{_1}/">#{_1}</a></li>) }.join("\n")
|
|
71
|
+
File.write("_site/docs/index.html", <<~HTML)
|
|
72
|
+
<!DOCTYPE html>
|
|
73
|
+
<html><head><meta charset="utf-8"><title>TapeDeck docs</title></head>
|
|
74
|
+
<body><h1>TapeDeck docs</h1><ul>
|
|
75
|
+
#{links}
|
|
76
|
+
</ul></body></html>
|
|
77
|
+
HTML
|
|
78
|
+
File.write("_site/index.html", <<~HTML)
|
|
79
|
+
<!DOCTYPE html>
|
|
80
|
+
<html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=docs/"></head></html>
|
|
81
|
+
HTML
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
Rake::Task["release"].enhance { Rake::Task["release:gem_coop"].invoke }
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TapeDeck
|
|
4
|
+
# Global settings, set through {TapeDeck.configure}.
|
|
5
|
+
class Configuration
|
|
6
|
+
# Supported values for {#on_schema_change}:
|
|
7
|
+
# [+:error+] raise {IncompatibleTapeError}
|
|
8
|
+
# [+:warn+] print a warning and verify anyway
|
|
9
|
+
# [+:ignore+] verify anyway
|
|
10
|
+
SCHEMA_CHANGE_ACTIONS = %i[error warn ignore].freeze
|
|
11
|
+
|
|
12
|
+
# @return [String] directory tapes are read from and written to (default +"spec/db_tapes"+)
|
|
13
|
+
attr_accessor :tape_library_dir
|
|
14
|
+
|
|
15
|
+
# @return [Hash{String => Sequel::Database}] databases to diff, keyed by the name used in tapes and patterns
|
|
16
|
+
attr_accessor :databases
|
|
17
|
+
|
|
18
|
+
# Record mode of tapes that don't set their own +record:+. One of {Tape::RECORD_MODES} (default +:once+). Set it
|
|
19
|
+
# to +:verify+ in CI, so a missing tape fails the run.
|
|
20
|
+
#
|
|
21
|
+
# @return [Symbol]
|
|
22
|
+
attr_reader :default_record_mode
|
|
23
|
+
|
|
24
|
+
# What to do when verifying finds that the schema changed since recording: the recorded exclude rules leave out
|
|
25
|
+
# other tables or columns than they did then. One of {SCHEMA_CHANGE_ACTIONS} (default +:error+).
|
|
26
|
+
#
|
|
27
|
+
# @return [Symbol]
|
|
28
|
+
attr_reader :on_schema_change
|
|
29
|
+
|
|
30
|
+
# @return [Array<String>] patterns added with {#exclude_tables}
|
|
31
|
+
attr_reader :excluded_tables
|
|
32
|
+
|
|
33
|
+
# @return [Array<String>] patterns added with {#exclude_columns}
|
|
34
|
+
attr_reader :excluded_columns
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
@tape_library_dir = "spec/db_tapes"
|
|
38
|
+
@databases = {}
|
|
39
|
+
@default_record_mode = :once
|
|
40
|
+
@on_schema_change = :error
|
|
41
|
+
@excluded_tables = []
|
|
42
|
+
@excluded_columns = []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param mode [Symbol] one of {Tape::RECORD_MODES}
|
|
46
|
+
# @raise [ArgumentError] for an unknown record mode
|
|
47
|
+
def default_record_mode=(mode)
|
|
48
|
+
Tape.validate_record_mode(mode)
|
|
49
|
+
@default_record_mode = mode
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param action [Symbol] one of {SCHEMA_CHANGE_ACTIONS}
|
|
53
|
+
# @raise [ArgumentError] for an unknown action
|
|
54
|
+
def on_schema_change=(action)
|
|
55
|
+
unless SCHEMA_CHANGE_ACTIONS.include?(action)
|
|
56
|
+
raise ArgumentError, "invalid schema change action #{action.inspect}, use one of #{SCHEMA_CHANGE_ACTIONS.inspect}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@on_schema_change = action
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Leaves matching tables out of snapshots entirely, so they are never recorded.
|
|
63
|
+
#
|
|
64
|
+
# @param patterns [Array<String>] globs on +"<database>.<table>"+
|
|
65
|
+
# @return [void]
|
|
66
|
+
# @example
|
|
67
|
+
# c.exclude_tables "shop.sessions", "*.schema_migrations"
|
|
68
|
+
def exclude_tables(*patterns)
|
|
69
|
+
@excluded_tables.concat(patterns.map(&:to_s))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Drops matching columns from every row before diffing, so they are never recorded. Primary key columns are never
|
|
73
|
+
# dropped, because rows are matched by them.
|
|
74
|
+
#
|
|
75
|
+
# @param patterns [Array<String>] globs on +"<database>.<table>.<column>"+
|
|
76
|
+
# @return [void]
|
|
77
|
+
# @example
|
|
78
|
+
# c.exclude_columns "*.created_at", "*.updated_at"
|
|
79
|
+
def exclude_columns(*patterns)
|
|
80
|
+
@excluded_columns.concat(patterns.map(&:to_s))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Wraps RSpec examples tagged +:tape_deck+ in a tape. The tape is named after the example's description hierarchy
|
|
84
|
+
# unless +tape_name:+ is given. When the example fails, the tape is neither recorded nor verified.
|
|
85
|
+
#
|
|
86
|
+
# @return [void]
|
|
87
|
+
# @example
|
|
88
|
+
# it "creates an order", :tape_deck do ... end
|
|
89
|
+
# it "creates an order", tape_deck: { record: :overwrite, tape_name: "orders/create" } do ... end
|
|
90
|
+
# it "creates an order", tape_deck: { databases: %w[shop] } do ... end
|
|
91
|
+
def configure_rspec_metadata!
|
|
92
|
+
::RSpec.configure do |config|
|
|
93
|
+
config.around(:each, :tape_deck) do |example|
|
|
94
|
+
options = Hash.try_convert(example.metadata[:tape_deck]) || {} # a bare :tape_deck tag is `true`
|
|
95
|
+
name = options[:tape_name] || Configuration.rspec_tape_name(example)
|
|
96
|
+
|
|
97
|
+
TapeDeck.insert_tape(name, **options.except(:tape_name))
|
|
98
|
+
example.run
|
|
99
|
+
example_failed = !example.exception.nil?
|
|
100
|
+
TapeDeck.eject_tape(discard: example_failed)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @param example [RSpec::Core::Example]
|
|
106
|
+
# @return [String] the example's description hierarchy, e.g. +"Checkout/creates an order"+
|
|
107
|
+
# @api private
|
|
108
|
+
def self.rspec_tape_name(example)
|
|
109
|
+
group_descriptions = example.example_group.parent_groups.reverse.map(&:description)
|
|
110
|
+
[*group_descriptions, example.description].join("/")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TapeDeck
|
|
4
|
+
# Base class for all TapeDeck errors.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a tape must be verified but its file does not exist, is not valid YAML or does not hold a diff.
|
|
8
|
+
class TapeLoadingError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when a tape cannot verify this run: it has no recording of a watched database, or something it left out is
|
|
11
|
+
# no longer excluded, so the tape lacks that data. Re-record the tape.
|
|
12
|
+
class IncompatibleTapeError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised when the block changed the databases differently than the tape says.
|
|
15
|
+
class UnexpectedDiffError < Error
|
|
16
|
+
# @return [Tape] the tape that did not match
|
|
17
|
+
attr_reader :tape
|
|
18
|
+
|
|
19
|
+
# @return [Hash] the diff stored on the tape
|
|
20
|
+
attr_reader :expected
|
|
21
|
+
|
|
22
|
+
# @return [Hash] the diff the block caused
|
|
23
|
+
attr_reader :actual
|
|
24
|
+
|
|
25
|
+
# @param tape [Tape]
|
|
26
|
+
# @param expected [Hash] the diff stored on the tape
|
|
27
|
+
# @param actual [Hash] the diff the block caused
|
|
28
|
+
def initialize(tape, expected, actual)
|
|
29
|
+
@tape = tape
|
|
30
|
+
@expected = expected
|
|
31
|
+
@actual = actual
|
|
32
|
+
super("DB diff does not match tape #{tape.file}\n#{describe}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# RSpec's differ if available, otherwise one that prints both sides. Loaded on first use.
|
|
36
|
+
#
|
|
37
|
+
# @return [#call] +(actual, expected) -> String+
|
|
38
|
+
# @api private
|
|
39
|
+
def self.differ
|
|
40
|
+
@differ ||= begin
|
|
41
|
+
require "rspec/support/differ"
|
|
42
|
+
::RSpec::Support::Differ.new.method(:diff)
|
|
43
|
+
rescue LoadError
|
|
44
|
+
->(actual, expected) { "--- expected\n#{expected}\n--- actual\n#{actual}" }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def describe
|
|
51
|
+
self.class.differ.call(Tape.serialize(actual), Tape.serialize(expected))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|