diff --git a/.github/unit-tests-suites.yml b/.github/unit-tests-suites.yml index 01dfdf3373..099428b788 100644 --- a/.github/unit-tests-suites.yml +++ b/.github/unit-tests-suites.yml @@ -156,6 +156,14 @@ suites: paths: - 'environments/support-forums/**' + wordpress-tv: + type: wordpress + name: WordPress.tv Theme + script: wptv:test + paths: + - 'environments/wordpress-tv/**' + - 'wordpress.tv/public_html/wp-content/themes/wptv-27/**' + sso: type: wordpress name: WordPress.org SSO diff --git a/environments/README.md b/environments/README.md index 1bceb0b5a6..d4ef306670 100644 --- a/environments/README.md +++ b/environments/README.md @@ -267,6 +267,45 @@ All accounts use the password `password`. **Block editor:** the forums use the block editor through [Blocks Everywhere](https://github.com/Automattic/blocks-everywhere), which is not installed here — it needs a Gutenberg old enough to break against WordPress trunk until [Automattic/blocks-everywhere#211](https://github.com/Automattic/blocks-everywhere/pull/211) lands. `Plugin::__construct()` loads the block support only when that plugin is present, so the environment runs on bbPress' plain editor until then. +### WordPress.tv + +A local instance of [WordPress.tv](https://wordpress.tv/) with the `wptv-27` block theme and Jetpack, which provides the VideoPress player and the sharing buttons. + +**Start:** + +```bash +npm run wptv:env start +``` + +The first start imports about 60 recent, featured, Education and How-To videos from WordPress.tv. It brings their speakers, events, languages, categories and producers, plus the site's pages. Each video embeds its real VideoPress video, so the players stream and the thumbnails load. A pending submission is added for the moderation tools. + +**Access:** `http://localhost:8888`. Log in with `admin` / `password` to edit videos or moderate submissions. + +**Import new videos** (clears the import flag, then imports the videos that are missing): + +```bash +npm run wptv:refresh +``` + +**WP CLI:** + +```bash +npm run wptv:env -- run cli -- wp +``` + +**Run tests** (the theme's PHPUnit suite, in a dedicated test environment): + +```bash +npm run wptv:test +``` + +**What differs from production.** WordPress.tv runs on WordPress.com, which provides VideoPress internals that Jetpack doesn't have. Without them, the theme leaves out what it builds from them: + +- Download links, subtitle tracks, and the video data in JSON-LD and Open Graph tags are missing, and `/api/videos.json` lists no video files. +- `/oembed/` and `/unisubs/` answer with an error for every video. +- Jetpack runs in offline mode. `mocks/wptv-local.php` turns on the VideoPress and Sharing modules anyway, so the players and sharing buttons render. The Like button needs a connection, so it doesn't show, and the editor asks to connect Jetpack. +- Uploads from the submission form stay local instead of going to VideoPress, so every submission gets the State of the Word 2024 video. + ### Photo Directory A test-only environment for the Photo Directory plugin in `wordpress.org/public_html/wp-content/plugins/photo-directory/`. The plugin is mounted at `wp-content/plugins/photo-directory` but not activated: its test bootstrap loads only the classes under test, so the suite runs without Google Cloud credentials. diff --git a/environments/mocks/wptv-local.php b/environments/mocks/wptv-local.php new file mode 100644 index 0000000000..fa2957afc0 --- /dev/null +++ b/environments/mocks/wptv-local.php @@ -0,0 +1,59 @@ +&1 +$RUN composer require --dev yoast/phpunit-polyfills:^4.0 --working-dir=/wordpress-phpunit 2>&1 diff --git a/environments/wordpress-tv/bin/after-start.sh b/environments/wordpress-tv/bin/after-start.sh new file mode 100755 index 0000000000..3c9950c567 --- /dev/null +++ b/environments/wordpress-tv/bin/after-start.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# +# Runs after wp-env start. Sets up the site like WordPress.tv and imports its videos once. +# + +CONFIG="--config wordpress-tv/.wp-env.json" +WP="npx wp-env $CONFIG run cli --" + +$WP wp option update blogname 'WordPress.tv' +$WP wp option update blogdescription 'WordPress-related videos curated and moderated by the WordPress.org community' +$WP wp rewrite structure '/%year%/%monthnum%/%day%/%postname%/' --hard +# The theme registers the taxonomies the import assigns. +$WP wp theme activate wptv-27 || exit 1 + +# Anonymous submissions are filed under this account. +if ! $WP wp user get anonvideoupload --field=ID > /dev/null 2>&1; then + $WP wp user create anonvideoupload anonvideoupload@example.org --role=author --porcelain > /dev/null && echo "Created user: anonvideoupload" +fi + +$WP wp eval-file wp-content/env-bin/import-videos.php diff --git a/environments/wordpress-tv/bin/import-videos.php b/environments/wordpress-tv/bin/import-videos.php new file mode 100644 index 0000000000..32f20fba58 --- /dev/null +++ b/environments/wordpress-tv/bin/import-videos.php @@ -0,0 +1,379 @@ + 60 ) ); + $code = wp_remote_retrieve_response_code( $response ); + if ( is_wp_error( $response ) || 200 !== $code ) { + WP_CLI::warning( sprintf( 'Could not fetch %s (%s).', $url, is_wp_error( $response ) ? $response->get_error_message() : "HTTP $code" ) ); + return null; + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + return is_array( $data ) ? $data : null; +} + +/** + * Fetches the names of all production categories, keyed by slug. + * + * Videos only carry their own categories, so the parents' names come from here. + * + * @return string[] Category names keyed by slug. + */ +function fetch_category_names(): array { + $names = array(); + $page = 1; + do { + $data = fetch_json( SITE_API . 'categories.json?paged=' . $page ); + foreach ( $data['categories'] ?? array() as $category ) { + $slug = category_path( $category['link'] ); + if ( $slug ) { + $names[ end( $slug ) ] = $category['name']; + } + } + ++$page; + } while ( $data && $page <= (int) ( $data['query']['pages'] ?? 0 ) ); + + return $names; +} + +/** + * Splits a category archive link into its slug hierarchy. + * + * @param string $link Category link, like https://wordpress.tv/category/year/2024/. + * @return string[] Slugs from the top-level category down. + */ +function category_path( string $link ): array { + if ( ! preg_match( '#/category/(.+?)/?$#', (string) wp_parse_url( $link, PHP_URL_PATH ), $matches ) ) { + return array(); + } + + return explode( '/', $matches[1] ); +} + +/** + * Returns the ID of a category, creating it and its parents as needed. + * + * @param string $link Category link. + * @param string[] $names Category names keyed by slug. + * @return int Term ID, or 0 on failure. + */ +function ensure_category( string $link, array $names ): int { + $parent = 0; + foreach ( category_path( $link ) as $slug ) { + $term = get_term_by( 'slug', $slug, 'category' ); + if ( ! $term ) { + $term = wp_insert_term( + $names[ $slug ] ?? ucfirst( $slug ), + 'category', + array( + 'slug' => $slug, + 'parent' => $parent, + ) + ); + if ( is_wp_error( $term ) ) { + WP_CLI::warning( "Could not create category $slug: " . $term->get_error_message() ); + return 0; + } + $term = get_term( $term['term_id'], 'category' ); + } + $parent = (int) $term->term_id; + } + + return $parent; +} + +/** + * Returns the IDs of terms, creating the missing ones. + * + * @param array[] $items Terms as listed by the videos API, with `slug` and `name`. + * @param string $taxonomy Taxonomy. + * @return int[] Term IDs. + */ +function ensure_terms( array $items, string $taxonomy ): array { + $ids = array(); + foreach ( $items as $item ) { + $term = get_term_by( 'slug', $item['slug'], $taxonomy ); + if ( ! $term ) { + $term = wp_insert_term( $item['name'], $taxonomy, array( 'slug' => $item['slug'] ) ); + if ( is_wp_error( $term ) ) { + WP_CLI::warning( "Could not create $taxonomy {$item['slug']}: " . $term->get_error_message() ); + continue; + } + $term = get_term( $term['term_id'], $taxonomy ); + } + $ids[] = (int) $term->term_id; + } + + return $ids; +} + +/** + * Finds the VideoPress GUID of a video in its file or thumbnail URLs. + * + * @param array $video Video from the videos API. + * @return string GUID, or an empty string. + */ +function video_guid( array $video ): string { + $urls = array_merge( array( $video['thumbnail'] ?? '' ), (array) ( $video['video']['mp4'] ?? array() ) ); + foreach ( $urls as $url ) { + if ( is_string( $url ) && preg_match( '#^https://videos\.files\.wordpress\.com/([A-Za-z0-9]{8})/#', $url, $matches ) ) { + return $matches[1]; + } + } + + return ''; +} + +/** + * Creates a post for a video unless one with its slug exists, then tags it. + * + * @param array $video Video from the videos API. + * @param string[] $names Category names keyed by slug. + * @param string[] $tags Tag slugs to add, even to an existing post. + * @return bool Whether the post was created. + */ +function import_video( array $video, array $names, array $tags ): bool { + $guid = video_guid( $video ); + $slug = basename( (string) wp_parse_url( $video['permalink'], PHP_URL_PATH ) ); + if ( ! $guid || ! $slug ) { + return false; + } + + $existing = get_page_by_path( $slug, OBJECT, 'post' ); + if ( $existing ) { + wp_set_object_terms( $existing->ID, $tags, 'post_tag', true ); + return false; + } + + $categories = array(); + foreach ( array_merge( $video['category'], array_filter( array( $video['year'], $video['location'] ) ) ) as $category ) { + $categories[] = ensure_category( $category['link'], $names ); + } + + $post_id = wp_insert_post( + wp_slash( + array( + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_author' => 1, + 'post_name' => $slug, + 'post_title' => $video['title'], + // The API appends the slides link to the excerpt, and the theme appends it again from the meta. + 'post_excerpt' => preg_replace( '#

Presentation Slides »

$#', '', $video['description'] ), + 'post_content' => "[wpvideo $guid]", + 'post_date_gmt' => $video['date'], + 'post_date' => get_date_from_gmt( $video['date'] ), + 'post_category' => array_filter( $categories ), + ) + ), + true + ); + if ( is_wp_error( $post_id ) ) { + WP_CLI::warning( "Could not import $slug: " . $post_id->get_error_message() ); + return false; + } + + wp_set_object_terms( $post_id, ensure_terms( $video['speakers'], 'speakers' ), 'speakers' ); + wp_set_object_terms( $post_id, ensure_terms( $video['event'], 'event' ), 'event' ); + wp_set_object_terms( $post_id, ensure_terms( $video['language'], 'language' ), 'language' ); + wp_set_object_terms( $post_id, $tags, 'post_tag' ); + if ( ! empty( $video['producer']['username'] ) ) { + wp_set_object_terms( $post_id, array( $video['producer']['username'] ), 'producer-username' ); + } + if ( ! empty( $video['producer']['name'] ) ) { + wp_set_object_terms( $post_id, array( $video['producer']['name'] ), 'producer' ); + } + if ( $video['slides'] ) { + update_post_meta( $post_id, '_wptv_slides_url', esc_url_raw( $video['slides'] ) ); + } + + return true; +} + +/** + * Imports the published pages, keeping their slugs and rendered content. + * + * @return int|null Number of pages created, or null when they couldn't be fetched. + */ +function import_pages(): ?int { + $pages = fetch_json( PAGES_API . '?per_page=50&_fields=slug,title,content' ); + if ( null === $pages ) { + return null; + } + + $count = 0; + foreach ( $pages as $page ) { + if ( get_page_by_path( $page['slug'] ) ) { + continue; + } + $post_id = wp_insert_post( + wp_slash( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_author' => 1, + 'post_name' => $page['slug'], + 'post_title' => html_entity_decode( $page['title']['rendered'], ENT_QUOTES, 'UTF-8' ), + 'post_content' => $page['content']['rendered'], + ) + ) + ); + if ( $post_id ) { + ++$count; + } + } + + return $count; +} + +/** + * Adds a pending submission, as the submission form would, for the moderation tools. + * + * @return bool Whether the submission was added. + */ +function seed_submission(): bool { + $pending = get_posts( + array( + 'post_status' => 'pending', + 'meta_key' => '_wptv_submitted_video', + 'fields' => 'ids', + ) + ); + if ( $pending ) { + return false; + } + + $author = get_user_by( 'login', 'anonvideoupload' ); + $post_id = wp_insert_post( + array( + 'post_type' => 'post', + 'post_status' => 'pending', + 'post_author' => $author ? $author->ID : 1, + 'post_title' => 'Jane Doe: Building Block Themes', + 'post_content' => serialize_block( + array( + 'blockName' => 'videopress/video', + 'attrs' => array( 'guid' => SUBMISSION_GUID ), + 'innerBlocks' => array(), + 'innerHTML' => '', + 'innerContent' => array(), + ) + ), + ) + ); + if ( ! $post_id ) { + return false; + } + + update_post_meta( + $post_id, + '_wptv_submitted_video', + array( + 'attachment_id' => 0, + 'submitted_by' => 'Jane Doe', + 'submitted_email' => 'jane@example.org', + 'title' => 'Building Block Themes', + 'producer_username' => '', + 'speakers' => 'Jane Doe', + 'event' => 'WordCamp Example 2026', + 'language' => 'English', + 'categories' => '', + 'description' => "A walk through building a block theme.\n\nWith a second paragraph.", + 'slides' => 'https://example.org/slides/', + 'ip' => '127.0.0.1', + 'recorded' => '2026-05-01', + 'video_guid' => SUBMISSION_GUID, + ) + ); + + return true; +} + +/** + * Replaces the sample content with WordPress.tv's, unless that was done before. + * + * @return void + */ +function run(): void { + if ( get_option( 'wptv_env_imported' ) ) { + WP_CLI::log( 'Already imported, skipping. Run `npm run wptv:refresh` to import again.' ); + return; + } + + $samples = array( + 'hello-world' => 'post', + 'sample-page' => 'page', + 'privacy-policy' => 'page', + ); + foreach ( $samples as $slug => $post_type ) { + $sample = get_page_by_path( $slug, OBJECT, $post_type ); + if ( $sample ) { + wp_delete_post( $sample->ID, true ); + } + } + + $names = fetch_category_names(); + + // The API leaves every video's tags empty, so the featured query adds its tag. + $queries = array( + 'posts_per_page=40' => array(), + 'tag=featured&posts_per_page=8' => array( 'featured' ), + 'category_name=education&posts_per_page=6' => array(), + 'category_name=how-to&posts_per_page=6' => array(), + ); + + $fetched = 0; + $imported = 0; + foreach ( $queries as $query => $tags ) { + $data = fetch_json( SITE_API . 'videos.json?' . $query ); + foreach ( $data['videos'] ?? array() as $video ) { + ++$fetched; + if ( import_video( $video, $names, $tags ) ) { + ++$imported; + } + } + } + + if ( ! $fetched ) { + WP_CLI::warning( 'No videos were fetched; wordpress.tv may be unreachable. Run `npm run wptv:refresh` to try again.' ); + return; + } + + $pages = import_pages(); + seed_submission(); + if ( null === $pages ) { + WP_CLI::warning( 'No pages were fetched. Run `npm run wptv:refresh` to try again.' ); + return; + } + update_option( 'wptv_env_imported', time() ); + + WP_CLI::success( sprintf( 'Imported %d new videos and %d new pages.', $imported, $pages ) ); +} + +run(); diff --git a/wordpress.tv/public_html/wp-content/themes/wptv-27/assets/js/editor.js b/wordpress.tv/public_html/wp-content/themes/wptv-27/assets/js/editor.js index bfba9dc334..db0ffadccf 100644 --- a/wordpress.tv/public_html/wp-content/themes/wptv-27/assets/js/editor.js +++ b/wordpress.tv/public_html/wp-content/themes/wptv-27/assets/js/editor.js @@ -220,7 +220,7 @@ return el('table', { className: 'wptv-review', 'aria-label': __('Submitted video', 'wptv') }, el('tbody', null, row(__('Submitted by', 'wptv'), el('div', { className: 'wptv-review__links' }, el('span', null, submission.submitted_by), - el('a', { href: `mailto:${submission.submitted_email}?Subject=Your%20WordPress.tv%20submission` }, submission.submitted_email), + el('a', { href: `mailto:${encodeURIComponent(submission.submitted_email)}?Subject=Your%20WordPress.tv%20submission` }, submission.submitted_email), el(components.ExternalLink, { href: `http://en.utrace.de/?query=${encodeURIComponent(submission.ip)}` }, submission.ip) )), field('title', __('Title', 'wptv'), () => { editPost({ title: values.title }); done('title'); }), diff --git a/wordpress.tv/public_html/wp-content/themes/wptv-27/inc/content.php b/wordpress.tv/public_html/wp-content/themes/wptv-27/inc/content.php index 42c78ec5f9..684cac3016 100644 --- a/wordpress.tv/public_html/wp-content/themes/wptv-27/inc/content.php +++ b/wordpress.tv/public_html/wp-content/themes/wptv-27/inc/content.php @@ -49,11 +49,13 @@ function document_title_parts( $title ) { /** * Appends the presentation slides link to the excerpt. * - * @param string $excerpt Post excerpt. + * @param string $excerpt Post excerpt. + * @param \WP_Post|null $post Post the excerpt belongs to. Defaults to the current post. * @return string */ -function excerpt_slides( $excerpt ) { - $slides = get_post_meta( get_the_ID(), '_wptv_slides_url', true ); +function excerpt_slides( $excerpt, $post = null ) { + $post = get_post( $post ); + $slides = $post ? get_post_meta( $post->ID, '_wptv_slides_url', true ) : ''; if ( ! empty( $slides ) ) { $excerpt .= '

Presentation Slides »

'; @@ -61,7 +63,7 @@ function excerpt_slides( $excerpt ) { return $excerpt; } -add_filter( 'get_the_excerpt', __NAMESPACE__ . '\excerpt_slides' ); +add_filter( 'get_the_excerpt', __NAMESPACE__ . '\excerpt_slides', 10, 2 ); /* * Media RSS (WordPress.com only; the filters do nothing elsewhere). diff --git a/wordpress.tv/public_html/wp-content/themes/wptv-27/phpunit.xml.dist b/wordpress.tv/public_html/wp-content/themes/wptv-27/phpunit.xml.dist new file mode 100644 index 0000000000..c41d5bd5e4 --- /dev/null +++ b/wordpress.tv/public_html/wp-content/themes/wptv-27/phpunit.xml.dist @@ -0,0 +1,11 @@ + + + + tests/ + + + diff --git a/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/Lockdown_Test.php b/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/Lockdown_Test.php new file mode 100644 index 0000000000..fbc9b9494b --- /dev/null +++ b/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/Lockdown_Test.php @@ -0,0 +1,79 @@ +ID : wp_insert_user( + array( + 'user_login' => 'wptv-admin', + 'user_pass' => wp_generate_password(), + 'role' => 'administrator', + ) + ); + wp_set_current_user( $id ); + } + + /** + * Logs the administrator out. + * + * @return void + */ + protected function tearDown(): void { + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * REST requests, with the status each gets. + * + * @return array[] + */ + public static function data_requests(): array { + return array( + 'create a template' => array( 'POST', '/wp/v2/templates', 403 ), + 'update a template part' => array( 'PUT', '/wp/v2/template-parts/wptv-27//header', 403 ), + 'update global styles' => array( 'POST', '/wp/v2/global-styles/1', 403 ), + 'list templates' => array( 'GET', '/wp/v2/templates', 200 ), + ); + } + + /** + * Writes to templates, template parts and global styles are rejected; reads are not. + * + * @param string $method Request method. + * @param string $route REST route. + * @param int $status Expected response status. + * @return void + */ + #[DataProvider( 'data_requests' )] + public function test_design_writes_are_rejected( string $method, string $route, int $status ): void { + $response = rest_do_request( new WP_REST_Request( $method, $route ) ); + + $this->assertSame( $status, $response->get_status() ); + } +} diff --git a/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/Routes_Test.php b/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/Routes_Test.php new file mode 100644 index 0000000000..f74b5cdf40 --- /dev/null +++ b/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/Routes_Test.php @@ -0,0 +1,83 @@ +request_uri = $_SERVER['REQUEST_URI'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Only restored. + flush_rewrite_rules( false ); + } + + /** + * Restores the request URI. + * + * @return void + */ + protected function tearDown(): void { + $_SERVER['REQUEST_URI'] = $this->request_uri; + + parent::tearDown(); + } + + /** + * Legacy URLs, with the query variable and value each resolves to. + * + * @return array[] + */ + public static function data_routes(): array { + return array( + 'API' => array( '/api/videos.json', 'wptvapi', 'videos.json' ), + 'oEmbed' => array( '/oembed/', 'oembed', '1' ), + 'Amara subtitles' => array( '/unisubs/', 'unisubs', '1' ), + 'event archive' => array( '/event/wordcamp-europe-2024/', 'event', 'wordcamp-europe-2024' ), + 'producer username' => array( '/producer-username/wordpressdotorg/', 'producer-username', 'wordpressdotorg' ), + ); + } + + /** + * A legacy URL resolves to the theme's query variable. + * + * @param string $path Request path. + * @param string $query_var Query variable. + * @param string $value Expected value. + * @return void + */ + #[DataProvider( 'data_routes' )] + public function test_legacy_url_resolves( string $path, string $query_var, string $value ): void { + $_SERVER['REQUEST_URI'] = $path; + + // A new WP instance lacks the taxonomies' query variables. + $wp = clone $GLOBALS['wp']; + $wp->parse_request(); + + $this->assertSame( $value, $wp->query_vars[ $query_var ] ?? null ); + } +} diff --git a/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/Submissions_Test.php b/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/Submissions_Test.php new file mode 100644 index 0000000000..45dc9cea5c --- /dev/null +++ b/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/Submissions_Test.php @@ -0,0 +1,68 @@ + '', + 'wptv_uploaded_by' => 'Jane Doe', + 'wptv_email' => 'jane@example.org', + 'wptv_video_title' => 'Building Block Themes', + ); + + return array( + 'valid' => array( $valid, false ), + 'filled honeypot' => array( array_merge( $valid, array( 'wptv_honey' => 'https://spam.example' ) ), 13 ), + 'title over 100 letters' => array( array_merge( $valid, array( 'wptv_video_title' => str_repeat( 'é', 101 ) ) ), 6 ), + 'no name' => array( array_merge( $valid, array( 'wptv_uploaded_by' => '' ) ), 10 ), + 'invalid email' => array( array_merge( $valid, array( 'wptv_email' => 'jane' ) ), 12 ), + ); + } + + /** + * An anonymous submission is rejected with the code the form shows. + * + * @param array $post Submitted fields. + * @param int|false $expected Error code, or false when the submission is valid. + * @return void + */ + #[DataProvider( 'data_submissions' )] + public function test_anonymous_submission_is_validated( array $post, int|false $expected ): void { + $_POST = wp_slash( $post ); + + $this->assertSame( $expected, validate() ); + } +} diff --git a/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/bootstrap.php b/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/bootstrap.php new file mode 100644 index 0000000000..773c590e81 --- /dev/null +++ b/wordpress.tv/public_html/wp-content/themes/wptv-27/tests/bootstrap.php @@ -0,0 +1,43 @@ + 'wptv-27', + 'stylesheet' => 'wptv-27', + 'permalink_structure' => '/%year%/%monthnum%/%day%/%postname%/', +); + +require $_tests_dir . '/includes/bootstrap.php';