File: test_ruby_compatibility.rb

package info (click to toggle)
ruby-zeitwerk 2.7.3-1
  • links: PTS, VCS
  • area: main
  • in suites: forky
  • size: 732 kB
  • sloc: ruby: 6,240; makefile: 4
file content (295 lines) | stat: -rw-r--r-- 9,895 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# frozen_string_literal: true

require "test_helper"
require "pathname"

class TestRubyCompatibility < LoaderTest
  # We decorate Kernel#require in lib/zeitwerk/kernel.rb be able to trigger
  # callbacks, autovivify implicit namespaces, keep track of what has been
  # autoloaded, and more.
  test "autoload calls Kernel#require" do
    files = [["x.rb", "X = true"]]
    with_files(files) do
      loader.push_dir(".")
      loader.setup

      $trc_require_has_been_called = false
      $trc_autoload_path = File.expand_path("x.rb")

      begin
        Kernel.module_eval do
          alias_method :trc_original_require, :require
          def require(path)
            $trc_require_has_been_called = true if path == $trc_autoload_path
            trc_original_require(path)
          end
        end

        assert X
        assert $trc_require_has_been_called
      ensure
        Kernel.module_eval do
          remove_method :require
          define_method :require, instance_method(:trc_original_require)
          remove_method :trc_original_require
        end
      end
    end
  end

  # Once a managed file is autoloaded, Zeitwerk verifies the expected constant
  # has been defined and raises Zeitwerk::NameError if not. This happens within
  # the context of the require call and is correct because an autoload does not
  # define the constant by itself, it has to be a side-effect.
  test "within a file triggered by an autoload, the constant being autoloaded is not defined" do
    files = [["x.rb", "$const_defined_for_X = Object.const_defined?(:X); X = 1"]]
    with_setup(files) do
      $const_defined_for_X = Object.const_defined?(:X)
      assert $const_defined_for_X
      assert X
      assert !$const_defined_for_X
    end
  end

  # Zeitwerk sets autoloads using absolute paths and string concatenation with
  # the root directories. These paths could contain symlinks, but we can still
  # identify managed files in our decorated Kernel#require because Ruby stores
  # the paths as they are in $LOADED_FEATURES with no symlink resolution.
  test "absolute paths passed to require end up in $LOADED_FEATURES as is" do
    on_teardown { $LOADED_FEATURES.pop }

    files = [["real/real_x.rb", ""]]
    with_files(files) do
      FileUtils.ln_s("real", "sym")
      FileUtils.ln_s(File.expand_path("real/real_x.rb"), "sym/sym_x.rb")

      sym_x = File.expand_path("sym/sym_x.rb")
      assert require(sym_x)
      assert $LOADED_FEATURES.last == sym_x
    end
  end

  # We configure autoloads on directories to autovivify modules on demand, and
  # lazily descend to set autoloads for their children. This is more efficient,
  # specially for large code bases.
  test "you can set autoloads on directories" do
    files = ["admin/users_controller.rb", "class UsersController; end"]
    with_setup(files) do
      assert_equal "#{Dir.pwd}/admin", Object.autoload?(:Admin)
    end
  end

  # While unloading constants we leverage this property to avoid lookups in
  # $LOADED_FEATURES for strings that we know are not going to be there.
  test "directories are not included in $LOADED_FEATURES" do
    with_files(["admin/users_controller.rb"]) do
      loader.push_dir(".")
      loader.setup

      assert Admin
      assert !$LOADED_FEATURES.include?(File.expand_path("admin"))
    end
  end

  # We exploit this one to simplify the detection of explicit namespaces.
  #
  # Let's suppose `Admin` is an explicit namespace and scanning finds first a
  # directory called `admin`. We set at that point an autoload for `Admin` that
  # will require that directory. If later on, scanning finds `admin.rb`, we just
  # set the autoload again, and change the target file.
  #
  # This way, we do not need to keep state or do an a posteriori pass, can set
  # autoloads linearly as scanning progresses.
  test "an autoload can be overridden" do
    on_teardown { remove_const :X }

    files = [
      ["x0/x.rb", "X = 0"],
      ["x1/x.rb", "X = 1"]
    ]
    with_files(files) do
      Object.autoload(:X, File.expand_path("x0/x.rb"))
      Object.autoload(:X, File.expand_path("x1/x.rb"))

      assert_equal 1, X
    end
  end

  # In some spots like shadowed files detection we need to check if constants
  # are already defined in the parent class or module. In order to do this and
  # still be lazy, we rely on this property of const_defined?
  #
  # This also matters for autoloads already set by 3rd-party code, for example
  # in reopened namespaces. Zeitwerk won't override them, but thanks to this
  # characteristic of const_defined? if won't trigger them either.
  test "const_defined? is true for autoloads and does not load the file, if the file exists" do
    on_teardown { remove_const :X }

    files = [["x.rb", "$const_defined_does_not_trigger_autoload = false; X = true"]]
    with_files(files) do
      $const_defined_does_not_trigger_autoload = true
      Object.autoload(:X, File.expand_path("x.rb"))

      assert Object.const_defined?(:X, false)
      assert $const_defined_does_not_trigger_autoload
    end
  end

  # We delegate constant name validation to Module#const_defined?.
  test "const_defined? raises NameError for invalid cnames" do
    error = assert_raises ::NameError do
      Module.new.const_defined?("Foo-Bar", false)
    end

    assert_includes error.message, "wrong constant name Foo-Bar"
  end

  # Unloading removes autoloads by calling remove_const. It is convenient that
  # remove_const does not execute the autoload because it would be surprising,
  # and slower, that those unused files got loaded precisely while unloading.
  test "remove_const does not trigger an autoload" do
    files = [["x.rb", "$remove_const_does_not_trigger_autoload = false; X = 1"]]
    with_files(files) do
      $remove_const_does_not_trigger_autoload = true
      Object.autoload(:X, File.expand_path("x.rb"))

      remove_const :X
      assert $remove_const_does_not_trigger_autoload
    end
  end

  # Loaders use this property when unloading to be able tell if the autoloads
  # that are pending according to their state are still pending. While things
  # are autoloaded that collection is maintained, this should not be needed. But
  # client code doing unsupported stuff like using require_relative on managed
  # files could introduce weird state we need to be defensive about.
  test "autoloading removes the autoload configuration in the parent" do
    on_teardown do
      remove_const :X
      delete_loaded_feature "x.rb"
    end

    files = [["x.rb", "X = true"]]
    with_files(files) do
      Object.autoload(:X, File.expand_path("x.rb"))

      assert Object.autoload?(:X)
      assert X
      assert !Object.autoload?(:X)
    end
  end

  # We use remove_const to delete autoload configurations while unloading.
  # Otherwise, the configured files or directories could become stale.
  test "autoload configuration can be deleted with remove_const" do
    files = [["x.rb", "X = true"]]
    with_files(files) do
      Object.autoload(:X, File.expand_path("x.rb"))

      assert Object.autoload?(:X)
      remove_const :X
      assert !Object.autoload?(:X)
    end
  end

  # This edge case justifies the need for the inceptions collection in the
  # registry.
  test "an autoload on yourself is ignored" do
    files = [["foo.rb", <<-EOS]]
      Object.autoload(:Foo, __FILE__)
      $trc_inception = !Object.autoload?(:Foo)
      Foo = 1
    EOS
    with_files(files) do
      loader.push_dir(".")
      loader.setup

      with_load_path do
        $trc_inception = false
        require "foo"
      end

      assert $trc_inception
    end
  end

  # Same as above, adding some depth.
  test "an autoload on a file being required at some point up in the call chain is also ignored" do
    files = [
      ["foo.rb", <<-EOS],
        require 'bar'
        Foo = 1
      EOS
     ["bar.rb", <<-EOS]
       Bar = true
       Object.autoload(:Foo, File.expand_path('foo.rb'))
       $trc_inception = !Object.autoload?(:Foo)
     EOS
    ]
    with_files(files) do
      loader.push_dir(".")
      loader.setup

      with_load_path do
        $trc_inception = false
        require "foo"
      end

      assert $trc_inception
    end
  end

  # If the user issues a require call with a Pathname object for a path that is
  # autoloadable, we are still able to intercept it because $LOADED_FEATURES
  # stores it as a string and loader_for is able to find its loader. During
  # unloading, we find and delete strings in $LOADED_FEATURES too.
  #
  # This is not a hard requirement, we could work around it if $LOADED_FEATURES
  # stored pathnames. But the code is simpler if this property holds.
  test "required pathnames end up as strings in $LOADED_FEATURES" do
    on_teardown do
      remove_const :X
      $LOADED_FEATURES.pop
    end

    files = [["x.rb", "X = 1"]]
    with_files(files) do
      with_load_path(".") do
        assert_equal true, require(Pathname.new("x"))
        assert_equal 1, X
        assert_equal File.expand_path("x.rb"), $LOADED_FEATURES.last
      end
    end
  end

  # This allows Zeitwerk to be thread-safe on regular file autoloads. Module
  # autovivification is custom, has its own test.
  test "autoloads and constant references are synchronized" do
    skip 'https://github.com/oracle/truffleruby/issues/2431' if RUBY_ENGINE == 'truffleruby'

    $ensure_M_is_autoloaded_by_the_thread = Queue.new

    files = [["m.rb", <<-EOS]]
      $ensure_M_is_autoloaded_by_the_thread.pop()

      module M
        sleep 1

        def self.works?
          true
        end
      end
    EOS
    with_setup(files) do
      t = Thread.new do
        $ensure_M_is_autoloaded_by_the_thread << true
        M
      end

      sleep 0.5 # Let the thread hit the sleep in m.rb.
      assert M.works? # this should block until the thread has finished autoloading

      t.join
    end
  end
end