Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions .github/workflows/wp-origin-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
name: WP Origin E2E

on:
pull_request:
push:
branches: [trunk]

jobs:
e2e:
name: WP Origin E2E
runs-on: ubuntu-latest
timeout-minutes: 20

services:
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wordpress
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -uroot -proot"
--health-interval=10s
--health-timeout=5s
--health-retries=10

env:
WP_VERSION: '6.9.4'
WP_DIR: ${{ github.workspace }}/.ci/wordpress
WP_URL: http://127.0.0.1:8080
WP_ADMIN_USER: admin
WP_ADMIN_PASS: password

steps:
- uses: actions/checkout@v4

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: zip, sqlite3, pdo, pdo_sqlite, mysqli, mbstring, curl
coverage: none
tools: phpunit-polyfills

- name: Install Composer dependencies
run: |
rm composer.lock
cp composer-ci-matrix-tests.json composer.json
composer install --no-interaction --no-progress --optimize-autoloader

- name: Install wp-cli
run: |
curl -sSLO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp

- name: Wait for MySQL
run: |
for _ in $(seq 1 30); do
if mysqladmin ping -h 127.0.0.1 -P 3306 -uroot -proot --silent; then exit 0; fi
sleep 2
done
echo "MySQL never came up." >&2
exit 1

- name: Download and configure WordPress
run: |
mkdir -p "$WP_DIR"
wp --path="$WP_DIR" core download --version="$WP_VERSION"
wp --path="$WP_DIR" config create \
--dbname=wordpress \
--dbuser=root \
--dbpass=root \
--dbhost=127.0.0.1:3306 \
--extra-php <<'PHP'
define( 'WP_ENVIRONMENT_TYPE', 'local' );
define( 'WP_DEBUG', true );
PHP
wp --path="$WP_DIR" core install \
--url="$WP_URL" \
--title="WP Origin E2E" \
--admin_user="$WP_ADMIN_USER" \
--admin_email="admin@example.com" \
--admin_password="$WP_ADMIN_PASS" \
--skip-email
wp --path="$WP_DIR" option update permalink_structure '/%postname%/'
wp --path="$WP_DIR" rewrite flush --hard

- name: Mount toolkit components and activate plugin
run: |
rm -rf "$WP_DIR/wp-content/plugins/wp-origin"
ln -s "$GITHUB_WORKSPACE/plugins/wp-origin" "$WP_DIR/wp-content/plugins/wp-origin"
ln -s "$GITHUB_WORKSPACE/components" "$WP_DIR/wp-content/components"
ln -s "$GITHUB_WORKSPACE/vendor" "$WP_DIR/wp-content/vendor"
wp --path="$WP_DIR" plugin activate wp-origin

- name: Seed Hello World post and Sample Page
run: |
wp --path="$WP_DIR" post update 1 \
--post_title='Hello World' \
--post_content='<!-- wp:paragraph --><p>Hello from WordPress</p><!-- /wp:paragraph -->' \
--post_status=publish
wp --path="$WP_DIR" post update 2 \
--post_title='Sample Page' \
--post_content='<!-- wp:paragraph --><p>Page from WordPress</p><!-- /wp:paragraph -->' \
--post_status=publish

- name: Create Application Password
id: app_password
run: |
PASSWORD=$(wp --path="$WP_DIR" user application-password create "$WP_ADMIN_USER" "WP Origin E2E" --porcelain)
PASSWORD=${PASSWORD// /}
echo "::add-mask::$PASSWORD"
echo "password=$PASSWORD" >> "$GITHUB_OUTPUT"

- name: Start PHP built-in server
run: |
cd "$WP_DIR"
nohup php -S 127.0.0.1:8080 -t . "$GITHUB_WORKSPACE/plugins/wp-origin/Tests/php-server-router.php" > /tmp/php-server.log 2>&1 &
echo $! > /tmp/server.pid
for _ in $(seq 1 30); do
if curl -fsS "$WP_URL/wp-json/" > /dev/null 2>&1; then exit 0; fi
sleep 1
done
echo "Built-in PHP server never became ready." >&2
cat /tmp/php-server.log
exit 1

- name: Run E2E tests
env:
WP_ORIGIN_E2E_BASE_URL: ${{ env.WP_URL }}
WP_ORIGIN_E2E_USERNAME: ${{ env.WP_ADMIN_USER }}
WP_ORIGIN_E2E_PASSWORD: ${{ steps.app_password.outputs.password }}
run: vendor/bin/phpunit --testsuite "WP Origin E2E"

- name: Print PHP server log on failure
if: failure()
run: |
echo "----- /tmp/php-server.log -----"
cat /tmp/php-server.log || true

- name: Stop PHP built-in server
if: always()
run: |
if [ -f /tmp/server.pid ]; then
kill "$(cat /tmp/server.pid)" 2>/dev/null || true
fi
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ components/DataLiberation/Tests/test-output-md
blueprint-dev.json
examples/create-wp-site/data-liberation.zip
untracked
.claude/
184 changes: 184 additions & 0 deletions components/Filesystem/Tests/FakeWpdb.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<?php

/**
* Minimal SQLite-backed wpdb shim for unit-testing WpdbFilesystem.
*
* Only implements the wpdb surface that WpdbFilesystem touches: prepare,
* query, get_var, get_col, insert, replace, update, delete. Translates
* the few MySQL-isms WpdbFilesystem uses (LONGBLOB, VARCHAR(N),
* START TRANSACTION, INSERT IGNORE) into their SQLite equivalents so
* the schema and transaction semantics match.
*
* Binary data is bound with SQLITE3_BLOB so Git objects round-trip
* byte-for-byte through put_contents/get_contents.
*/
class FakeWpdb {

public $prefix = 'wp_';
public $last_error = '';
public $last_query = '';

private $db;

public function __construct() {
$this->db = new SQLite3( ':memory:' );
$this->db->enableExceptions( true );
}

public function get_charset_collate() {
return '';
}

public function query( $sql ) {
$this->last_query = $sql;
$translated = $this->translate_sql( $sql );

try {
return $this->db->exec( $translated ) ? 1 : 0;
} catch ( Exception $e ) {
$this->last_error = $e->getMessage();

return false;
}
}

public function prepare( $sql, ...$args ) {
if ( count( $args ) === 1 && is_array( $args[0] ) ) {
$args = $args[0];
}

// WpdbFilesystem only ever uses %s placeholders.
$parts = explode( '%s', $sql );
$result = $parts[0];
$count = count( $args );
for ( $i = 0; $i < $count; $i++ ) {
$result .= "'" . SQLite3::escapeString( (string) $args[ $i ] ) . "'";
if ( isset( $parts[ $i + 1 ] ) ) {
$result .= $parts[ $i + 1 ];
}
}

return $result;
}

public function get_var( $sql ) {
$this->last_query = $sql;
$result = $this->db->query( $this->translate_sql( $sql ) );
if ( ! $result ) {
return null;
}
$row = $result->fetchArray( SQLITE3_NUM );

return false === $row ? null : $row[0];
}

public function get_col( $sql ) {
$this->last_query = $sql;
$result = $this->db->query( $this->translate_sql( $sql ) );
$column = array();
if ( ! $result ) {
return $column;
}
while ( $row = $result->fetchArray( SQLITE3_NUM ) ) {
$column[] = $row[0];
}

return $column;
}

public function insert( $table, $data, $format = null ) {
return $this->run_prepared(
'INSERT INTO ' . $table,
$data
);
}

public function replace( $table, $data, $format = null ) {
return $this->run_prepared(
'INSERT OR REPLACE INTO ' . $table,
$data
);
}

public function update( $table, $data, $where, $format = null, $where_format = null ) {
$set_columns = array();
$where_columns = array();
foreach ( array_keys( $data ) as $col ) {
$set_columns[] = "$col = ?";
}
foreach ( array_keys( $where ) as $col ) {
$where_columns[] = "$col = ?";
}
$sql = 'UPDATE ' . $table . ' SET ' . implode( ', ', $set_columns )
. ' WHERE ' . implode( ' AND ', $where_columns );
$stmt = $this->db->prepare( $sql );
$i = 1;
foreach ( $data as $value ) {
$this->bind_value( $stmt, $i++, $value );
}
foreach ( $where as $value ) {
$this->bind_value( $stmt, $i++, $value );
}

return false === $stmt->execute() ? false : 1;
}

public function delete( $table, $where, $where_format = null ) {
$where_columns = array();
foreach ( array_keys( $where ) as $col ) {
$where_columns[] = "$col = ?";
}
$sql = 'DELETE FROM ' . $table . ' WHERE ' . implode( ' AND ', $where_columns );
$stmt = $this->db->prepare( $sql );
$i = 1;
foreach ( $where as $value ) {
$this->bind_value( $stmt, $i++, $value );
}

return false === $stmt->execute() ? false : 1;
}

private function run_prepared( $verb_with_table, $data ) {
$columns = array_keys( $data );
$placeholders = array_fill( 0, count( $data ), '?' );
$sql = $verb_with_table
. ' (' . implode( ', ', $columns ) . ')'
. ' VALUES (' . implode( ', ', $placeholders ) . ')';
$stmt = $this->db->prepare( $sql );
$i = 1;
foreach ( $data as $value ) {
$this->bind_value( $stmt, $i++, $value );
}

return false === $stmt->execute() ? false : 1;
}

private function bind_value( SQLite3Stmt $stmt, $position, $value ) {
if ( null === $value ) {
$stmt->bindValue( $position, null, SQLITE3_NULL );

return;
}
if ( is_int( $value ) ) {
$stmt->bindValue( $position, $value, SQLITE3_INTEGER );

return;
}
// PHP's SQLite3 driver truncates SQLITE3_TEXT bindings at the
// first NUL, so values containing NULs must bind as BLOB to
// round-trip byte-for-byte. Path/name columns never contain
// NULs and benefit from TEXT binding so SQLite's type-affinity
// comparisons (`WHERE path = '/'`) match TEXT-affinity columns.
$type = ( false === strpos( $value, "\0" ) ) ? SQLITE3_TEXT : SQLITE3_BLOB;
$stmt->bindValue( $position, $value, $type );
}

private function translate_sql( $sql ) {
$sql = preg_replace( '/\bLONGBLOB\b/i', 'BLOB', $sql );
$sql = preg_replace( '/\bVARCHAR\(\d+\)/i', 'TEXT', $sql );
$sql = str_ireplace( 'START TRANSACTION', 'BEGIN', $sql );
$sql = str_ireplace( 'INSERT IGNORE', 'INSERT OR IGNORE', $sql );

return $sql;
}
}
Loading
Loading