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 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
@@ -0,0 +1,7 @@
1
+ --title TapeDeck
2
+ --readme README.md
3
+ lib/**/*.rb
4
+ -
5
+ CHANGELOG.md
6
+ DEVELOPMENT.md
7
+ LICENSE.txt
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changes
2
+
3
+ ## 0.1.0.beta1
4
+
5
+ - First beta. The API and the tape format may still change.
@@ -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 Version](https://img.shields.io/gem/v/tape_deck?include_prereleases)][gem]
6
+ [![Build Status](https://github.com/fnordfish/tape_deck/actions/workflows/main.yml/badge.svg)][ci]
7
+ [![API Docs](https://img.shields.io/badge/docs-API-blue)][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