diff --git a/.gitignore b/.gitignore
index 1d0f0533f2..4188afacf9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,6 @@ dev/yasd_init.php
.phpunit.result.cache
Makefile
appwrite.config.json
-/.zed/
\ No newline at end of file
+/.zed/
+playwright-report
+test-results
diff --git a/Dockerfile b/Dockerfile
index d097edf0ca..d13d23309c 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -39,6 +39,7 @@ COPY ./bin /usr/local/bin
COPY ./docs /usr/src/code/docs
COPY ./src /usr/src/code/src
COPY ./dev /usr/src/code/dev
+COPY ./mongo-entrypoint.sh /usr/src/code/mongo-entrypoint.sh
# Set Volumes
RUN mkdir -p /storage/uploads && \
@@ -101,5 +102,6 @@ RUN if [ "$DEBUG" = "false" ]; then rm -rf /usr/src/code/dev; fi
RUN if [ "$DEBUG" = "false" ]; then rm -f /usr/local/lib/php/extensions/no-debug-non-zts-20230831/xdebug.so; fi
EXPOSE 80
+EXPOSE 8080
CMD [ "php", "app/http.php" ]
diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml
index e3ca65fa97..a934769713 100644
--- a/app/views/install/compose.phtml
+++ b/app/views/install/compose.phtml
@@ -11,6 +11,9 @@ $httpsPort = $this->getParam('httpsPort', '');
$version = $this->getParam('version', '');
$organization = $this->getParam('organization', '');
$image = $this->getParam('image', '');
+$dbService = $this->getParam('database');
+$hostPath = $this->getParam('hostPath', '');
+
?>services:
traefik:
image: traefik:2.11
@@ -69,7 +72,7 @@ $image = $this->getParam('image', '');
- appwrite-sites:/storage/sites:rw
- appwrite-builds:/storage/builds:rw
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
- redis
# - clamav
environment:
@@ -221,7 +224,7 @@ $image = $this->getParam('image', '');
networks:
- appwrite
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -251,7 +254,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -277,7 +280,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -305,7 +308,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
volumes:
- appwrite-uploads:/storage/uploads:rw
- appwrite-cache:/storage/cache:rw
@@ -368,7 +371,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -394,7 +397,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
volumes:
- appwrite-functions:/storage/functions:rw
- appwrite-sites:/storage/sites:rw
@@ -462,7 +465,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
volumes:
- appwrite-config:/storage/config:rw
- appwrite-certificates:/storage/certificates:rw
@@ -499,7 +502,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
- openruntimes-executor
environment:
- _APP_ENV
@@ -624,7 +627,7 @@ $image = $this->getParam('image', '');
volumes:
- appwrite-imports:/storage/imports:rw
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -698,7 +701,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -727,7 +730,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -755,7 +758,7 @@ $image = $this->getParam('image', '');
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -782,7 +785,7 @@ $image = $this->getParam('image', '');
networks:
- appwrite
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -807,7 +810,7 @@ $image = $this->getParam('image', '');
networks:
- appwrite
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -832,7 +835,7 @@ $image = $this->getParam('image', '');
networks:
- appwrite
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -918,6 +921,7 @@ $image = $this->getParam('image', '');
- OPR_EXECUTOR_STORAGE_WASABI_REGION=$_APP_STORAGE_WASABI_REGION
- OPR_EXECUTOR_STORAGE_WASABI_BUCKET=$_APP_STORAGE_WASABI_BUCKET
+
mariadb:
image: mariadb:10.11 # fix issues when upgrading using: mysql_upgrade -u root -p
container_name: appwrite-mariadb
@@ -927,6 +931,8 @@ $image = $this->getParam('image', '');
- appwrite
volumes:
- appwrite-mariadb:/var/lib/mysql:rw
+ ports:
+ - "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=${_APP_DB_ROOT_PASS}
- MYSQL_DATABASE=${_APP_DB_SCHEMA}
@@ -934,6 +940,24 @@ $image = $this->getParam('image', '');
- MYSQL_PASSWORD=${_APP_DB_PASS}
- MARIADB_AUTO_UPGRADE=1
command: 'mysqld --innodb-flush-method=fsync'
+
+ mongodb:
+ image: mongo:8.0.8
+ container_name: appwrite-mongodb
+ <<: *x-logging
+ restart: unless-stopped
+ networks:
+ - appwrite
+ volumes:
+ - appwrite-mongodb:/data/db:rw
+ - appwrite-mongodb-config:/data/configdb:rw
+ - ./mongo-entrypoint.sh:/mongo-entrypoint.sh:ro
+ ports:
+ - "27017:27017"
+ environment:
+ - MONGO_INITDB_DATABASE=${_APP_DB_SCHEMA}
+ entrypoint: ["/bin/bash", "/mongo-entrypoint.sh"]
+
redis:
image: redis:7.2.4-alpine
@@ -968,7 +992,12 @@ networks:
name: runtimes
volumes:
+
appwrite-mariadb:
+
+ appwrite-mongodb:
+ appwrite-mongodb-config:
+
appwrite-redis:
appwrite-cache:
appwrite-uploads:
diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml
new file mode 100644
index 0000000000..793b5b9b96
--- /dev/null
+++ b/app/views/install/installer.phtml
@@ -0,0 +1,832 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Step 1 of 6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Note: Use the Generate button for a secure random key, or provide your own. Make sure to back it up in a secure location.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Your Appwrite instance has been successfully and is now running.
+
+
+
Next Steps:
+
+ - Access your Appwrite console at: http:///console
+ - Create your first project and start building!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/composer.lock b/composer.lock
index 4dccb29a1a..417c9d3c03 100644
--- a/composer.lock
+++ b/composer.lock
@@ -6663,16 +6663,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "9.6.29",
+ "version": "9.6.30",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3"
+ "reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9ecfec57835a5581bc888ea7e13b51eb55ab9dd3",
- "reference": "9ecfec57835a5581bc888ea7e13b51eb55ab9dd3",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b69489b312503bf8fa6d75a76916919d7d2fa6d4",
+ "reference": "b69489b312503bf8fa6d75a76916919d7d2fa6d4",
"shasum": ""
},
"require": {
@@ -6746,7 +6746,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.29"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.30"
},
"funding": [
{
@@ -6770,7 +6770,7 @@
"type": "tidelift"
}
],
- "time": "2025-09-24T06:29:11+00:00"
+ "time": "2025-12-01T07:35:08+00:00"
},
{
"name": "psr/cache",
diff --git a/mongo-entrypoint.sh b/mongo-entrypoint.sh
new file mode 100644
index 0000000000..36b8b930dd
--- /dev/null
+++ b/mongo-entrypoint.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+set -e
+
+# Start MongoDB in the background
+mongod --replSet rs0 --bind_ip_all &
+
+# Wait for MongoDB to start
+echo "Waiting for MongoDB to start..."
+until mongosh --eval "print('MongoDB is ready')" > /dev/null 2>&1; do
+ sleep 1
+done
+
+# Initialize replica set if not already initialized
+echo "Initializing replica set..."
+mongosh --eval "
+try {
+ rs.status();
+ print('Replica set already initialized');
+} catch (e) {
+ rs.initiate({
+ _id: 'rs0',
+ members: [{ _id: 0, host: 'localhost:27017' }]
+ });
+ print('Replica set initialized');
+}
+"
+
+# Wait for replica set to be ready
+echo "Waiting for replica set to be ready..."
+until mongosh --eval "rs.status().ok" > /dev/null 2>&1; do
+ sleep 1
+done
+
+echo "MongoDB replica set is ready"
+
+# Keep the container running by waiting on the MongoDB process
+wait
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000000..fdb9a4975d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,79 @@
+{
+ "name": "@appwrite.io/repo",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@appwrite.io/repo",
+ "version": "1.0.0",
+ "license": "ISC",
+ "devDependencies": {
+ "@playwright/test": "^1.56.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.56.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz",
+ "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.56.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.56.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
+ "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.56.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.56.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
+ "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 1e2b1be430..84dd910930 100644
--- a/package.json
+++ b/package.json
@@ -8,5 +8,30 @@
"files": [
"docs/examples",
"app/config/specs"
- ]
+ ],
+ "version": "1.0.0",
+ "description": "> We just announced Timestamp Overrides for Appwrite Databases - [Learn more](https://appwrite.io/blog/post/announcing-timestamp-overrides)",
+ "main": "index.js",
+ "directories": {
+ "doc": "docs",
+ "test": "tests"
+ },
+ "scripts": {
+ "test": "playwright test",
+ "test:installer": "playwright test tests/playwright/installer.spec.js",
+ "test:screenshots": "playwright test tests/playwright/installer-screenshots.spec.js --project=chromium",
+ "test:headed": "playwright test --headed",
+ "test:ui": "playwright test --ui"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "commonjs",
+ "bugs": {
+ "url": "https://github.com/appwrite/appwrite/issues"
+ },
+ "homepage": "https://github.com/appwrite/appwrite#readme",
+ "devDependencies": {
+ "@playwright/test": "^1.56.0"
+ }
}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000000..7193d8b28f
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,36 @@
+// @ts-check
+const { defineConfig, devices } = require('@playwright/test');
+
+/**
+ * @see https://playwright.dev/docs/test-configuration
+ */
+module.exports = defineConfig({
+ testDir: './tests/playwright',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: 'html',
+ use: {
+ baseURL: 'http://localhost:8080',
+ trace: 'on-first-retry',
+ screenshot: 'on',
+ },
+
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+
+ {
+ name: 'firefox',
+ use: { ...devices['Desktop Firefox'] },
+ },
+
+ {
+ name: 'webkit',
+ use: { ...devices['Desktop Safari'] },
+ },
+ ],
+});
diff --git a/public/images/logos/mariadb.svg b/public/images/logos/mariadb.svg
new file mode 100644
index 0000000000..0d00509e3d
--- /dev/null
+++ b/public/images/logos/mariadb.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/images/logos/mongodb.svg b/public/images/logos/mongodb.svg
new file mode 100644
index 0000000000..54403d5284
--- /dev/null
+++ b/public/images/logos/mongodb.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php
index 5f2a966220..1abd61310d 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php
@@ -102,11 +102,9 @@ class Get extends Action
// Use transaction-aware document retrieval if transactionId is provided
if ($transactionId !== null) {
$document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId, $queries);
- } elseif (! empty($selects)) {
- // has selects, allow relationship on documents!
+ } elseif (!empty($selects)) {
$document = $dbForProject->getDocument($collectionTableId, $documentId, $queries);
} else {
- // has no selects, disable relationship looping on documents!
$document = $dbForProject->skipRelationships(fn () => $dbForProject->getDocument($collectionTableId, $documentId, $queries));
}
} catch (QueryException $e) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
index 8bbaecedc9..0edeb40596 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
@@ -16,6 +16,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
+use Utopia\Database\Exception\Timeout;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Query\Cursor;
@@ -150,6 +151,8 @@ class XList extends Action
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, $message);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ } catch (Timeout) {
+ throw new Exception(Exception::DATABASE_TIMEOUT);
}
$operations = 0;
diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php
index b210a020b9..9c51c4a91c 100644
--- a/src/Appwrite/Platform/Tasks/Install.php
+++ b/src/Appwrite/Platform/Tasks/Install.php
@@ -12,10 +12,12 @@ use Utopia\Config\Config;
use Utopia\Platform\Action;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
+use Utopia\Validator\WhiteList;
class Install extends Action
{
protected string $path = '/usr/src/code/appwrite';
+ protected ?string $hostPath = null;
public static function getName(): string
{
@@ -33,6 +35,9 @@ class Install extends Action
->param('interactive', 'Y', new Text(1), 'Run an interactive session', true)
->param('no-start', false, new Boolean(true), 'Run an interactive session', true)
->callback($this->action(...));
+
+ $this->hostPath = $this->detectHostPath($this->path);
+ $this->ensureHostPathLink();
}
public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart): void
@@ -40,7 +45,7 @@ class Install extends Action
$config = Config::getParam('variables');
$defaultHTTPPort = '80';
$defaultHTTPSPort = '443';
- /** @var array> $vars array whre key is variable name and value is variable */
+ /** @var array> $vars array where key is variable name and value is variable */
$vars = [];
foreach ($config as $category) {
@@ -59,18 +64,11 @@ class Install extends Action
}
}
+ // Check for existing installation
$data = @file_get_contents($this->path . '/docker-compose.yml');
+ $existingInstallation = $data !== false;
- if ($data !== false) {
- if ($interactive == 'Y' && Console::isInteractive()) {
- $answer = Console::confirm('Previous installation found, do you want to overwrite it (a backup will be created before overwriting)? (Y/n)');
-
- if (\strtolower($answer) !== 'y') {
- Console::info('No action taken.');
- return;
- }
- }
-
+ if ($existingInstallation) {
$time = \time();
Console::info('Compose file found, creating backup: docker-compose.yml.' . $time . '.backup');
file_put_contents($this->path . '/docker-compose.yml.' . $time . '.backup', $data);
@@ -88,7 +86,7 @@ class Install extends Action
}
if ($oldVersion) {
- foreach ($compose->getServices() as $service) { // Fetch all env vars from previous compose file
+ foreach ($compose->getServices() as $service) {
if (!$service) {
continue;
}
@@ -107,12 +105,12 @@ class Install extends Action
}
}
- $data = @file_get_contents($this->path . '/.env');
+ $envData = @file_get_contents($this->path . '/.env');
- if ($data !== false) { // Fetch all env vars from previous .env file
+ if ($envData !== false) {
Console::info('Env file found, creating backup: .env.' . $time . '.backup');
- file_put_contents($this->path . '/.env.' . $time . '.backup', $data);
- $env = new Env($data);
+ file_put_contents($this->path . '/.env.' . $time . '.backup', $envData);
+ $env = new Env($envData);
foreach ($env->list() as $key => $value) {
if (is_null($value)) {
@@ -138,6 +136,17 @@ class Install extends Action
}
}
+ // If interactive and web mode enabled, start web server
+ if ($interactive === 'Y' && Console::isInteractive()) {
+ Console::success('Starting web installer...');
+ Console::info('Open your browser at: http://localhost:8080');
+ Console::info('Press Ctrl+C to cancel installation');
+
+ $this->startWebServer($defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars);
+ return;
+ }
+
+ // Fall back to CLI mode
if (empty($httpPort)) {
$httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHTTPPort . ')');
$httpPort = ($httpPort) ? $httpPort : $defaultHTTPPort;
@@ -148,97 +157,438 @@ class Install extends Action
$httpsPort = ($httpsPort) ? $httpsPort : $defaultHTTPSPort;
}
- $input = [];
-
$password = new Password();
$token = new Token();
foreach ($vars as $var) {
- if (!empty($var['filter']) && ($interactive !== 'Y' || !Console::isInteractive())) {
- if ($data && $var['default'] !== null) {
- $input[$var['name']] = $var['default'];
- continue;
- }
-
- if ($var['filter'] === 'token') {
- $input[$var['name']] = $token->generate();
- continue;
- }
-
- if ($var['filter'] === 'password') {
- $input[$var['name']] = $password->generate();
- continue;
- }
- }
if (!$var['required'] || !Console::isInteractive() || $interactive !== 'Y') {
- $input[$var['name']] = $var['default'];
continue;
}
- $input[$var['name']] = Console::confirm($var['question'] . ' (default: \'' . $var['default'] . '\')');
+ $value = Console::confirm($var['question'] . ' (default: \'' . $var['default'] . '\')');
- if (empty($input[$var['name']])) {
- $input[$var['name']] = $var['default'];
+ if (!empty($value)) {
+ $userInput[$var['name']] = $value;
}
- if ($var['filter'] === 'domainTarget') {
- if ($input[$var['name']] !== 'localhost') {
- Console::warning("\nIf you haven't already done so, set the following record for {$input[$var['name']]} on your DNS provider:\n");
- $mask = "%-15.15s %-10.10s %-30.30s\n";
- printf($mask, "Type", "Name", "Value");
- printf($mask, "A or AAAA", "@", "");
- Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n");
- }
+ if ($var['filter'] === 'domainTarget' && !empty($value) && $value !== 'localhost') {
+ Console::warning("\nIf you haven't already done so, set the following record for {$value} on your DNS provider:\n");
+ $mask = "%-15.15s %-10.10s %-30.30s\n";
+ printf($mask, "Type", "Name", "Value");
+ printf($mask, "A or AAAA", "@", "");
+ Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n");
}
}
+ $input = $this->prepareEnvironmentVariables($userInput, $vars);
+ $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart);
+ }
+
+ private function detectHostPath(string $path): ?string
+ {
+ if (!is_file('/proc/self/mountinfo')) {
+ return null;
+ }
+
+ $bestMatch = null;
+ $bestLength = 0;
+
+ foreach (@file('/proc/self/mountinfo') as $line) {
+ $line = trim($line);
+ if ($line === '') {
+ continue;
+ }
+
+ $parts = explode(' - ', $line, 2);
+ if (count($parts) < 2) {
+ continue;
+ }
+
+ $left = preg_split('/\s+/', $parts[0]);
+ $right = preg_split('/\s+/', $parts[1]);
+
+ if (count($left) < 5 || count($right) < 2) {
+ continue;
+ }
+
+ $mountPoint = $left[4];
+ $root = $left[3];
+ $fsType = $right[0];
+ $source = $right[1];
+
+ if (!str_starts_with($path, $mountPoint)) {
+ continue;
+ }
+
+ $mountLength = strlen($mountPoint);
+ if ($mountLength < $bestLength) {
+ continue;
+ }
+
+ $relative = substr($path, $mountLength);
+ $relative = ltrim($relative, '/');
+
+ $hostBase = null;
+
+ if ($fsType === 'fakeowner' && str_starts_with($source, '/run/host_mark/')) {
+ $share = basename($source);
+ $hostBase = '/' . $share . rtrim($root, '/');
+ } elseif ($root !== '/' && $root !== '.') {
+ $hostBase = $root;
+ } elseif ($source !== 'none') {
+ $hostBase = $source;
+ }
+
+ if (!$hostBase || $hostBase === '.') {
+ continue;
+ }
+
+ if ($hostBase[0] !== '/') {
+ $hostBase = '/' . $hostBase;
+ }
+
+ $candidate = rtrim($hostBase, '/');
+ if ($relative !== '') {
+ $candidate .= '/' . $relative;
+ }
+
+ $candidate = preg_replace('#//+#', '/', $candidate);
+
+ $bestMatch = $candidate;
+ $bestLength = $mountLength;
+ }
+
+ return $bestMatch;
+ }
+
+ private function ensureHostPathLink(): void
+ {
+ if (empty($this->hostPath) || $this->hostPath === $this->path) {
+ return;
+ }
+
+ if (is_link($this->hostPath)) {
+ return;
+ }
+
+ if (is_dir($this->hostPath)) {
+ @rmdir($this->hostPath);
+ }
+
+ $parent = dirname($this->hostPath);
+ if (!is_dir($parent)) {
+ @mkdir($parent, 0755, true);
+ }
+
+ if (!file_exists($this->hostPath)) {
+ @symlink($this->path, $this->hostPath);
+ }
+ }
+
+ protected function startWebServer(string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void
+ {
+ $port = 8080;
+ $host = '0.0.0.0';
+ $url = "http://localhost:$port";
+
+ // Create a router script for handling requests
+ $routerScript = \sys_get_temp_dir() . '/appwrite-installer-router.php';
+ $this->createRouterScript($routerScript, $defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars, $isUpgrade, $lockedDatabase);
+
+ // Start PHP built-in server in background
+ $command = \sprintf(
+ 'php -S %s:%d %s >/dev/null 2>&1 &',
+ $host,
+ $port,
+ \escapeshellarg($routerScript)
+ );
+
+ // Start the server
+ \exec($command, $output, $exitCode);
+ \sleep(3);
+
+ // Check if the server actually started
+ $handle = @fsockopen('localhost', $port, $errno, $errstr, 1);
+ if ($handle === false) {
+ Console::exit(1);
+ }
+ \fclose($handle);
+
+ // Wait for the server process to finish
+ while (true) {
+ $handle = @fsockopen('localhost', $port, $errno, $errstr, 1);
+ if ($handle === false) {
+ // Server has stopped
+ break;
+ }
+ \fclose($handle);
+ \sleep(1);
+ }
+
+ // Cleanup
+ if (\file_exists($routerScript)) {
+ \unlink($routerScript);
+ }
+ }
+
+ private function createRouterScript(string $path, string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void
+ {
+ $installPhpPath = __FILE__;
+ $appViewsPath = __DIR__ . '/../../../../app/views/install';
+ $publicPath = __DIR__ . '/../../../../public';
+ $vendorPath = __DIR__ . '/../../../../vendor/autoload.php';
+ $appwritePath = __DIR__ . '/../../../../app/init.php';
+
+ $script = <<<'PHP'
+ 'text/css',
+ 'js' => 'application/javascript',
+ 'json' => 'application/json',
+ 'png' => 'image/png',
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'gif' => 'image/gif',
+ 'svg' => 'image/svg+xml',
+ 'ico' => 'image/x-icon',
+ 'woff' => 'font/woff',
+ 'woff2' => 'font/woff2',
+ 'ttf' => 'font/ttf',
+ 'eot' => 'application/vnd.ms-fontobject',
+ ];
+
+ $contentType = $mimeTypes[$extension] ?? 'application/octet-stream';
+ header('Content-Type: ' . $contentType);
+ header('Content-Length: ' . filesize($filePath));
+ readfile($filePath);
+ exit;
+ }
+}
+
+// Handle POST request (installation)
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && $uri === '/install') {
+ header('Content-Type: application/json');
+
+ $input = json_decode(file_get_contents('php://input'), true);
+
+ if (!$input) {
+ http_response_code(400);
+ echo json_encode(['success' => false, 'message' => 'Invalid request']);
+ exit;
+ }
+
+ try {
+ require_once INSTALL_PHP_PATH;
+ $installer = new \Appwrite\Platform\Tasks\Install();
+
+ // Prepare user inputs - use locked database if in upgrade mode
+ $userInput = [
+ '_APP_ENV' => 'production',
+ '_APP_OPENSSL_KEY_V1' => $input['opensslKey'] ?? '',
+ '_APP_DOMAIN' => $input['appDomain'] ?? 'localhost',
+ '_APP_DOMAIN_TARGET' => $input['appDomain'] ?? 'localhost',
+ '_APP_EMAIL_CERTIFICATES' => $input['emailCertificates'] ?? '',
+ '_APP_DB_ADAPTER' => LOCKED_DATABASE ?? ($input['database'] ?? 'mongodb'),
+ ];
+
+ // Use the prepareEnvironmentVariables method to merge with defaults
+ $vars = json_decode(VARS_JSON, true);
+ $envVars = $installer->prepareEnvironmentVariables($userInput, $vars);
+
+ // Call performInstallation method
+ $installer->performInstallation(
+ $input['httpPort'] ?? DEFAULT_HTTP_PORT,
+ $input['httpsPort'] ?? DEFAULT_HTTPS_PORT,
+ ORGANIZATION,
+ IMAGE,
+ $envVars,
+ NO_START
+ );
+
+ echo json_encode(['success' => true, 'message' => 'Installation completed successfully']);
+
+ // Stop the server after successful installation
+ register_shutdown_function(function() {
+ sleep(2); // Give time for response to be sent
+ posix_kill(posix_getpid(), SIGTERM);
+ });
+
+ } catch (\Throwable $e) {
+ http_response_code(500);
+ echo json_encode(['success' => false, 'message' => $e->getMessage()]);
+ }
+ exit;
+}
+
+// Serve installer UI
+if ($uri === '/' || $uri === '') {
+ header('Content-Type: text/html');
+
+ $vars = json_decode(VARS_JSON, true);
+ $defaultHttpPort = DEFAULT_HTTP_PORT;
+ $defaultHttpsPort = DEFAULT_HTTPS_PORT;
+ $isUpgrade = IS_UPGRADE;
+ $lockedDatabase = LOCKED_DATABASE;
+
+ include APP_VIEWS_PATH . '/installer.phtml';
+ exit;
+}
+
+// 404
+http_response_code(404);
+echo '404 Not Found';
+PHP;
+
+ $varsJson = json_encode($vars, JSON_UNESCAPED_SLASHES);
+ $script = sprintf(
+ $script,
+ $vendorPath,
+ $appwritePath,
+ $installPhpPath,
+ $appViewsPath,
+ $publicPath,
+ $defaultHTTPPort,
+ $defaultHTTPSPort,
+ $organization,
+ $image,
+ var_export($noStart, true),
+ str_replace("'", "\\'", $varsJson),
+ var_export($isUpgrade, true),
+ var_export($lockedDatabase, true)
+ );
+
+ file_put_contents($path, $script);
+ }
+
+ public function prepareEnvironmentVariables(array $userInput, array $vars): array
+ {
+ $input = [];
+ $password = new Password();
+ $token = new Token();
+
+ // Start with all defaults
+ foreach ($vars as $var) {
+ if (!empty($var['filter']) && $var['filter'] === 'token') {
+ $input[$var['name']] = $token->generate();
+ } elseif (!empty($var['filter']) && $var['filter'] === 'password') {
+ $input[$var['name']] = $password->generate();
+ } else {
+ $input[$var['name']] = $var['default'];
+ }
+ }
+
+ // Override with user inputs
+ foreach ($userInput as $key => $value) {
+ if ($value !== null && $value !== '') {
+ $input[$key] = $value;
+ }
+ }
+
+ // Set database-specific connection details
+ $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb';
+ if ($database === 'mongodb') {
+ $input['_APP_DB_HOST'] = 'mongodb';
+ $input['_APP_DB_PORT'] = 27017;
+ } elseif ($database === 'mariadb') {
+ $input['_APP_DB_HOST'] = 'mariadb';
+ $input['_APP_DB_PORT'] = 3306;
+ }
+
+ return $input;
+ }
+
+ public function performInstallation(
+ string $httpPort,
+ string $httpsPort,
+ string $organization,
+ string $image,
+ array $input,
+ bool $noStart
+ ): void {
+ // Check if we're running in CLI context
+ $isCLI = php_sapi_name() === 'cli';
+
$templateForCompose = new View(__DIR__ . '/../../../../app/views/install/compose.phtml');
$templateForEnv = new View(__DIR__ . '/../../../../app/views/install/env.phtml');
+ $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb';
+
$templateForCompose
->setParam('httpPort', $httpPort)
->setParam('httpsPort', $httpsPort)
- ->setParam('version', APP_VERSION_STABLE)
+ ->setParam('version', 'next')
->setParam('organization', $organization)
- ->setParam('image', $image);
+ ->setParam('image', $image)
+ ->setParam('database', $database)
+ ->setParam('hostPath', $this->hostPath);
$templateForEnv->setParam('vars', $input);
- if (!file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) {
- $message = 'Failed to save Docker Compose file';
- Console::error($message);
- Console::exit(1);
+ if (!\file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) {
+ throw new \Exception('Failed to save Docker Compose file');
}
- if (!file_put_contents($this->path . '/.env', $templateForEnv->render(false))) {
- $message = 'Failed to save environment variables file';
- Console::error($message);
- Console::exit(1);
+ if (!\file_put_contents($this->path . '/.env', $templateForEnv->render(false))) {
+ throw new \Exception('Failed to save environment variables file');
}
- $env = '';
- $stdout = '';
- $stderr = '';
+ // Copy MongoDB entrypoint script for replica set setup
+ if ($database === 'mongodb') {
+ $mongoEntrypoint = __DIR__ . '/../../../../mongo-entrypoint.sh';
- foreach ($input as $key => $value) {
- if ($value) {
- $env .= $key . '=' . \escapeshellarg($value) . ' ';
+ if (file_exists($mongoEntrypoint)) {
+ copy($mongoEntrypoint, $this->path . '/mongo-entrypoint.sh');
}
}
- $exit = 0;
if (!$noStart) {
- Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\"");
- $exit = Console::execute("$env docker compose --project-directory $this->path up -d --remove-orphans --renew-anon-volumes", '', $stdout, $stderr);
- }
+ $env = '';
+ foreach ($input as $key => $value) {
+ if ($value) {
+ $env .= $key . '=' . \escapeshellarg($value) . ' ';
+ }
+ }
- if ($exit !== 0) {
- $message = 'Failed to install Appwrite dockers';
- Console::error($message);
- Console::error($stderr);
- Console::exit($exit);
+ if ($isCLI) {
+ Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\"");
+ }
+
+ $command = "$env docker compose --project-directory $this->path up -d --remove-orphans --renew-anon-volumes 2>&1";
+ \exec($command, $output, $exit);
+
+ if ($exit !== 0) {
+ throw new \Exception('Failed to start Appwrite: ' . implode("\n", $output));
+ }
+
+ if ($isCLI) {
+ Console::success('Appwrite installed successfully');
+ }
} else {
- $message = 'Appwrite installed successfully';
- Console::success($message);
+ if ($isCLI) {
+ Console::success('Installation files created. Run "docker compose up -d" to start Appwrite');
+ }
}
}
}
diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php
index d6159d2718..09463a2605 100644
--- a/src/Appwrite/Platform/Tasks/Upgrade.php
+++ b/src/Appwrite/Platform/Tasks/Upgrade.php
@@ -8,6 +8,8 @@ use Utopia\Validator\Text;
class Upgrade extends Install
{
+ private ?string $lockedDatabase = null;
+
public static function getName(): string
{
return 'upgrade';
@@ -15,6 +17,8 @@ class Upgrade extends Install
public function __construct()
{
+ parent::__construct();
+
$this
->desc('Upgrade Appwrite')
->param('http-port', '', new Text(4), 'Server HTTP port', true)
@@ -39,6 +43,15 @@ class Upgrade extends Install
Console::log(' └── docker-compose.yml');
Console::exit(1);
}
- parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart);
+
+ $database = System::getEnv('_APP_DB_ADAPTER', 'mongodb');
+ $this->lockedDatabase = $database;
+
+ parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart, $database);
+ }
+
+ protected function startWebServer(string $defaultHTTPPort, string $defaultHTTPSPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void
+ {
+ parent::startWebServer($defaultHTTPPort, $defaultHTTPSPort, $organization, $image, $noStart, $vars, true, $this->lockedDatabase);
}
}
diff --git a/tests/playwright/installer-screenshots.spec.js b/tests/playwright/installer-screenshots.spec.js
new file mode 100644
index 0000000000..3b909a5701
--- /dev/null
+++ b/tests/playwright/installer-screenshots.spec.js
@@ -0,0 +1,52 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+
+test.describe('Appwrite Web Installer - Screenshots', () => {
+ test('capture screenshots of wizard steps', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('networkidle');
+ await page.waitForTimeout(500);
+
+ await expect(page.locator('h1')).toContainText('Appwrite Installation');
+ await page.screenshot({ path: 'tests/playwright/screenshots/step-1-configuration.png', fullPage: true });
+
+ await page.locator('#httpPort').fill('8080');
+ await page.locator('#httpsPort').fill('8443');
+ await page.locator('#primaryButton').click();
+ await page.waitForTimeout(300);
+
+ await expect(page.locator('.step[data-step="2"]')).toHaveClass(/active/);
+ await page.screenshot({ path: 'tests/playwright/screenshots/step-2-database.png', fullPage: true });
+
+ await page.locator('#primaryButton').click();
+ await page.waitForTimeout(300);
+ await page.screenshot({ path: 'tests/playwright/screenshots/step-3-domain.png', fullPage: true });
+
+ await page.locator('#appDomain').fill('appwrite.example.com');
+ await page.locator('#emailCertificates').fill('admin@example.com');
+ await page.locator('#primaryButton').click();
+ await page.waitForTimeout(300);
+ await page.screenshot({ path: 'tests/playwright/screenshots/step-4-security.png', fullPage: true });
+
+ await page.locator('button:has-text("Review")').click();
+ await page.waitForTimeout(300);
+ await page.screenshot({ path: 'tests/playwright/screenshots/step-5-review.png', fullPage: true });
+ await page.locator('#reviewContent').screenshot({ path: 'tests/playwright/screenshots/step-5-review-detail.png' });
+
+ await page.setViewportSize({ width: 375, height: 812 });
+ await page.waitForTimeout(300);
+ await page.screenshot({ path: 'tests/playwright/screenshots/mobile-view.png', fullPage: true });
+ });
+
+ test('capture validation states', async ({ page }) => {
+ await page.goto('/');
+ await page.waitForLoadState('networkidle');
+
+ await page.locator('#httpPort').fill('invalid');
+ await page.locator('#primaryButton').click();
+ await page.waitForTimeout(300);
+
+ await expect(page.locator('.alert.is-error')).toBeVisible();
+ await page.screenshot({ path: 'tests/playwright/screenshots/validation-error.png', fullPage: true });
+ });
+});
diff --git a/tests/playwright/installer.spec.js b/tests/playwright/installer.spec.js
new file mode 100644
index 0000000000..bb0cb3514f
--- /dev/null
+++ b/tests/playwright/installer.spec.js
@@ -0,0 +1,132 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+
+test.describe('Appwrite Web Installer', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/');
+ });
+
+ test('loads installer wizard shell', async ({ page }) => {
+ await expect(page).toHaveTitle(/Appwrite Installation/);
+ await expect(page.locator('h1')).toContainText('Appwrite Installation');
+ await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 1/i);
+ });
+
+ test('shows configuration fields by default', async ({ page }) => {
+ await expect(page.locator('.step[data-step="1"]')).toHaveClass(/active/);
+ await expect(page.locator('#httpPort')).toHaveValue('80');
+ await expect(page.locator('#httpsPort')).toHaveValue('443');
+ });
+
+ test('validates port numbers on step 1', async ({ page }) => {
+ await page.locator('#httpPort').fill('invalid');
+ await page.locator('#primaryButton').click();
+ await expect(page.locator('.alert.is-error')).toBeVisible();
+ await expect(page.locator('.step[data-step="1"]')).toHaveClass(/active/);
+ });
+
+ test('advances to database step with valid ports', async ({ page }) => {
+ await page.locator('#httpPort').fill('8080');
+ await page.locator('#httpsPort').fill('8443');
+ await page.locator('#primaryButton').click();
+
+ await expect(page.locator('.step[data-step="2"]')).toHaveClass(/active/);
+ await expect(page.locator('.database-option')).toHaveCount(2);
+ await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 2/i);
+ });
+
+ test('moves to domain step after database selection', async ({ page }) => {
+ await page.locator('#primaryButton').click();
+ await page.locator('#primaryButton').click();
+
+ await expect(page.locator('.step[data-step="3"]')).toHaveClass(/active/);
+ await expect(page.locator('#appDomain')).toBeVisible();
+ await expect(page.locator('#emailCertificates')).toBeVisible();
+ await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 3/i);
+ });
+
+ test('validates domain fields', async ({ page }) => {
+ await page.locator('#primaryButton').click();
+ await page.locator('#primaryButton').click();
+
+ await page.locator('#appDomain').fill('');
+ await page.locator('#primaryButton').click();
+ await expect(page.locator('.alert.is-error')).toContainText(/hostname/i);
+
+ await page.locator('#appDomain').fill('appwrite.example.com');
+ await page.locator('#emailCertificates').fill('invalid');
+ await page.locator('#primaryButton').click();
+ await expect(page.locator('.alert.is-error')).toContainText(/valid email/i);
+ });
+
+ test('moves to security step with valid domain data', async ({ page }) => {
+ await page.locator('#primaryButton').click();
+ await page.locator('#primaryButton').click();
+ await page.locator('#appDomain').fill('appwrite.example.com');
+ await page.locator('#emailCertificates').fill('admin@example.com');
+ await page.locator('#primaryButton').click();
+
+ await expect(page.locator('.step[data-step="4"]')).toHaveClass(/active/);
+ const keyValue = await page.locator('#opensslKey').inputValue();
+ expect(keyValue.length).toBeGreaterThan(32);
+ await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 4/i);
+ });
+
+ test('shows review step before installation', async ({ page }) => {
+ await page.locator('#primaryButton').click();
+ await page.locator('#primaryButton').click();
+ await page.locator('#appDomain').fill('appwrite.example.com');
+ await page.locator('#emailCertificates').fill('admin@example.com');
+ await page.locator('#primaryButton').click();
+ await page.locator('button:has-text("Review")').click();
+
+ await expect(page.locator('.step[data-step="5"]')).toHaveClass(/active/);
+ await expect(page.locator('#reviewContent')).toContainText('appwrite.example.com');
+ await expect(page.locator('#reviewContent')).toContainText('admin@example.com');
+ await expect(page.locator('#wizardStepLabel')).toHaveText(/Step 5/i);
+ });
+
+ test('allows navigating backwards', async ({ page }) => {
+ await page.locator('#primaryButton').click();
+ await expect(page.locator('.step[data-step="2"]')).toHaveClass(/active/);
+ await page.locator('#backButton').click();
+ await expect(page.locator('.step[data-step="1"]')).toHaveClass(/active/);
+ });
+
+ test('prepares primary action before installation', async ({ page }) => {
+ await page.locator('#httpPort').fill('8080');
+ await page.locator('#httpsPort').fill('8443');
+ await page.locator('#primaryButton').click();
+ await page.locator('#primaryButton').click();
+ await page.locator('#appDomain').fill('appwrite.local');
+ await page.locator('#emailCertificates').fill('admin@appwrite.local');
+ await page.locator('#primaryButton').click();
+ await page.locator('button:has-text("Review")').click();
+
+ await expect(page.locator('#reviewContent')).toContainText('8080');
+ await expect(page.locator('#reviewContent')).toContainText('appwrite.local');
+ await expect(page.locator('button:has-text("Install Appwrite")')).toBeVisible();
+ });
+
+ test('keeps installation log hidden until triggered', async ({ page }) => {
+ await page.locator('#primaryButton').click();
+ await page.locator('#primaryButton').click();
+ await page.locator('#appDomain').fill('appwrite.test');
+ await page.locator('#emailCertificates').fill('test@appwrite.test');
+ await page.locator('#primaryButton').click();
+ await page.locator('button:has-text("Review")').click();
+
+ await expect(page.locator('#installationLog')).toBeHidden();
+ });
+
+ test('loads Pink design tokens', async ({ page }) => {
+ const stylesheets = await page.evaluate(() => Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map(link => link.getAttribute('href')));
+ expect(stylesheets.some(href => href && href.includes('pink'))).toBe(true);
+ });
+});
+
+test.describe('Installer API', () => {
+ test.skip('API tests require the install task to be running', () => {
+ // Intentionally skipped pending API harness for installer.
+ });
+});