@@ -18,6 +18,151 @@ def test_external_encoding
1818 end
1919 end
2020
21+ def test_returns_block_value_and_leaves_no_temp_file
22+ result = Gem ::AtomicFileWriter . open ( @path ) do |file |
23+ file . write "hello"
24+ :done
25+ end
26+
27+ assert_equal :done , result
28+ assert_equal "hello" , File . binread ( @path )
29+ assert_equal [ "out.txt" ] , Dir . children ( @dir )
30+ end
31+
32+ def test_content_is_flushed_before_rename
33+ original_rename = File . method ( :rename )
34+ size_at_rename = nil
35+
36+ rename_spy = lambda do |src , dest |
37+ size_at_rename = File . size ( src )
38+ original_rename . call ( src , dest )
39+ end
40+
41+ File . stub ( :rename , rename_spy ) do
42+ Gem ::AtomicFileWriter . open ( @path ) do |file |
43+ file . write "hello"
44+ end
45+ end
46+
47+ assert_equal 5 , size_at_rename
48+ assert_equal "hello" , File . binread ( @path )
49+ end
50+
51+ def test_keeps_destination_and_removes_temp_file_on_error
52+ File . binwrite @path , "old"
53+
54+ error = assert_raise ( RuntimeError ) do
55+ Gem ::AtomicFileWriter . open ( @path ) do |file |
56+ file . write "new"
57+ raise "boom"
58+ end
59+ end
60+
61+ assert_equal "boom" , error . message
62+ assert_equal "old" , File . binread ( @path )
63+ assert_equal [ "out.txt" ] , Dir . children ( @dir )
64+ end
65+
66+ def test_keeps_destination_and_removes_temp_file_on_interrupt
67+ File . binwrite @path , "old"
68+
69+ assert_raise ( Interrupt ) do
70+ Gem ::AtomicFileWriter . open ( @path ) do |file |
71+ file . write "new"
72+ raise Interrupt
73+ end
74+ end
75+
76+ assert_equal "old" , File . binread ( @path )
77+ assert_equal [ "out.txt" ] , Dir . children ( @dir )
78+ end
79+
80+ def test_removes_temp_file_when_closing_it_keeps_raising
81+ File . binwrite @path , "old"
82+
83+ temp_file = nil
84+ closes = 0
85+ raising = true
86+ # A real file is closed even when its close raises, so only a stub can keep
87+ # raising like this. The writer leaves that file to the garbage collector,
88+ # which is why this test closes it itself.
89+ failing_open = temp_file_open_stub do |file |
90+ temp_file = file
91+ file . define_singleton_method ( :close ) do
92+ closes += 1
93+ raise Interrupt if raising
94+
95+ super ( )
96+ end
97+ end
98+
99+ File . stub ( :open , failing_open ) do
100+ assert_raise ( Interrupt ) do
101+ Gem ::AtomicFileWriter . open ( @path ) do |file |
102+ file . write "new"
103+ end
104+ end
105+ end
106+
107+ assert_equal 2 , closes
108+ assert_equal "old" , File . binread ( @path )
109+ assert_equal [ "out.txt" ] , Dir . children ( @dir )
110+ ensure
111+ raising = false
112+ temp_file &.close
113+ end
114+
115+ def test_keeps_destination_when_closing_the_temp_file_fails
116+ File . binwrite @path , "old"
117+
118+ failing_open = temp_file_open_stub do |file |
119+ file . define_singleton_method ( :close ) do
120+ super ( )
121+ raise Errno ::ENOSPC
122+ end
123+ end
124+
125+ File . stub ( :open , failing_open ) do
126+ assert_raise ( Errno ::ENOSPC ) do
127+ Gem ::AtomicFileWriter . open ( @path ) do |file |
128+ file . write "new"
129+ end
130+ end
131+ end
132+
133+ assert_equal "old" , File . binread ( @path )
134+ assert_equal [ "out.txt" ] , Dir . children ( @dir )
135+ end
136+
137+ def test_keeps_destination_when_renaming_fails
138+ File . binwrite @path , "old"
139+
140+ File . stub ( :rename , -> ( _src , _dest ) { raise Errno ::EXDEV } ) do
141+ assert_raise ( Errno ::EXDEV ) do
142+ Gem ::AtomicFileWriter . open ( @path ) do |file |
143+ file . write "new"
144+ end
145+ end
146+ end
147+
148+ assert_equal "old" , File . binread ( @path )
149+ assert_equal [ "out.txt" ] , Dir . children ( @dir )
150+ end
151+
152+ def test_preserves_destination_permissions
153+ pend "Windows cannot round-trip the POSIX permission bits" if Gem . win_platform?
154+
155+ File . binwrite @path , "old"
156+ File . chmod 0o604 , @path
157+
158+ Gem ::AtomicFileWriter . open ( @path ) do |file |
159+ file . write "new"
160+ end
161+
162+ assert_equal "new" , File . binread ( @path )
163+ assert_equal 0o604 , File . stat ( @path ) . mode & 0o777
164+ end
165+
21166 def test_multibyte_basename_is_truncated_at_char_boundary
22167 pend "long file names easily exceed MAX_PATH on Windows" if Gem . win_platform?
23168
@@ -82,4 +227,31 @@ def test_byteslice_at_char_boundary_with_invalid_encoding
82227
83228 assert_equal "\xFF a" . dup . force_encoding ( Encoding ::UTF_8 ) , sliced
84229 end
230+
231+ private
232+
233+ # Returns a File.open replacement that hands the writer's temporary file to
234+ # the given block, which installs whatever close behaviour a test needs.
235+ # Every other path is opened normally. Call this before installing the stub,
236+ # since it captures the current File.open.
237+ def temp_file_open_stub ( &injection )
238+ original_open = File . method ( :open )
239+ prefix = ".#{ File . basename ( @path ) } .tmp."
240+
241+ lambda do |name , *args , **kwargs , &block |
242+ unless File . basename ( name . to_s ) . start_with? ( prefix )
243+ next original_open . call ( name , *args , **kwargs , &block )
244+ end
245+ raise ArgumentError , "this stub cannot replace close on a File.open with a block" if block
246+
247+ file = original_open . call ( name , *args , **kwargs )
248+ begin
249+ injection . call ( file )
250+ rescue StandardError
251+ file . close
252+ raise
253+ end
254+ file
255+ end
256+ end
85257end
0 commit comments