Fassung 1.4.3: Update ohne Git, Verwaltung auf dem Telefon, eigene Symbole
Vier Wuensche und ein Fund beim Nachmessen. - Farbwaehler im Designbereich. Das Textfeld bleibt massgeblich, damit "@akzent" ein Verweis auf die Palette bleibt und nicht beim ersten Klick zu einem festen Hexwert erstarrt (ADR 066). - Die Verwaltung auf dem Telefon: das Menue klappt unter 760 px zu und nennt den Bereich, in dem man steht; Ziele 44 px, kein Skript. - Eigene SVG-Symbole statt Unicode-Zeichen - ein Telefon ersetzt die sonst durch Emoji in fremdem Stil. Zwei Eintraege trugen dabei denselben Pfad; ein Test findet beides (ADR 067). - Update aus dem Releasearchiv: der Weg fuer Installationen ohne Git. Zwei Schritte, weil ein Prozess, der sich selbst ausgetauscht hat, mit dem alten Code weiterarbeitet. var/, .env, hochgeladene Schriften und das erzeugte Buendel bleiben unberuehrt; public/ wird dazu Datei fuer Datei zusammengefuehrt. Geprueft ohne Netz gegen eine Releaseseite im Speicher (ADR 068). Beim Nachmessen der echten Startseite statt einer Testseite: das groesste Element ueber der Falz ist dort kein <img>, sondern der Hintergrund des ersten Abschnitts - und der steht nur im Stylesheet. Drei Vorabverweise mit denselben Geraetegrenzen wie der Compiler holen genau die Variante, die die Regel auch nimmt. LCP 1,3 s -> 1,1 s. Dabei gefunden: dasselbe Bild wurde je Geraeteregel neu abgefragt - Startseite 19 -> 15 Abfragen (ADR 069). Geprueft: 428 Tests auf PHP 8.2 und 8.4, Editor-, Browser- und Linkpruefung, Lighthouse auf drei echten Seiten (alles ausser der Leistung der Startseite 100). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea3dd4ddf2
commit
d6d476177a
26 files changed
+1740
-26
No files matched your search
@@ -0,0 +1,325 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\Download;
|
||||
use App\Ops\Updater;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Das Update über das Releasearchiv - der Weg für Installationen ohne Git.
|
||||
*
|
||||
* Auf geteiltem Hosting gibt es keine Arbeitskopie, also auch kein `git fetch`. Dort wird über
|
||||
* das Archiv installiert und über das Archiv aktualisiert, und das ist der Weg, den die meisten
|
||||
* Installationen dieses CMS nehmen werden. Er war bis hierhin ungeprüft.
|
||||
*
|
||||
* Geprüft wird gegen eine Installation in einem Wegwerfverzeichnis und eine Releaseseite, die im
|
||||
* Speicher liegt (@see ReleaseOnDisk): echte Archive, echte Prüfsummen, echtes Entpacken, echte
|
||||
* Umbenennungen - nur eben ohne Netz. Die Frage, um die es geht, ist nicht „läuft der Download",
|
||||
* sondern: Bleibt stehen, was dem Betreiber gehört?
|
||||
*/
|
||||
final class ArchiveUpdateTest extends TestCase
|
||||
{
|
||||
private const BASE = 'https://releases.example.invalid/download';
|
||||
private const API = 'https://releases.example.invalid/api';
|
||||
|
||||
private string $root;
|
||||
private Connection $db;
|
||||
private ReleaseOnDisk $releases;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->root = str_replace('\\', '/', sys_get_temp_dir()).'/kant-archiv-'.bin2hex(random_bytes(6));
|
||||
// Eine Installation, wie sie nach dem Entpacken eines Releases aussieht.
|
||||
foreach (['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'var/media', 'public/assets/fonts', 'public/assets/build'] as $verzeichnis) {
|
||||
mkdir($this->root.'/'.$verzeichnis, 0o775, true);
|
||||
}
|
||||
$this->write('VERSION', "1.0.0\n");
|
||||
$this->write('vendor/autoload.php', "<?php // alt\n");
|
||||
$this->write('src/Alt.php', "<?php // wird ersetzt\n");
|
||||
$this->write('composer.json', '{"name":"alt"}');
|
||||
$this->write('composer.lock', '{"packages":[]}');
|
||||
$this->write('migrations.php', "<?php return [];\n");
|
||||
$this->write('cli-config.php', "<?php // alt\n");
|
||||
$this->write('public/index.php', "<?php // alt\n");
|
||||
$this->write('public/assets/site.css', 'body{color:alt}');
|
||||
// Was dem Betreiber gehört - hochgeladene Schriften, das erzeugte Bündel, seine Medien,
|
||||
// seine Datenbank, sein Geheimnis. Nichts davon steht im Archiv.
|
||||
$this->write('public/assets/fonts/eigene.woff2', 'SCHRIFT');
|
||||
$this->write('public/assets/build/bundle.css', 'body{color:erzeugt}');
|
||||
$this->write('var/media/bild.jpg', 'BILD');
|
||||
$this->write('.env', "APP_SECRET=geheim\n");
|
||||
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.$this->root.'/var/cms.db');
|
||||
TestSchema::build($this->db);
|
||||
$this->releases = new ReleaseOnDisk();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
$this->removeDirectory($this->root);
|
||||
}
|
||||
|
||||
public function testAnUpdateReplacesTheApplicationAndLeavesTheInstallationUntouched(): void
|
||||
{
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||
|
||||
$ergebnis = $this->updater()->run();
|
||||
|
||||
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
|
||||
self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.');
|
||||
self::assertSame(
|
||||
['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
|
||||
array_column($ergebnis['steps'], 'label'),
|
||||
'Die Reihenfolge ist der Punkt: gesichert wird vor dem ersten Handgriff, geprüft vor dem Entpacken.',
|
||||
);
|
||||
|
||||
// Die Anwendung ist die neue.
|
||||
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')));
|
||||
self::assertFileExists($this->root.'/src/Neu.php');
|
||||
self::assertFileDoesNotExist($this->root.'/src/Alt.php', 'src/ wird ersetzt, nicht ergänzt.');
|
||||
self::assertStringContainsString('neu', (string) file_get_contents($this->root.'/public/index.php'));
|
||||
self::assertSame('body{color:neu}', file_get_contents($this->root.'/public/assets/site.css'));
|
||||
|
||||
// Und alles, was dem Betreiber gehört, steht noch da.
|
||||
self::assertSame('SCHRIFT', file_get_contents($this->root.'/public/assets/fonts/eigene.woff2'));
|
||||
self::assertFileDoesNotExist($this->root.'/public/assets/fonts/andere.woff2', 'Schriften aus dem Archiv würden die hochgeladenen überschreiben.');
|
||||
self::assertSame('body{color:erzeugt}', file_get_contents($this->root.'/public/assets/build/bundle.css'));
|
||||
self::assertSame('BILD', file_get_contents($this->root.'/var/media/bild.jpg'));
|
||||
self::assertStringContainsString('APP_SECRET=geheim', (string) file_get_contents($this->root.'/.env'));
|
||||
self::assertFileExists($this->root.'/var/cms.db');
|
||||
|
||||
// Die Website bleibt bis zu den Migrationen im Wartungszustand, und der alte Stand liegt
|
||||
// noch da, falls doch etwas zurückgeschoben werden muss.
|
||||
self::assertFileExists($this->root.'/'.Updater::MAINTENANCE_FILE);
|
||||
self::assertCount(1, glob($this->root.'/var/backups/sicherung-*') ?: []);
|
||||
self::assertCount(1, glob($this->root.'/var/update/*/alt/VERSION') ?: []);
|
||||
|
||||
// Der zweite Schritt ist auf der Platte vermerkt, nicht in der Sitzung: fällt sie aus,
|
||||
// bliebe die Website sonst im Wartungszustand und der Knopf wäre weg.
|
||||
$offen = $this->updater()->pending();
|
||||
self::assertNotNull($offen, 'Ohne Marke findet niemand mehr den zweiten Schritt.');
|
||||
self::assertSame('1.1.0', $offen['fassung']);
|
||||
}
|
||||
|
||||
public function testNothingIsPendingBeforeAnUpdateAndAfterAFailedOne(): void
|
||||
{
|
||||
self::assertNull($this->updater()->pending());
|
||||
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
|
||||
$this->updater()->run();
|
||||
|
||||
self::assertNull($this->updater()->pending(), 'Ein Update, das nicht ersetzt hat, lässt nichts offen.');
|
||||
}
|
||||
|
||||
public function testAnArchiveWithTheWrongChecksumIsNeverUnpacked(): void
|
||||
{
|
||||
// Der Fall, für den die Prüfsumme da ist: unterwegs ausgetauscht oder halb übertragen.
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
|
||||
|
||||
$ergebnis = $this->updater()->run();
|
||||
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
self::assertSame('Prüfsumme', $this->firstFailed($ergebnis)['label']);
|
||||
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')), 'Nichts wurde ersetzt.');
|
||||
self::assertFileExists($this->root.'/src/Alt.php');
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die Website darf davon nichts merken.');
|
||||
}
|
||||
|
||||
public function testAnIncompleteArchiveIsRefusedBeforeTheSiteGoesIntoMaintenance(): void
|
||||
{
|
||||
// Ein Archiv ohne vendor/ ist auf einem Hosting ohne Composer wertlos - das muss auffallen,
|
||||
// bevor die halbe Anwendung ersetzt ist.
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0', false));
|
||||
|
||||
$ergebnis = $this->updater()->run();
|
||||
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
self::assertSame('Entpacken', $this->firstFailed($ergebnis)['label']);
|
||||
self::assertStringContainsString('vendor', $this->firstFailed($ergebnis)['detail']);
|
||||
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
|
||||
}
|
||||
|
||||
public function testAnUnreachableReleasePageChangesNothingAtAll(): void
|
||||
{
|
||||
// Viele Hoster lassen ausgehende Verbindungen nicht zu. Dann passiert hier gar nichts -
|
||||
// insbesondere wird nicht gesichert, nicht ersetzt und kein Arbeitsordner angelegt.
|
||||
$ergebnis = $this->updater()->run();
|
||||
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
self::assertCount(1, $ergebnis['steps']);
|
||||
self::assertStringContainsString('Releaseseite', $ergebnis['steps'][0]['detail']);
|
||||
self::assertSame([], glob($this->root.'/var/update/*') ?: []);
|
||||
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
|
||||
}
|
||||
|
||||
public function testTheNewestVersionAlreadyInstalledIsNotAFailure(): void
|
||||
{
|
||||
$this->releases->release(self::BASE, self::API, '1.0.0', $this->archive('1.0.0'));
|
||||
$updater = $this->updater();
|
||||
|
||||
self::assertNull($updater->updateAvailable());
|
||||
$ergebnis = $updater->run();
|
||||
|
||||
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
|
||||
self::assertFalse($ergebnis['migrate'] ?? false);
|
||||
self::assertSame('Nichts zu tun', end($ergebnis['steps'])['label']);
|
||||
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
|
||||
}
|
||||
|
||||
public function testTheAvailableVersionIsOnlyReportedWhenItIsNewer(): void
|
||||
{
|
||||
self::assertNull($this->updater()->updateAvailable(), 'Ohne Antwort gibt es keine neue Fassung.');
|
||||
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
|
||||
self::assertSame('1.1.0', $this->updater()->updateAvailable());
|
||||
|
||||
// Etwas, das keine Fassung ist, wird nicht angeboten - sonst lädt der Updater irgendetwas.
|
||||
$this->releases->serve(self::API.'/releases/latest', json_encode(['tag_name' => 'nightly']));
|
||||
self::assertNull($this->updater()->updateAvailable());
|
||||
}
|
||||
|
||||
public function testTheDownloaderRefusesEverythingThatIsNotHttps(): void
|
||||
{
|
||||
// Kein Netz nötig, um das zu prüfen: die Sperre greift vor dem ersten Byte. Ein Update
|
||||
// über eine unverschlüsselte Verbindung könnte unterwegs ausgetauscht worden sein.
|
||||
$download = new Download();
|
||||
foreach (['http://releases.example.invalid/a.zip', 'ftp://releases.example.invalid/a.zip', 'file:///etc/passwd', '/tmp/a.zip'] as $url) {
|
||||
self::assertNull($download->get($url), $url.' darf nicht geholt werden.');
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheUpdaterReplacesEveryEntryTheReleaseArchiveShips(): void
|
||||
{
|
||||
// Der Fehler, der schon einmal auf dem Hosting aufgeschlagen ist, andersherum: Kommt eine
|
||||
// Datei neu ins Release, ohne dass sie hier in der Ersetzungsliste steht, bleibt sie beim
|
||||
// Update auf dem alten Stand - und das fällt erst auf, wenn etwas nicht mehr passt.
|
||||
$skript = (string) file_get_contents(__DIR__.'/../scripts/build-release.php');
|
||||
preg_match('/\$include = \[(.+?)\];/s', $skript, $mit);
|
||||
self::assertNotEmpty($mit, 'Die Mitnahmeliste des Releaseskripts ist nicht auffindbar.');
|
||||
|
||||
$quelle = (string) file_get_contents(__DIR__.'/../src/Ops/ArchiveUpdate.php');
|
||||
preg_match('/ERSETZEN = \[(.+?)\];/s', $quelle, $ersetzt);
|
||||
self::assertNotEmpty($ersetzt, 'Die Ersetzungsliste des Updaters ist nicht auffindbar.');
|
||||
$liste = static fn (string $roh): array => array_filter(array_map(
|
||||
static fn (string $eintrag): string => trim($eintrag, " '\r\n\t"),
|
||||
explode(',', $roh),
|
||||
));
|
||||
|
||||
// public/ wird Datei für Datei zusammengeführt statt ersetzt, die drei anderen sind
|
||||
// Beiwerk, das eine laufende Installation nicht liest.
|
||||
$anders = ['public', 'README.md', 'LICENSE', '.env.example'];
|
||||
foreach ($liste($mit[1]) as $eintrag) {
|
||||
self::assertContains(
|
||||
$eintrag,
|
||||
[...$liste($ersetzt[1]), ...$anders],
|
||||
sprintf('„%s" liegt im Releasearchiv, wird beim Update aber weder ersetzt noch bewusst ausgelassen.', $eintrag),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function updater(): ArchiveUpdate
|
||||
{
|
||||
$runner = new CommandRunner();
|
||||
$url = 'sqlite:///'.$this->root.'/var/cms.db';
|
||||
|
||||
return new ArchiveUpdate(
|
||||
$this->root,
|
||||
new Updater($this->root, $url, $runner, $this->db),
|
||||
new Backup($this->root, $url, $this->root.'/var/media', $this->db, $runner),
|
||||
$this->releases,
|
||||
self::BASE,
|
||||
self::API,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut ein Releasearchiv, wie `scripts/build-release.php` es baut: ein Verzeichnis
|
||||
* `kantsites-<Fassung>/` im Zip, darin die Anwendung.
|
||||
*/
|
||||
private function archive(string $version, bool $vollstaendig = true): string
|
||||
{
|
||||
$pfad = $this->root.'/var/release-'.$version.'.zip';
|
||||
@mkdir(dirname($pfad), 0o775, true);
|
||||
$zip = new \ZipArchive();
|
||||
self::assertTrue($zip->open($pfad, \ZipArchive::CREATE | \ZipArchive::OVERWRITE));
|
||||
$inhalt = [
|
||||
'VERSION' => $version."\n",
|
||||
'src/Neu.php' => "<?php // neu\n",
|
||||
'bin/console' => "<?php // neu\n",
|
||||
'config/services.yaml' => "services:\n",
|
||||
'templates/base.html.twig' => 'neu',
|
||||
'migrations.php' => "<?php return ['neu'];\n",
|
||||
'cli-config.php' => "<?php // neu\n",
|
||||
'composer.json' => '{"name":"neu"}',
|
||||
'composer.lock' => '{"packages":["neu"]}',
|
||||
'public/index.php' => "<?php // neu\n",
|
||||
'public/assets/site.css' => 'body{color:neu}',
|
||||
// Das Archiv bringt eigene Schriften und ein eigenes Bündel mit - beides darf die
|
||||
// Installation nicht überschreiben.
|
||||
'public/assets/fonts/andere.woff2' => 'FREMD',
|
||||
'public/assets/build/bundle.css' => 'body{color:fremd}',
|
||||
];
|
||||
if ($vollstaendig) {
|
||||
$inhalt['vendor/autoload.php'] = "<?php // neu\n";
|
||||
}
|
||||
foreach ($inhalt as $name => $text) {
|
||||
$zip->addFromString('kantsites-'.$version.'/'.$name, $text);
|
||||
}
|
||||
$zip->close();
|
||||
$roh = (string) file_get_contents($pfad);
|
||||
unlink($pfad);
|
||||
|
||||
return $roh;
|
||||
}
|
||||
|
||||
private function write(string $relativ, string $inhalt): void
|
||||
{
|
||||
@mkdir(dirname($this->root.'/'.$relativ), 0o775, true);
|
||||
file_put_contents($this->root.'/'.$relativ, $inhalt);
|
||||
}
|
||||
|
||||
/** @param array{steps: list<array{label: string, ok: bool, detail: string}>} $ergebnis */
|
||||
private function firstFailed(array $ergebnis): array
|
||||
{
|
||||
foreach ($ergebnis['steps'] as $schritt) {
|
||||
if (!$schritt['ok']) {
|
||||
return $schritt;
|
||||
}
|
||||
}
|
||||
self::fail('Kein gescheiterter Schritt: '.$this->explain($ergebnis));
|
||||
}
|
||||
|
||||
private function explain(array $ergebnis): string
|
||||
{
|
||||
return implode("\n", array_map(
|
||||
static fn (array $schritt): string => sprintf('%s %s: %s', $schritt['ok'] ? 'ok' : 'FEHLER', $schritt['label'], $schritt['detail']),
|
||||
$ergebnis['steps'],
|
||||
));
|
||||
}
|
||||
|
||||
private function removeDirectory(string $pfad): void
|
||||
{
|
||||
if (!is_dir($pfad)) {
|
||||
@unlink($pfad);
|
||||
|
||||
return;
|
||||
}
|
||||
foreach (scandir($pfad) ?: [] as $eintrag) {
|
||||
if ('.' !== $eintrag && '..' !== $eintrag) {
|
||||
@chmod($pfad.'/'.$eintrag, 0o777);
|
||||
$this->removeDirectory($pfad.'/'.$eintrag);
|
||||
}
|
||||
}
|
||||
@rmdir($pfad);
|
||||
}
|
||||
}
|
||||
@@ -1077,6 +1077,30 @@ final class CmsTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testTheAdministrationDrawsItsOwnIconsInsteadOfBorrowingCharacters(): void
|
||||
{
|
||||
// Umut: „Machst du auch gleich eigene Icons statt die Web-Dinger zu nutzen – auf dem
|
||||
// Smartphone kommen sonst Emojis.“ Genau so ist es: ein Telefon ersetzt ein Zeichen wie
|
||||
// das Zahnrad durch ein buntes Emoji in fremdem Stil, und die Navigation sieht aus wie
|
||||
// zusammengewürfelt. Eigene Pfade sind eine Linienstärke, eine Farbe und kein Request.
|
||||
$shell = (string) file_get_contents(__DIR__.'/../templates/admin/shell.html.twig');
|
||||
self::assertDoesNotMatchRegularExpression(
|
||||
'/[\\x{2190}-\\x{2BFF}\\x{1F300}-\\x{1FAFF}]/u',
|
||||
$shell,
|
||||
'In der Navigation steht ein Zeichen, das ein Telefon zum Emoji macht.',
|
||||
);
|
||||
self::assertStringContainsString('admin/icon.html.twig', $shell);
|
||||
|
||||
$icons = (string) file_get_contents(__DIR__.'/../templates/admin/icon.html.twig');
|
||||
// Jedes verwendete Symbol muss es geben - sonst zeichnet die Vorlage stillschweigend das
|
||||
// Ersatzsymbol, und zwei Einträge sehen gleich aus.
|
||||
foreach (['pages', 'partials', 'menus', 'media', 'forms', 'statistics', 'theme', 'settings', 'system', 'users', 'external'] as $name) {
|
||||
self::assertStringContainsString($name.": 'M", $icons, $name.' fehlt in der Symbolsammlung.');
|
||||
}
|
||||
preg_match_all("/'(M[^']+)'/", $icons, $pfade);
|
||||
self::assertSame(count($pfade[1]), count(array_unique($pfade[1])), 'Zwei Symbole tragen denselben Pfad.');
|
||||
}
|
||||
|
||||
public function testNoStylesheetHasADanglingSelector(): void
|
||||
{
|
||||
// Gefunden, als der Einwilligungsdialog plötzlich ungestaltet unter dem Fuß stand: beim
|
||||
@@ -2913,6 +2937,71 @@ final class CmsTest extends TestCase
|
||||
self::assertStringNotContainsString('fetchpriority', $second);
|
||||
}
|
||||
|
||||
public function testTheBackgroundOfTheFirstSectionIsFetchedBeforeTheStylesheetFindsIt(): void
|
||||
{
|
||||
// Auf der Startseite des Piloten ist das größte Element über der Falz kein `<img>`, sondern
|
||||
// der Hintergrund des ersten Abschnitts. Ein Hintergrundbild steht nur im Stylesheet, und
|
||||
// das kommt nach dem HTML - der Browser erfährt also erst vom Bild, wenn er das CSS
|
||||
// geparst hat. Gemessen: 1,3 s statt 0,9 s bis zum größten Element.
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
$datei = tempnam(sys_get_temp_dir(), 'kant-hero-');
|
||||
$limit = ini_get('memory_limit');
|
||||
ini_set('memory_limit', '512M');
|
||||
|
||||
try {
|
||||
$bild = imagecreatetruecolor(2600, 1600);
|
||||
imagejpeg($bild, $datei, 80);
|
||||
imagedestroy($bild);
|
||||
$item = $media->upload(new UploadedFile($datei, 'hero.jpg', test: true));
|
||||
|
||||
$version = static fn (Connection $db): int => (int) $db->fetchOne('SELECT version FROM page WHERE id = ?', ['page-1']);
|
||||
$ohne = DemoContent::starter('Ohne Hintergrund');
|
||||
$entwurf = $this->pages->saveDraft('page-1', $ohne, $version($this->db));
|
||||
$this->pages->publish('page-1', $entwurf, $version($this->db));
|
||||
$sparsam = (int) $this->request('/')->headers->get('X-Kant-Queries');
|
||||
|
||||
$mit = DemoContent::starter('Mit Hintergrund');
|
||||
$mit['schemaVersion'] = max(10, $mit['schemaVersion'] ?? 10);
|
||||
$mit['blocks'][0]['style']['desktop']['backgroundImage'] = $item['id'];
|
||||
$entwurf = $this->pages->saveDraft('page-1', $mit, $version($this->db));
|
||||
$this->pages->publish('page-1', $entwurf, $version($this->db));
|
||||
$antwort = $this->request('/');
|
||||
$html = (string) $antwort->getContent();
|
||||
|
||||
// Drei Verweise, einer je Gerät - und genau einer trifft zu.
|
||||
preg_match_all('/<link rel="preload" as="image" href="([^"]+)" media="([^"]+)"[^>]*>/', $html, $treffer, PREG_SET_ORDER);
|
||||
self::assertCount(3, $treffer, 'Der Hintergrund des ersten Abschnitts wird nicht vorab geholt.');
|
||||
self::assertSame(
|
||||
['(min-width:1025px)', '(min-width:768px) and (max-width:1024px)', '(max-width:767px)'],
|
||||
array_column($treffer, 2),
|
||||
);
|
||||
self::assertSame(
|
||||
['/media/'.$item['id'].'/2400', '/media/'.$item['id'].'/1600', '/media/'.$item['id'].'/960'],
|
||||
array_column($treffer, 1),
|
||||
'Jedes Gerät bekommt die Variante, die es auch ohne Vorabholen bekäme.',
|
||||
);
|
||||
foreach ($treffer as $verweis) {
|
||||
// Der Punkt der Übung: Vorabverweis und Regel müssen dieselbe Datei meinen. Sonst
|
||||
// lädt der Browser zwei Bilder statt einem, und das Vorabholen kostet mehr als es
|
||||
// bringt.
|
||||
self::assertStringContainsString('url("'.$verweis[1].'")', $html, 'Der Vorabverweis meint eine andere Datei als die Regel.');
|
||||
self::assertStringContainsString('fetchpriority="high"', $verweis[0]);
|
||||
}
|
||||
|
||||
// Dasselbe Bild wird während einer Ausgabe mehrfach gesucht: je Gerät eine Regel, dazu
|
||||
// der Kopf. Gefragt wird trotzdem nur einmal.
|
||||
$mitBild = (int) $antwort->headers->get('X-Kant-Queries');
|
||||
self::assertLessThanOrEqual($sparsam + 1, $mitBild, sprintf(
|
||||
'Ein Hintergrundbild kostet eine Abfrage, nicht vier (%d statt %d).',
|
||||
$mitBild,
|
||||
$sparsam,
|
||||
));
|
||||
} finally {
|
||||
@unlink($datei);
|
||||
ini_set('memory_limit', (string) $limit);
|
||||
}
|
||||
}
|
||||
|
||||
public function testAPageDoesNotAskTheDatabaseOverAndOver(): void
|
||||
{
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Ops\Download;
|
||||
|
||||
/**
|
||||
* Eine Releaseseite, die im Dateisystem liegt statt im Netz.
|
||||
*
|
||||
* Ein Test, der wirklich nach git.kant-it.de greift, prüft die Leitung und nicht den Updater: er
|
||||
* schlägt fehl, wenn der Server umzieht, und er ist grün, wenn im Archiv Unsinn steht. Diese
|
||||
* Attrappe beantwortet dieselben drei Adressen - Releaseliste, Archiv, Prüfsumme - aus dem
|
||||
* Speicher und merkt sich, was abgefragt wurde.
|
||||
*/
|
||||
final class ReleaseOnDisk extends Download
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
private array $antworten = [];
|
||||
|
||||
/** @var list<string> */
|
||||
public array $abgefragt = [];
|
||||
|
||||
public function serve(string $url, string $inhalt): void
|
||||
{
|
||||
$this->antworten[$url] = $inhalt;
|
||||
}
|
||||
|
||||
/** Legt ein vollständiges Release ab: Releaseliste, Archiv und Prüfsumme. */
|
||||
public function release(string $base, string $api, string $version, string $archiv, ?string $checksum = null): void
|
||||
{
|
||||
$this->serve($api.'/releases/latest', json_encode(['tag_name' => 'v'.$version], JSON_THROW_ON_ERROR));
|
||||
$this->serve(sprintf('%s/v%s/kantsites-%s.zip', $base, $version, $version), $archiv);
|
||||
$this->serve(sprintf('%s/v%s/kantsites-%s.zip.sha256', $base, $version, $version), sprintf(
|
||||
"%s kantsites-%s.zip\n",
|
||||
$checksum ?? hash('sha256', $archiv),
|
||||
$version,
|
||||
));
|
||||
}
|
||||
|
||||
public function get(string $url): ?string
|
||||
{
|
||||
$this->abgefragt[] = $url;
|
||||
|
||||
return $this->antworten[$url] ?? null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user