diff options
Diffstat (limited to 'testing/web-platform/tests/tools/third_party/websockets/example/tutorial')
15 files changed, 938 insertions, 0 deletions
diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/connect4.css b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/connect4.css new file mode 100644 index 0000000000..27f0baf6e4 --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/connect4.css @@ -0,0 +1,105 @@ +/* General layout */ + +body { + background-color: white; + display: flex; + flex-direction: column-reverse; + justify-content: center; + align-items: center; + margin: 0; + min-height: 100vh; +} + +/* Action buttons */ + +.actions { + display: flex; + flex-direction: row; + justify-content: space-evenly; + align-items: flex-end; + width: 720px; + height: 100px; +} + +.action { + color: darkgray; + font-family: "Helvetica Neue", sans-serif; + font-size: 20px; + line-height: 20px; + font-weight: 300; + text-align: center; + text-decoration: none; + text-transform: uppercase; + padding: 20px; + width: 120px; +} + +.action:hover { + background-color: darkgray; + color: white; + font-weight: 700; +} + +.action[href=""] { + display: none; +} + +/* Connect Four board */ + +.board { + background-color: blue; + display: flex; + flex-direction: row; + padding: 0 10px; + position: relative; +} + +.board::before, +.board::after { + background-color: blue; + content: ""; + height: 720px; + width: 20px; + position: absolute; +} + +.board::before { + left: -20px; +} + +.board::after { + right: -20px; +} + +.column { + display: flex; + flex-direction: column-reverse; + padding: 10px; +} + +.cell { + border-radius: 50%; + width: 80px; + height: 80px; + margin: 10px 0; +} + +.empty { + background-color: white; +} + +.column:hover .empty { + background-color: lightgray; +} + +.column:hover .empty ~ .empty { + background-color: white; +} + +.red { + background-color: red; +} + +.yellow { + background-color: yellow; +} diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/connect4.js b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/connect4.js new file mode 100644 index 0000000000..cb5eb9fa27 --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/connect4.js @@ -0,0 +1,45 @@ +const PLAYER1 = "red"; + +const PLAYER2 = "yellow"; + +function createBoard(board) { + // Inject stylesheet. + const linkElement = document.createElement("link"); + linkElement.href = import.meta.url.replace(".js", ".css"); + linkElement.rel = "stylesheet"; + document.head.append(linkElement); + // Generate board. + for (let column = 0; column < 7; column++) { + const columnElement = document.createElement("div"); + columnElement.className = "column"; + columnElement.dataset.column = column; + for (let row = 0; row < 6; row++) { + const cellElement = document.createElement("div"); + cellElement.className = "cell empty"; + cellElement.dataset.column = column; + columnElement.append(cellElement); + } + board.append(columnElement); + } +} + +function playMove(board, player, column, row) { + // Check values of arguments. + if (player !== PLAYER1 && player !== PLAYER2) { + throw new Error(`player must be ${PLAYER1} or ${PLAYER2}.`); + } + const columnElement = board.querySelectorAll(".column")[column]; + if (columnElement === undefined) { + throw new RangeError("column must be between 0 and 6."); + } + const cellElement = columnElement.querySelectorAll(".cell")[row]; + if (cellElement === undefined) { + throw new RangeError("row must be between 0 and 5."); + } + // Place checker in cell. + if (!cellElement.classList.replace("empty", player)) { + throw new Error("cell must be empty."); + } +} + +export { PLAYER1, PLAYER2, createBoard, playMove }; diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/connect4.py b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/connect4.py new file mode 100644 index 0000000000..0a61e7c7ee --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/connect4.py @@ -0,0 +1,62 @@ +__all__ = ["PLAYER1", "PLAYER2", "Connect4"] + +PLAYER1, PLAYER2 = "red", "yellow" + + +class Connect4: + """ + A Connect Four game. + + Play moves with :meth:`play`. + + Get past moves with :attr:`moves`. + + Check for a victory with :attr:`winner`. + + """ + + def __init__(self): + self.moves = [] + self.top = [0 for _ in range(7)] + self.winner = None + + @property + def last_player(self): + """ + Player who played the last move. + + """ + return PLAYER1 if len(self.moves) % 2 else PLAYER2 + + @property + def last_player_won(self): + """ + Whether the last move is winning. + + """ + b = sum(1 << (8 * column + row) for _, column, row in self.moves[::-2]) + return any(b & b >> v & b >> 2 * v & b >> 3 * v for v in [1, 7, 8, 9]) + + def play(self, player, column): + """ + Play a move in a column. + + Returns the row where the checker lands. + + Raises :exc:`RuntimeError` if the move is illegal. + + """ + if player == self.last_player: + raise RuntimeError("It isn't your turn.") + + row = self.top[column] + if row == 6: + raise RuntimeError("This slot is full.") + + self.moves.append((player, column, row)) + self.top[column] += 1 + + if self.winner is None and self.last_player_won: + self.winner = self.last_player + + return row diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/favicon.ico b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/favicon.ico new file mode 100644 index 0000000000..602c14e4eb --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/start/favicon.ico @@ -0,0 +1,2 @@ + h& ��( >��-=��v=��`@��B��U=���;���;���;���B��#J��VE���A���=���<���;���<���R��WN���I���E���E��%>���;���;���;���=��*\��VU���Q���M���N��$K��cE���A���=���D���`����q9 ^���]���Z���U��$R��dM���I���F���g��j�j1��i0��k1Cf��b���_��#[��eU���Q���N���R���t8w�k2��i0��k19d��J]���Y���U���R���|>s�v:��r7��k3�����k2u�j50f��da���]���^����Gk�~@��z>��w<Ҧs@�l2��q��zE�h��;p��ēN��I���C��Bѳ�@�x<��o5��M��o7�ȔOJ��M���J���F(�A��z=��v:��p5ɪw3ϘS%ŒP���L���JﳂC��~A��{>ɪwDʖS�ŒP���M���J���CŻ�D͗UBƔPyÏMY̙f����������������( @ <��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��U��U��U��U��U��U��U��U��U��B��<���<���<���;���<���<��M<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��<��A��3<���;���;���;���;���;���;���;���U��U��U��U��U��U��U��U��U��U��U��U��U��U��U��>��>��>��>��>��>��>��F��3A���?���>���;���;���;���;���;���;���<���<��<��<��<��<��<��<��<��<��<��<��<��<��<��=��=��=��=��=��=��J��4F���D���B���?���>���<���;���;���;���;���;���>��->��>��>��>��>��>��>��>��>��>��>��>��>��<��<��<��<��<��M��5J���G���E���C���B���@���=���<���;���;���;���;���=���=��=��=��=��=��=��=��=��=��=��=��=��=��U��U��U��U��R��5N���K���I���G���E���C���B���B��A��'<���;���;���;���;���;���<��Y<��<��<��<��<��<��<��<��<��<��<��<��<��<��U��6S���Q���N���M���K���I���H���H��H��B��U@���>���<���;���;���;���;���<���U��U��U��U��U��U��U��U��U��U���j2�j2Z��6V���T���R���P���O���M���L���L��L��H��JE���A���@���=���<���;���;���;���;���<���<��<��<��<��<��<��<��<��<���k2^��1Z���X���V���T���S���P���P���P��P��K��KH���F���C���B���@���=���<���@���a���}�k��zOŜj2$�j2�j2�j2�j2�j2�j2�j2�j2�i1_���^���\���Z���X���V���V���V��V��M��LL���I���G���F���C���B���@����m��i1��i0��i0��i0��k2u�k2�k2�k2�k2�k2�k2�k2�k2�k1b���_���]���[���Z���Z���Z��Z��T��LR���N���M���J���I���H���G��sG���r6/�j0��i0��i0��i0��i1��i1�i1�i1�i1�i1�i1�i1�i1�k1d���a���`���]���]���]��]��V��MU���R���Q���N���M���K���J��rJ��J���r7p�l2��j1��i0��i0��k1��k1�k1�k1�k1�k1�k1�k1�k1��U���f��sa���a��_a��a��\��NW���V���T���R���P���O���M��qM��M���t8e�q6��o4��l2��j1��i0��k1X�k1�k1�k1�k1�k1�k1�k1�k1��U��U��U��U��U��Ub��F^���\���Z���X���W���T���T��pT��T���{<f�w;��s8��q6��o5��k2��j1�q9 �q9�q9�j2W��U��U��U��U��U�i0�i0�i0�i0�i0�i0a���_���^���[���Z���X���W��pW��W���~@g�z>��x<��v:��s8��r7��p5��l2B�l2�l2�j1}�i0��j0���U��U��U��U�i0�i0�i0�i0�i0���d���a���`���^���[���\��o\��\����Eg��B��~@��z=��y<��v:��t9��r7S�r7�r7�k3~�i0��|J��i0��i0��i0�i0�i0�j1�j1�j1�j1�j1�j1g���c���a���_���_��n_��_����JS��E���C���B��|?��z=��x<��v;R�v;�v;�n4�l2���\�����è���i0�i0�i0�i0�k1�k1�k1�k1�k1�k1���i��ff���d��Bd��d��ĝN +��I���H���E���C��A��|?��~?Q�~?�~?�t8�q6��n4��t=�Ѽ���N��j1љj1�j1�j1�o5�o5�o5�o5�o5�o5�o5�o5�o5�o5�o5�o5M`��J���H���G���D���C���BQ��B��B�z>��t9��s8��q6��m3��l3��k1��k19�k1�k1�k1�s5�s5�s5�s5�s5�s5�s5�s5�s5�s5�s5�s5ÐO���M���J���H���G���G^��G��G�}?��z=��y<��u9��s8��o5��o4��o5>�o5�o5�o5�o5�u;�u;�u;�u;�u;�u;�u;�u;�u;�u;�u;�u;ƑO�ÐO���L���J���I���G2��G��C���B��|?��z=��v:��u9��t7��s5>�s5�s5�s5�s5�s5�}?�}?�}?�}?�}?�}?�}?�}?�}?�}?�}?�}?ʕTtőO�ÐN���M���I���I귆Gʵ�D���C��~@��|?��z=��x<�u;=�u;�u;�u;�u;�u;�u;��D��D��D��D��D��D��D��D��D��D��D��D̙W#ɖS�ŒP�ÐN���K���J���H���F���D���C��}@��|?�}?=�}?�}?�}?�}?�}?�}?�}?��I��I��I��I��I��I��I��I��I��I��I��I��I̘T�ǓQ�őO�ÏN���L���J���I���F���D���C�D<��D��D��D��D��D��D��D��DUUUUUUUUUUUUU��U̗T�ǔQ�ŒP�ÏN���K���J���H���F鶄I8��I��I��I��I��I��I��I��I��I˘WOȓQ�œP�ÑN���Lھ�L�UUUUUUUUUUU�����������������?��?����������0��`������@��������������0����0�� ��?����������������������� diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step1/app.py b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step1/app.py new file mode 100644 index 0000000000..3b0fbd7868 --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step1/app.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python + +import asyncio +import itertools +import json + +import websockets + +from connect4 import PLAYER1, PLAYER2, Connect4 + + +async def handler(websocket): + # Initialize a Connect Four game. + game = Connect4() + + # Players take alternate turns, using the same browser. + turns = itertools.cycle([PLAYER1, PLAYER2]) + player = next(turns) + + async for message in websocket: + # Parse a "play" event from the UI. + event = json.loads(message) + assert event["type"] == "play" + column = event["column"] + + try: + # Play the move. + row = game.play(player, column) + except RuntimeError as exc: + # Send an "error" event if the move was illegal. + event = { + "type": "error", + "message": str(exc), + } + await websocket.send(json.dumps(event)) + continue + + # Send a "play" event to update the UI. + event = { + "type": "play", + "player": player, + "column": column, + "row": row, + } + await websocket.send(json.dumps(event)) + + # If move is winning, send a "win" event. + if game.winner is not None: + event = { + "type": "win", + "player": game.winner, + } + await websocket.send(json.dumps(event)) + + # Alternate turns. + player = next(turns) + + +async def main(): + async with websockets.serve(handler, "", 8001): + await asyncio.Future() # run forever + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step1/index.html b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step1/index.html new file mode 100644 index 0000000000..8e38e89922 --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step1/index.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <title>Connect Four</title> + </head> + <body> + <div class="board"></div> + <script src="main.js" type="module"></script> + </body> +</html> diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step1/main.js b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step1/main.js new file mode 100644 index 0000000000..dd28f9a6a8 --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step1/main.js @@ -0,0 +1,53 @@ +import { createBoard, playMove } from "./connect4.js"; + +function showMessage(message) { + window.setTimeout(() => window.alert(message), 50); +} + +function receiveMoves(board, websocket) { + websocket.addEventListener("message", ({ data }) => { + const event = JSON.parse(data); + switch (event.type) { + case "play": + // Update the UI with the move. + playMove(board, event.player, event.column, event.row); + break; + case "win": + showMessage(`Player ${event.player} wins!`); + // No further messages are expected; close the WebSocket connection. + websocket.close(1000); + break; + case "error": + showMessage(event.message); + break; + default: + throw new Error(`Unsupported event type: ${event.type}.`); + } + }); +} + +function sendMoves(board, websocket) { + // When clicking a column, send a "play" event for a move in that column. + board.addEventListener("click", ({ target }) => { + const column = target.dataset.column; + // Ignore clicks outside a column. + if (column === undefined) { + return; + } + const event = { + type: "play", + column: parseInt(column, 10), + }; + websocket.send(JSON.stringify(event)); + }); +} + +window.addEventListener("DOMContentLoaded", () => { + // Initialize the UI. + const board = document.querySelector(".board"); + createBoard(board); + // Open the WebSocket connection and register event handlers. + const websocket = new WebSocket("ws://localhost:8001/"); + receiveMoves(board, websocket); + sendMoves(board, websocket); +}); diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step2/app.py b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step2/app.py new file mode 100644 index 0000000000..2693d4304d --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step2/app.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python + +import asyncio +import json +import secrets + +import websockets + +from connect4 import PLAYER1, PLAYER2, Connect4 + + +JOIN = {} + +WATCH = {} + + +async def error(websocket, message): + """ + Send an error message. + + """ + event = { + "type": "error", + "message": message, + } + await websocket.send(json.dumps(event)) + + +async def replay(websocket, game): + """ + Send previous moves. + + """ + # Make a copy to avoid an exception if game.moves changes while iteration + # is in progress. If a move is played while replay is running, moves will + # be sent out of order but each move will be sent once and eventually the + # UI will be consistent. + for player, column, row in game.moves.copy(): + event = { + "type": "play", + "player": player, + "column": column, + "row": row, + } + await websocket.send(json.dumps(event)) + + +async def play(websocket, game, player, connected): + """ + Receive and process moves from a player. + + """ + async for message in websocket: + # Parse a "play" event from the UI. + event = json.loads(message) + assert event["type"] == "play" + column = event["column"] + + try: + # Play the move. + row = game.play(player, column) + except RuntimeError as exc: + # Send an "error" event if the move was illegal. + await error(websocket, str(exc)) + continue + + # Send a "play" event to update the UI. + event = { + "type": "play", + "player": player, + "column": column, + "row": row, + } + websockets.broadcast(connected, json.dumps(event)) + + # If move is winning, send a "win" event. + if game.winner is not None: + event = { + "type": "win", + "player": game.winner, + } + websockets.broadcast(connected, json.dumps(event)) + + +async def start(websocket): + """ + Handle a connection from the first player: start a new game. + + """ + # Initialize a Connect Four game, the set of WebSocket connections + # receiving moves from this game, and secret access tokens. + game = Connect4() + connected = {websocket} + + join_key = secrets.token_urlsafe(12) + JOIN[join_key] = game, connected + + watch_key = secrets.token_urlsafe(12) + WATCH[watch_key] = game, connected + + try: + # Send the secret access tokens to the browser of the first player, + # where they'll be used for building "join" and "watch" links. + event = { + "type": "init", + "join": join_key, + "watch": watch_key, + } + await websocket.send(json.dumps(event)) + # Receive and process moves from the first player. + await play(websocket, game, PLAYER1, connected) + finally: + del JOIN[join_key] + del WATCH[watch_key] + + +async def join(websocket, join_key): + """ + Handle a connection from the second player: join an existing game. + + """ + # Find the Connect Four game. + try: + game, connected = JOIN[join_key] + except KeyError: + await error(websocket, "Game not found.") + return + + # Register to receive moves from this game. + connected.add(websocket) + try: + # Send the first move, in case the first player already played it. + await replay(websocket, game) + # Receive and process moves from the second player. + await play(websocket, game, PLAYER2, connected) + finally: + connected.remove(websocket) + + +async def watch(websocket, watch_key): + """ + Handle a connection from a spectator: watch an existing game. + + """ + # Find the Connect Four game. + try: + game, connected = WATCH[watch_key] + except KeyError: + await error(websocket, "Game not found.") + return + + # Register to receive moves from this game. + connected.add(websocket) + try: + # Send previous moves, in case the game already started. + await replay(websocket, game) + # Keep the connection open, but don't receive any messages. + await websocket.wait_closed() + finally: + connected.remove(websocket) + + +async def handler(websocket): + """ + Handle a connection and dispatch it according to who is connecting. + + """ + # Receive and parse the "init" event from the UI. + message = await websocket.recv() + event = json.loads(message) + assert event["type"] == "init" + + if "join" in event: + # Second player joins an existing game. + await join(websocket, event["join"]) + elif "watch" in event: + # Spectator watches an existing game. + await watch(websocket, event["watch"]) + else: + # First player starts a new game. + await start(websocket) + + +async def main(): + async with websockets.serve(handler, "", 8001): + await asyncio.Future() # run forever + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step2/index.html b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step2/index.html new file mode 100644 index 0000000000..1a16f72a25 --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step2/index.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <title>Connect Four</title> + </head> + <body> + <div class="actions"> + <a class="action new" href="/">New</a> + <a class="action join" href="">Join</a> + <a class="action watch" href="">Watch</a> + </div> + <div class="board"></div> + <script src="main.js" type="module"></script> + </body> +</html> diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step2/main.js b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step2/main.js new file mode 100644 index 0000000000..d38a0140ac --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step2/main.js @@ -0,0 +1,83 @@ +import { createBoard, playMove } from "./connect4.js"; + +function initGame(websocket) { + websocket.addEventListener("open", () => { + // Send an "init" event according to who is connecting. + const params = new URLSearchParams(window.location.search); + let event = { type: "init" }; + if (params.has("join")) { + // Second player joins an existing game. + event.join = params.get("join"); + } else if (params.has("watch")) { + // Spectator watches an existing game. + event.watch = params.get("watch"); + } else { + // First player starts a new game. + } + websocket.send(JSON.stringify(event)); + }); +} + +function showMessage(message) { + window.setTimeout(() => window.alert(message), 50); +} + +function receiveMoves(board, websocket) { + websocket.addEventListener("message", ({ data }) => { + const event = JSON.parse(data); + switch (event.type) { + case "init": + // Create links for inviting the second player and spectators. + document.querySelector(".join").href = "?join=" + event.join; + document.querySelector(".watch").href = "?watch=" + event.watch; + break; + case "play": + // Update the UI with the move. + playMove(board, event.player, event.column, event.row); + break; + case "win": + showMessage(`Player ${event.player} wins!`); + // No further messages are expected; close the WebSocket connection. + websocket.close(1000); + break; + case "error": + showMessage(event.message); + break; + default: + throw new Error(`Unsupported event type: ${event.type}.`); + } + }); +} + +function sendMoves(board, websocket) { + // Don't send moves for a spectator watching a game. + const params = new URLSearchParams(window.location.search); + if (params.has("watch")) { + return; + } + + // When clicking a column, send a "play" event for a move in that column. + board.addEventListener("click", ({ target }) => { + const column = target.dataset.column; + // Ignore clicks outside a column. + if (column === undefined) { + return; + } + const event = { + type: "play", + column: parseInt(column, 10), + }; + websocket.send(JSON.stringify(event)); + }); +} + +window.addEventListener("DOMContentLoaded", () => { + // Initialize the UI. + const board = document.querySelector(".board"); + createBoard(board); + // Open the WebSocket connection and register event handlers. + const websocket = new WebSocket("ws://localhost:8001/"); + initGame(websocket); + receiveMoves(board, websocket); + sendMoves(board, websocket); +}); diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/Procfile b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/Procfile new file mode 100644 index 0000000000..2e35818f67 --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/Procfile @@ -0,0 +1 @@ +web: python app.py diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/app.py b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/app.py new file mode 100644 index 0000000000..c2ee020d20 --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/app.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python + +import asyncio +import json +import os +import secrets +import signal + +import websockets + +from connect4 import PLAYER1, PLAYER2, Connect4 + + +JOIN = {} + +WATCH = {} + + +async def error(websocket, message): + """ + Send an error message. + + """ + event = { + "type": "error", + "message": message, + } + await websocket.send(json.dumps(event)) + + +async def replay(websocket, game): + """ + Send previous moves. + + """ + # Make a copy to avoid an exception if game.moves changes while iteration + # is in progress. If a move is played while replay is running, moves will + # be sent out of order but each move will be sent once and eventually the + # UI will be consistent. + for player, column, row in game.moves.copy(): + event = { + "type": "play", + "player": player, + "column": column, + "row": row, + } + await websocket.send(json.dumps(event)) + + +async def play(websocket, game, player, connected): + """ + Receive and process moves from a player. + + """ + async for message in websocket: + # Parse a "play" event from the UI. + event = json.loads(message) + assert event["type"] == "play" + column = event["column"] + + try: + # Play the move. + row = game.play(player, column) + except RuntimeError as exc: + # Send an "error" event if the move was illegal. + await error(websocket, str(exc)) + continue + + # Send a "play" event to update the UI. + event = { + "type": "play", + "player": player, + "column": column, + "row": row, + } + websockets.broadcast(connected, json.dumps(event)) + + # If move is winning, send a "win" event. + if game.winner is not None: + event = { + "type": "win", + "player": game.winner, + } + websockets.broadcast(connected, json.dumps(event)) + + +async def start(websocket): + """ + Handle a connection from the first player: start a new game. + + """ + # Initialize a Connect Four game, the set of WebSocket connections + # receiving moves from this game, and secret access tokens. + game = Connect4() + connected = {websocket} + + join_key = secrets.token_urlsafe(12) + JOIN[join_key] = game, connected + + watch_key = secrets.token_urlsafe(12) + WATCH[watch_key] = game, connected + + try: + # Send the secret access tokens to the browser of the first player, + # where they'll be used for building "join" and "watch" links. + event = { + "type": "init", + "join": join_key, + "watch": watch_key, + } + await websocket.send(json.dumps(event)) + # Receive and process moves from the first player. + await play(websocket, game, PLAYER1, connected) + finally: + del JOIN[join_key] + del WATCH[watch_key] + + +async def join(websocket, join_key): + """ + Handle a connection from the second player: join an existing game. + + """ + # Find the Connect Four game. + try: + game, connected = JOIN[join_key] + except KeyError: + await error(websocket, "Game not found.") + return + + # Register to receive moves from this game. + connected.add(websocket) + try: + # Send the first move, in case the first player already played it. + await replay(websocket, game) + # Receive and process moves from the second player. + await play(websocket, game, PLAYER2, connected) + finally: + connected.remove(websocket) + + +async def watch(websocket, watch_key): + """ + Handle a connection from a spectator: watch an existing game. + + """ + # Find the Connect Four game. + try: + game, connected = WATCH[watch_key] + except KeyError: + await error(websocket, "Game not found.") + return + + # Register to receive moves from this game. + connected.add(websocket) + try: + # Send previous moves, in case the game already started. + await replay(websocket, game) + # Keep the connection open, but don't receive any messages. + await websocket.wait_closed() + finally: + connected.remove(websocket) + + +async def handler(websocket): + """ + Handle a connection and dispatch it according to who is connecting. + + """ + # Receive and parse the "init" event from the UI. + message = await websocket.recv() + event = json.loads(message) + assert event["type"] == "init" + + if "join" in event: + # Second player joins an existing game. + await join(websocket, event["join"]) + elif "watch" in event: + # Spectator watches an existing game. + await watch(websocket, event["watch"]) + else: + # First player starts a new game. + await start(websocket) + + +async def main(): + # Set the stop condition when receiving SIGTERM. + loop = asyncio.get_running_loop() + stop = loop.create_future() + loop.add_signal_handler(signal.SIGTERM, stop.set_result, None) + + port = int(os.environ.get("PORT", "8001")) + async with websockets.serve(handler, "", port): + await stop + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/index.html b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/index.html new file mode 100644 index 0000000000..1a16f72a25 --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/index.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <title>Connect Four</title> + </head> + <body> + <div class="actions"> + <a class="action new" href="/">New</a> + <a class="action join" href="">Join</a> + <a class="action watch" href="">Watch</a> + </div> + <div class="board"></div> + <script src="main.js" type="module"></script> + </body> +</html> diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/main.js b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/main.js new file mode 100644 index 0000000000..3000fa2f78 --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/main.js @@ -0,0 +1,93 @@ +import { createBoard, playMove } from "./connect4.js"; + +function getWebSocketServer() { + if (window.location.host === "python-websockets.github.io") { + return "wss://websockets-tutorial.herokuapp.com/"; + } else if (window.location.host === "localhost:8000") { + return "ws://localhost:8001/"; + } else { + throw new Error(`Unsupported host: ${window.location.host}`); + } +} + +function initGame(websocket) { + websocket.addEventListener("open", () => { + // Send an "init" event according to who is connecting. + const params = new URLSearchParams(window.location.search); + let event = { type: "init" }; + if (params.has("join")) { + // Second player joins an existing game. + event.join = params.get("join"); + } else if (params.has("watch")) { + // Spectator watches an existing game. + event.watch = params.get("watch"); + } else { + // First player starts a new game. + } + websocket.send(JSON.stringify(event)); + }); +} + +function showMessage(message) { + window.setTimeout(() => window.alert(message), 50); +} + +function receiveMoves(board, websocket) { + websocket.addEventListener("message", ({ data }) => { + const event = JSON.parse(data); + switch (event.type) { + case "init": + // Create links for inviting the second player and spectators. + document.querySelector(".join").href = "?join=" + event.join; + document.querySelector(".watch").href = "?watch=" + event.watch; + break; + case "play": + // Update the UI with the move. + playMove(board, event.player, event.column, event.row); + break; + case "win": + showMessage(`Player ${event.player} wins!`); + // No further messages are expected; close the WebSocket connection. + websocket.close(1000); + break; + case "error": + showMessage(event.message); + break; + default: + throw new Error(`Unsupported event type: ${event.type}.`); + } + }); +} + +function sendMoves(board, websocket) { + // Don't send moves for a spectator watching a game. + const params = new URLSearchParams(window.location.search); + if (params.has("watch")) { + return; + } + + // When clicking a column, send a "play" event for a move in that column. + board.addEventListener("click", ({ target }) => { + const column = target.dataset.column; + // Ignore clicks outside a column. + if (column === undefined) { + return; + } + const event = { + type: "play", + column: parseInt(column, 10), + }; + websocket.send(JSON.stringify(event)); + }); +} + +window.addEventListener("DOMContentLoaded", () => { + // Initialize the UI. + const board = document.querySelector(".board"); + createBoard(board); + // Open the WebSocket connection and register event handlers. + const websocket = new WebSocket(getWebSocketServer()); + initGame(websocket); + receiveMoves(board, websocket); + sendMoves(board, websocket); +}); diff --git a/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/requirements.txt b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/requirements.txt new file mode 100644 index 0000000000..14774b465e --- /dev/null +++ b/testing/web-platform/tests/tools/third_party/websockets/example/tutorial/step3/requirements.txt @@ -0,0 +1 @@ +websockets |