master
ブランチ master
LittleBeast / src / Std / Test / LibCron.php
21.3 KB、469行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, and/or sell copies of the Software, subject to the following conditions:
10
11 1. **Origin Attribution**:
12 - You must not misrepresent the origin of the Software; you must not claim
13 you created the original Software.
14 - If the Software is used in a product, you must either:
15 a. Provide clear attribution in the product's documentation, user interface,
16 or other visible areas, **OR**
17 b. Pay the original developers a fee they specify in writing.
18 2. **Usage Restriction**:
19 - The Software, or any derivative works, dependencies, or libraries
20 incorporating it, must not be used for censorship or to suppress freedom of
21 speech, expression, or creativity. Prohibited uses include, but are not
22 limited to:
23 - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24 ideas, or objective reality.
25 - Tools or systems designed to restrict access to information or
26 artistic works.
27 3. **Notice Preservation**:
28 - This license and the above copyright notice must remain intact in all copies
29 of the source code.
30
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Test;
39
40require_once __DIR__.'/../../../autoload.php';
41
42use Std\Lib\Tester;
43use Std\Lib\Activitypub;
44use Std\Lib\Crypto\Rsa;
45use Std\Lib\Cron;
46use Std\Lib\CronTab;
47
48$test = new Tester([
49 'colorOutput' => true,
50 'verboseOutput' => true
51]);
52
53// テスト毎の一時ディレクトリ(最後に削除する)
54$tmpDirs = [];
55$tmp = function() use (&$tmpDirs): string {
56 $dir = sys_get_temp_dir().'/lbcron_'.bin2hex(random_bytes(6));
57 mkdir($dir, 0700, true);
58 return $tmpDirs[] = $dir;
59};
60$at = fn(string $s) => new \DateTimeImmutable($s);
61$next = fn(string $line, string $after) => CronTab::fromString($line)->nextRun(new \DateTimeImmutable($after))?->format('Y-m-d H:i');
62
63$test->describe('CronTabの解析', function($test): void {
64 $test->it('5項目とタスク名を解析出来るはず', function($test): void {
65 $ct = CronTab::fromString('*/5 2 * * 1-5 apfederate');
66 $test->assertEquals('*/5 2 * * 1-5', $ct->expression());
67 $test->assertEquals('apfederate', $ct->command);
68 $test->assertTrue($ct->isActive);
69 $test->assertNull($ct->validate());
70 });
71
72 $test->it('マクロを展開出来るはず', function($test): void {
73 $test->assertEquals('0 0 * * *', CronTab::fromString('@daily x')->expression());
74 $test->assertEquals('0 * * * *', CronTab::fromString('@HOURLY x')->expression());
75 $test->assertEquals('0 0 1 1 *', CronTab::fromString('@annually x')->expression());
76 });
77
78 $test->it('形式が不正なら例外を投げるはず', function($test): void {
79 $test->assertThrows(fn() => CronTab::fromString('* * * * apfederate'), \InvalidArgumentException::class);
80 $test->assertThrows(fn() => CronTab::fromString('@often apfederate'), \InvalidArgumentException::class);
81 });
82
83 $test->it('不正な値を検出するはず', function($test): void {
84 $test->assertStringContains('範囲外', CronTab::fromString('61 * * * * x')->validate() ?? '');
85 $test->assertStringContains('間隔', CronTab::fromString('*/0 * * * * x')->validate() ?? '');
86 $test->assertStringContains('逆', CronTab::fromString('* 5-1 * * * x')->validate() ?? '');
87 $test->assertStringContains('不正', CronTab::fromString('* * * foo * x')->validate() ?? '');
88 $test->assertStringContains('タスク名', CronTab::fromString('* * * * * rm;-rf')->validate() ?? '');
89 });
90
91 $test->it('配列に変換して戻せるはず', function($test): void {
92 $ct = CronTab::fromString('0 3 * * sun backup', false);
93 $ct->setId(7);
94 $ct->lastRun = 1700000000;
95 $copy = CronTab::fromArray($ct->toArray());
96 $test->assertEquals($ct->toArray(), $copy->toArray());
97 $test->assertEquals(7, $copy->getId());
98 $test->assertFalse($copy->isActive);
99 });
100});
101
102$test->describe('CronTabの予定', function($test) use ($at, $next): void {
103 $test->it('isDueは分単位で判断するはず', function($test) use ($at): void {
104 $ct = CronTab::fromString('*/15 10 * * * x');
105 $test->assertTrue($ct->isDue($at('2026-01-05 10:30')));
106 $test->assertFalse($ct->isDue($at('2026-01-05 10:31')));
107 $test->assertFalse($ct->isDue($at('2026-01-05 11:30')));
108 });
109
110 $test->it('次の実行時刻を計算出来るはず', function($test) use ($next): void {
111 $test->assertEquals('2026-01-05 10:45', $next('*/15 * * * * x', '2026-01-05 10:31'));
112 // 同じ分は含まない
113 $test->assertEquals('2026-01-05 10:45', $next('*/15 * * * * x', '2026-01-05 10:30'));
114 // 「5/20」は5, 25, 45
115 $test->assertEquals('2026-01-05 10:45', $next('5/20 * * * * x', '2026-01-05 10:26'));
116 // 金曜日の後は月曜日
117 $test->assertEquals('2026-01-12 09:00', $next('0 9 * * mon-fri x', '2026-01-09 10:00'));
118 // 曜日の7も日曜日
119 $test->assertEquals('2026-01-11 00:00', $next('0 0 * * 7 x', '2026-01-09 10:00'));
120 // 月の名前
121 $test->assertEquals('2026-07-01 00:00', $next('0 0 1 jan,jul * x', '2026-02-01 00:00'));
122 });
123
124 $test->it('日と曜日の両方を指定したらどちらかに合えば実行するはず', function($test) use ($next): void {
125 // 2026-02-06は金曜日、13日も金曜日
126 $test->assertEquals('2026-02-06 00:00', $next('0 0 13 * fri x', '2026-02-01 00:00'));
127 // 片方が「*」なら両方に合う事
128 $test->assertEquals('2026-02-13 00:00', $next('0 0 13 * * x', '2026-02-01 00:00'));
129 });
130
131 $test->it('閏年と存在しない日を扱えるはず', function($test) use ($next): void {
132 $test->assertEquals('2028-02-29 00:00', $next('0 0 29 2 * x', '2026-03-01 00:00'));
133 $test->assertNull($next('0 0 30 2 * x', '2026-03-01 00:00'));
134 });
135});
136
137$test->describe('Cronのジョブ管理', function($test) use ($tmp): void {
138 if (!CRON_ENABLED) {
139 $test->skipAll('CRON_ENABLEDが無効');
140 }
141
142 $cron = CRON_ENABLED ? new Cron($tmp()) : null;
143
144 $test->it('ジョブを追加出来るはず', function($test) use ($cron): void {
145 $res = $cron->add(CronTab::fromString('0 2 15 6 * apfederate'));
146 $test->assertTrue($res->isSuccess);
147 $test->assertEquals([
148 'id' => 1,
149 'isActive' => true,
150 'minute' => '0',
151 'hour' => '2',
152 'dayOfMonth' => '15',
153 'month' => '6',
154 'dayOfWeek' => '*',
155 'command' => 'apfederate',
156 'lastRun' => null,
157 'lastError' => null,
158 ], $res->data);
159 $test->assertEquals(2, $cron->add(CronTab::fromString('@daily other'))->data['id']);
160 });
161
162 $test->it('不正なジョブは追加しないはず', function($test) use ($cron): void {
163 $res = $cron->add(CronTab::fromString('99 * * * * x'));
164 $test->assertFalse($res->isSuccess);
165 $test->assertCount(2, $cron->list()->data);
166 });
167
168 $test->it('ジョブを表示・変更出来るはず', function($test) use ($cron): void {
169 $test->assertEquals('apfederate', $cron->show(1)->data['command']);
170 $test->assertFalse($cron->show(99)->isSuccess);
171
172 $res = $cron->edit(1, CronTab::fromString('0 7 15 6 * apfederate'));
173 $test->assertTrue($res->isSuccess);
174 $test->assertEquals('7', $res->data['hour']);
175 $test->assertEquals('7', $cron->show(1)->data['hour']);
176 });
177
178 $test->it('ジョブを有効・無効に出来るはず', function($test) use ($cron): void {
179 $test->assertFalse($cron->toggle(1)->data['isActive']);
180 $test->assertTrue($cron->enable(1)->data['isActive']);
181 $test->assertFalse($cron->disable(1)->data['isActive']);
182 });
183
184 $test->it('ジョブを削除出来るはず', function($test) use ($cron): void {
185 $res = $cron->delete(1);
186 $test->assertTrue($res->isSuccess);
187 $test->assertEquals(1, $res->data['id']);
188 $test->assertEquals([2], array_column($cron->list()->data, 'id'));
189 $test->assertFalse($cron->delete(1)->isSuccess);
190 $test->assertEquals(3, $cron->add(CronTab::fromString('@hourly x'))->data['id']);
191 });
192});
193
194$test->describe('Cronの実行', function($test) use ($tmp, $at): void {
195 if (!CRON_ENABLED) {
196 $test->skipAll('CRON_ENABLEDが無効');
197 }
198
199 $dir = CRON_ENABLED ? $tmp() : '';
200 $count = ['every5' => 0, 'hourly' => 0];
201 $cron = CRON_ENABLED ? new Cron($dir) : null;
202 if ($cron) {
203 $cron->register('every5', function() use (&$count) { $count['every5']++; });
204 $cron->register('hourly', function() use (&$count) { $count['hourly']++; return \Result::Success('毎時'); });
205 $cron->register('boom', function() { throw new \RuntimeException('壊れた'); });
206 $cron->register('nope', fn() => false);
207 $cron->add(CronTab::fromString('*/5 * * * * every5'));
208 $cron->add(CronTab::fromString('0 * * * * hourly'));
209 }
210
211 $test->it('予定の来たジョブだけを実行するはず', function($test) use ($cron, $at, &$count): void {
212 $test->assertEquals([], $cron->run($at('2026-01-05 09:58'))->data['ran']);
213 $test->assertEquals([], $cron->run($at('2026-01-05 09:59'))->data['ran']);
214
215 $res = $cron->run($at('2026-01-05 10:00'));
216 $test->assertEquals([1, 2], array_keys($res->data['ran']));
217 $test->assertEquals('毎時', $res->data['ran'][2]);
218 $test->assertEquals(['every5' => 1, 'hourly' => 1], $count);
219
220 $test->assertEquals([], $cron->run($at('2026-01-05 10:01'))->data['ran']);
221 $test->assertEquals([1], array_keys($cron->run($at('2026-01-05 10:05'))->data['ran']));
222 $test->assertEquals($at('2026-01-05 10:05')->getTimestamp(), $cron->show(1)->data['lastRun']);
223 });
224
225 $test->it('同じ分に2回実行しないはず', function($test) use ($cron, $at, &$count): void {
226 $res = $cron->run($at('2026-01-05 10:05'));
227 $test->assertEquals('この分は実行済みです。', $res->message);
228 $test->assertEquals(2, $count['every5']);
229 });
230
231 $test->it('止まっていた間の分は1回だけ実行するはず', function($test) use ($cron, $at, &$count): void {
232 // 10:10〜10:35の間に6回予定があったが、1回だけ
233 $res = $cron->run($at('2026-01-05 10:35'));
234 $test->assertEquals([1], array_keys($res->data['ran']));
235 $test->assertEquals(3, $count['every5']);
236 });
237
238 $test->it('長く止まっていても各ジョブは1回だけ実行するはず', function($test) use ($cron, $at, &$count): void {
239 // 10:35から12:30まで(11:00と12:00の予定があった)。遡るのはCATCH_UP(1時間)まで
240 $res = $cron->run($at('2026-01-05 12:30'));
241 $test->assertEquals([1, 2], array_keys($res->data['ran']));
242 $test->assertEquals(['every5' => 4, 'hourly' => 2], $count);
243 });
244
245 $test->it('無効なジョブは実行しないはず', function($test) use ($cron, $at, &$count): void {
246 $cron->disable(1);
247 $test->assertEquals([], $cron->run($at('2026-01-05 12:35'))->data['ran']);
248 $cron->enable(1);
249 });
250
251 $test->it('失敗を記録するはず', function($test) use ($cron, $at): void {
252 $a = $cron->add(CronTab::fromString('* * * * * boom'))->data['id'];
253 $b = $cron->add(CronTab::fromString('* * * * * nope'))->data['id'];
254 $c = $cron->add(CronTab::fromString('* * * * * missing'))->data['id'];
255
256 $res = $cron->run($at('2026-01-05 12:36'));
257 $test->assertTrue($res->isSuccess);
258 $test->assertEquals([$a, $b, $c], array_keys($res->data['failed']));
259 $test->assertStringContains('壊れた', $cron->show($a)->data['lastError']);
260 $test->assertEquals('失敗', $cron->show($b)->data['lastError']);
261 $test->assertStringContains('登録されていません', $cron->show($c)->data['lastError']);
262
263 foreach ([$a, $b, $c] as $id) $cron->delete($id);
264 });
265
266 $test->it('前の実行が終わっていなければ何もしないはず', function($test) use ($cron, $at, $dir): void {
267 $fp = fopen("{$dir}/run.lock", 'c');
268 flock($fp, LOCK_EX);
269 $res = $cron->run($at('2026-01-05 12:40'));
270 flock($fp, LOCK_UN);
271 fclose($fp);
272
273 $test->assertTrue($res->isSuccess);
274 $test->assertTrue($res->data['busy'] ?? false);
275 $test->assertEquals([1], array_keys($cron->run($at('2026-01-05 12:40'))->data['ran']));
276 });
277
278 $test->it('予定に関係なく今すぐ実行出来るはず', function($test) use ($cron, &$count): void {
279 $res = $cron->runNow(2);
280 $test->assertTrue($res->isSuccess);
281 $test->assertEquals(3, $count['hourly']);
282 $test->assertFalse($cron->runNow(99)->isSuccess);
283 });
284});
285
286$test->describe('ActivityPubの連合(apfederate)', function($test) use ($tmp): void {
287 if (!ACTIVITYPUB_ENABLED) {
288 $test->skipAll('ACTIVITYPUB_ENABLEDが無効');
289 } else if (PHP_SAPI === 'cli' && empty(FEDIINFO['domain'])) {
290 $test->skipAll('FEDIINFOの「domain」が未設定');
291 }
292
293 // 受付ボックスの取得と送信だけを偽物にする
294 $make = fn(array $posts, array $inboxes) => new class($posts, $inboxes) extends Activitypub {
295 public array $sent = [];
296 public array $fail = [];
297 public function __construct(array $posts, private array $inboxes) {
298 parent::__construct($posts);
299 }
300 protected function followerInboxes(): array {
301 return $this->inboxes;
302 }
303 protected function sendActivity(string $inboxUrl, array $activity): bool {
304 if (in_array($inboxUrl, $this->fail, true)) return false;
305 $this->sent[] = $inboxUrl.' '.basename($activity['object']['id']);
306 return true;
307 }
308 public function sign(string $url, string $body, Rsa $key): ?array {
309 return $this->signRequest($url, $body, $key);
310 }
311 };
312 $post = fn(string $uuid, string $date, array $extra = []) => $extra + [
313 'title' => "投稿{$uuid}", 'date' => $date, 'thumbnail' => '', 'category' => [],
314 'uuid' => $uuid, 'preview' => 'a & b', 'slug' => "post-{$uuid}",
315 ];
316 $inboxes = ['https://a.example/inbox', 'https://b.example/inbox'];
317 $state = ACTIVITYPUB_ENABLED ? $tmp().'/ap_federated.json' : '';
318 $read = fn() => json_decode(file_get_contents($state), true);
319 $posts = [$post('old1', '2025-01-01 10:00'), $post('old2', '2025-02-01 10:00')];
320
321 $test->it('初回は既存の投稿を記録するだけのはず', function($test) use ($make, $posts, $inboxes, $state, $read): void {
322 $ap = $make($posts, $inboxes);
323 $res = $ap->federate($state);
324 $test->assertTrue($res->isSuccess);
325 $test->assertStringContains('初回', $res->message);
326 $test->assertEquals([], $ap->sent);
327 $test->assertEquals(['old1', 'old2'], array_keys($read()['sent']));
328 });
329
330 $test->it('新しい公開投稿だけを古い順に送信するはず', function($test) use ($make, $post, &$posts, $inboxes, $state, $read): void {
331 $posts[] = $post('new2', '2026-01-02 10:00');
332 $posts[] = $post('new1', '2026-01-01 10:00');
333 $posts[] = $post('member', '2026-01-03 10:00', ['category' => ['tech', 'memberonly']]);
334 $posts[] = $post('locked', '2026-01-03 10:00', ['preview' => '未許可']);
335 $posts[] = $post('', '2026-01-03 10:00');
336 $posts[] = $post('future', date('Y-m-d H:i:s', time() + 86400));
337
338 $ap = $make($posts, $inboxes);
339 $res = $ap->federate($state);
340 $test->assertTrue($res->isSuccess);
341 $test->assertEquals(['posts' => 2, 'delivered' => 4, 'failed' => 0], $res->data);
342 $test->assertEquals([
343 'https://a.example/inbox new1', 'https://b.example/inbox new1',
344 'https://a.example/inbox new2', 'https://b.example/inbox new2',
345 ], $ap->sent);
346
347 $s = $read();
348 $test->assertEquals(['old1', 'old2', 'new1', 'new2'], array_keys($s['sent']));
349 $test->assertEquals([], $s['pending']);
350 });
351
352 $test->it('送信済みの投稿は2回送らないはず', function($test) use ($make, &$posts, $inboxes, $state): void {
353 $ap = $make($posts, $inboxes);
354 $res = $ap->federate($state);
355 $test->assertEquals('新しい投稿はありません。', $res->message);
356 $test->assertEquals([], $ap->sent);
357 });
358
359 $test->it('失敗した受付ボックスだけを再試行するはず', function($test) use ($make, $post, &$posts, $inboxes, $state, $read): void {
360 $posts[] = $post('retry', '2026-01-04 10:00');
361
362 $ap = $make($posts, $inboxes);
363 $ap->fail = ['https://b.example/inbox'];
364 $res = $ap->federate($state);
365 $test->assertTrue($res->isSuccess);
366 $test->assertEquals(['posts' => 0, 'delivered' => 1, 'failed' => 1], $res->data);
367 $test->assertEquals(['inboxes' => ['https://b.example/inbox'], 'tries' => 1], $read()['pending']['retry']);
368
369 $ap = $make($posts, $inboxes);
370 $res = $ap->federate($state);
371 $test->assertEquals(['https://b.example/inbox retry'], $ap->sent);
372 $test->assertEquals(['posts' => 1, 'delivered' => 1, 'failed' => 0], $res->data);
373 $test->assertArrayHasKey('retry', $read()['sent']);
374 });
375
376 $test->it('FEDERATE_MAX_TRIES回失敗したら諦めるはず', function($test) use ($make, $post, &$posts, $inboxes, $state, $read): void {
377 $posts[] = $post('down', '2026-01-05 10:00');
378
379 for ($i = 1; $i <= Activitypub::FEDERATE_MAX_TRIES; $i++) {
380 $ap = $make($posts, $inboxes);
381 $ap->fail = $inboxes;
382 $res = $ap->federate($state);
383 $test->assertFalse($res->isSuccess, "{$i}回目は全部失敗なのでエラーのはず");
384 }
385 $test->assertEquals([], $read()['pending']);
386 $test->assertArrayHasKey('down', $read()['sent']);
387 });
388
389 $test->it('送信待ちの間に非公開になった投稿は送らないはず', function($test) use ($make, $post, &$posts, $inboxes, $state, $read): void {
390 $posts[] = $post('hidden', '2026-01-06 10:00');
391 $ap = $make($posts, $inboxes);
392 $ap->fail = $inboxes;
393 $ap->federate($state);
394 $test->assertArrayHasKey('hidden', $read()['pending']);
395
396 array_pop($posts);
397 $posts[] = $post('hidden', '2026-01-06 10:00', ['category' => ['staffonly']]);
398 $ap = $make($posts, $inboxes);
399 $ap->federate($state);
400 $test->assertEquals([], $ap->sent);
401 $test->assertEquals([], $read()['pending']);
402 });
403
404 $test->it('Createアクティビティを正しく作るはず', function($test) use ($make, $post): void {
405 $p = $post('x1', '2026-01-01 09:00', ['category' => ['php']]);
406 $ap = $make([$p], []);
407 $a = json_decode($ap->getActivity('x1'), true);
408
409 $test->assertEquals('Create', $a['type']);
410 $test->assertEquals(gmdate('Y-m-d\TH:i:s\Z', strtotime('2026-01-01 09:00')), $a['published']);
411 $test->assertStringContains('a &amp; b', $a['object']['content']);
412 $test->assertEquals([['type' => 'Hashtag', 'name' => '#php']], $a['object']['tag']);
413 $test->assertStringContains('/blog/post-x1', $a['object']['url']);
414 });
415
416 $test->it('ブログ毎に違うIDで送信し、後から追加したブログの既存の投稿は送らないはず', function($test) use ($make, $post, &$posts, $inboxes, $state, $read): void {
417 // 設定に無いブログ(testblog)は、初めて見た時に既存の投稿を送信済みにする
418 $posts[] = $post('tb1', '2026-01-07 10:00', ['section' => 'testblog']);
419 $ap = $make($posts, $inboxes);
420 $ap->federate($state);
421 $test->assertEquals([], $ap->sent);
422 $test->assertArrayHasKey('testblog/tb1', $read()['sent']);
423 $test->assertTrue(in_array('testblog', $read()['sections'], true));
424
425 // 同じUUID(翻訳)でもblogとは別の投稿
426 $posts[] = $post('down', '2026-01-08 10:00', ['section' => 'testblog']);
427 $ap = $make($posts, $inboxes);
428 $res = $ap->federate($state);
429 $test->assertEquals(['posts' => 1, 'delivered' => 2, 'failed' => 0], $res->data);
430 $test->assertArrayHasKey('testblog/down', $read()['sent']);
431
432 $a = json_decode($ap->getActivity('down', 'testblog'), true);
433 $test->assertStringContains('/ap/activities/create/testblog/down', $a['id']);
434 $test->assertStringContains('/ap/objects/testblog/down', $a['object']['id']);
435 $test->assertStringContains('/testblog/post-down', $a['object']['url']);
436 $test->assertStringContains('/blog/post-down', json_decode($ap->getActivity('down'), true)['object']['url']);
437 $test->assertEquals('', $ap->getObject('tb1'), 'blogには無い');
438 });
439
440 $test->it('(request-target)を含めて署名するはず', function($test) use ($make): void {
441 $key = Rsa::generate(2048);
442
443 $ap = $make([], []);
444 $body = '{"type":"Create"}';
445 $list = $ap->sign('https://a.example:443/users/x/inbox?y=1', $body, $key);
446 $test->assertNotNull($list);
447
448 $h = [];
449 foreach ($list as $line) {
450 [$k, $v] = explode(': ', $line, 2);
451 $h[$k] = $v;
452 }
453 $test->assertEquals('a.example', $h['Host']);
454 $test->assertEquals('SHA-256='.base64_encode(hash('sha256', $body, true)), $h['Digest']);
455 $test->assertStringContains('headers="(request-target) host date digest"', $h['Signature']);
456
457 preg_match('/signature="([^"]+)"/', $h['Signature'], $m);
458 $signed = "(request-target): post /users/x/inbox?y=1\nhost: {$h['Host']}\ndate: {$h['Date']}\ndigest: {$h['Digest']}";
459 $test->assertTrue(Rsa::fromPem($key->publicPem())->verify($signed, base64_decode($m[1])));
460 });
461});
462
463// 一時ディレクトリを削除する
464foreach ($tmpDirs as $dir) {
465 foreach (glob("{$dir}/*") ?: [] as $f) @unlink($f);
466 @rmdir($dir);
467}
468
469$test->printSummary();