cm-chessboard 8.12.16 → 8.12.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -207,6 +207,13 @@ The event has the following **`event.type`**:
207
207
  Return `true` or `false` to validate the start square. `false` cancels the move.
208
208
  - **`INPUT_EVENT_TYPE.validateMoveInput`**: To validate the users move input. `event.squareFrom` and `event.squareTo`
209
209
  contain the coordinates. Return `true` or `false` to validate the move. `false` cancels the move.
210
+ The validator runs once at most per sequence of actions that describe a move (e.g. click starting piece, click destination),
211
+ so side effects like updating game position state or triggering sounds can run here.
212
+ One special case is moving a piece to a square occupied by another friendly piece. This kind of move can be useful
213
+ when you want to allow chess960 castling style (moving the king into the rook position) or when you want to allow
214
+ recapture premoves. Return `true` to commit it (e.g. castling by clicking the rook). When you return `false` here the
215
+ clicked own piece simply becomes the new selection, so if you show illegal-move feedback, skip it when `event.squareTo`
216
+ still holds one of the mover's own pieces.
210
217
  - **`INPUT_EVENT_TYPE.moveInputCanceled`**: The user canceled the move with clicking again on the start square, clicking
211
218
  outside the board or right click.
212
219
  - **`INPUT_EVENT_TYPE.moveInputFinished`**: Fired after the move was made, also when canceled.
@@ -416,6 +423,17 @@ const chessboard = new Chessboard(document.getElementById("board"), {
416
423
 
417
424
  When `movePieceForm` is enabled, press **Shift+Option+E** (Mac) or **Shift+Alt+E** (Windows/Linux) to focus the "Move from" input field.
418
425
 
426
+ ## Testing
427
+
428
+ The unit tests use the tiny [Teevi](https://github.com/shaack/teevi) framework and run in a real browser.
429
+
430
+ - **In the browser (no install needed):** open [`test/index.html`](test/index.html) directly, or run `npm test`, which
431
+ just prints that hint.
432
+ - **Headless (optional):** `npm run test:headless` runs the same suite in headless Chrome and exits non-zero on failure,
433
+ which is handy for CI. To keep cm-chessboard dependency-free, [puppeteer](https://pptr.dev) is **not** a project
434
+ dependency; install it **globally** for this: `npm install -g puppeteer`. The runner starts its own static server, so
435
+ nothing else is required.
436
+
419
437
  ## Usage with JS Frameworks
420
438
 
421
439
  - Works with **Vue** out of the box
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cm-chessboard",
3
- "version": "8.12.16",
3
+ "version": "8.12.18",
4
4
  "description": "A JavaScript chessboard which is lightweight, ES6 module based, responsive, SVG rendered and without dependencies.",
5
5
  "keywords": [
6
6
  "chess",
@@ -16,7 +16,8 @@
16
16
  "module": "./src/Chessboard.js",
17
17
  "browser": "./src/Chessboard.js",
18
18
  "scripts": {
19
- "test": "tput setaf 4;echo 'Run test/index.html in your browser for unit testing.'; echo ''; exit 0"
19
+ "test": "tput setaf 4;echo 'Run test/index.html in your browser for unit testing, or `npm run test:headless` (needs a global puppeteer).'; echo ''; exit 0",
20
+ "test:headless": "node test/headless.mjs"
20
21
  },
21
22
  "repository": {
22
23
  "type": "git",
@@ -171,7 +171,10 @@ export class VisualMoveInput {
171
171
  throw new Error("moveInputState")
172
172
  }
173
173
  this.toSquare = params.square
174
- if (this.toSquare && this.validateMoveInputCallback(this.fromSquare, this.toSquare)) {
174
+ // if the move was already validated, don't trigger the validator again so possible user side effects run once at most
175
+ const validated = params.validated !== undefined ?
176
+ params.validated : this.validateMoveInputCallback(this.fromSquare, this.toSquare)
177
+ if (this.toSquare && validated) {
175
178
  this.chessboard.movePiece(this.fromSquare, this.toSquare, prevState === MOVE_INPUT_STATE.clickTo).then(() => {
176
179
  if (prevState === MOVE_INPUT_STATE.clickTo) {
177
180
  this.view.setPieceVisibility(this.toSquare, true)
@@ -298,9 +301,11 @@ export class VisualMoveInput {
298
301
  const startPieceName = this.chessboard.getPiece(this.fromSquare)
299
302
  const startPieceColor = startPieceName ? startPieceName.substring(0, 1) : null
300
303
  if (color && startPieceColor === pieceColor) {
301
- // added to allow chess960 castling
304
+ // added to allow moves into own pieces, useful for chess960 castle style or recapture premoves
305
+ // result holds false if the user legality checker deemed the move into another own piece as illegal
306
+ // in that case, we start a new move by selecting the target piece
302
307
  const result = this.validateMoveInputCallback(this.fromSquare, square)
303
- if(!result) {
308
+ if (!result) {
304
309
  this.moveInputCanceledCallback(this.fromSquare, square, MOVE_CANCELED_REASON.clickedAnotherPiece)
305
310
  if (this.moveInputStartedCallback(square)) {
306
311
  this.setMoveInputState(MOVE_INPUT_STATE.pieceClickedThreshold, {
@@ -312,6 +317,10 @@ export class VisualMoveInput {
312
317
  } else {
313
318
  this.setMoveInputState(MOVE_INPUT_STATE.reset)
314
319
  }
320
+ } else {
321
+ // if the user deemed the move into own piece legal, execute it
322
+ // but prevent validating the move again with the validated flag
323
+ this.setMoveInputState(MOVE_INPUT_STATE.moveDone, {square: square, validated: true})
315
324
  }
316
325
  } else {
317
326
  this.setMoveInputState(MOVE_INPUT_STATE.moveDone, {square: square})
@@ -384,14 +393,10 @@ export class VisualMoveInput {
384
393
  if (square) {
385
394
  if (this.moveInputState === MOVE_INPUT_STATE.dragTo || this.moveInputState === MOVE_INPUT_STATE.clickDragTo) {
386
395
  if (this.fromSquare === square) {
387
- if (this.moveInputState === MOVE_INPUT_STATE.clickDragTo) {
388
- this.chessboard.state.position.setPiece(this.fromSquare, this.movedPiece)
389
- this.view.setPieceVisibility(this.fromSquare)
390
- this.moveInputCanceledCallback(square, null, MOVE_CANCELED_REASON.draggedBack)
391
- this.setMoveInputState(MOVE_INPUT_STATE.reset)
392
- } else {
393
- this.setMoveInputState(MOVE_INPUT_STATE.clickTo, {square: square})
394
- }
396
+ this.chessboard.state.position.setPiece(this.fromSquare, this.movedPiece)
397
+ this.view.setPieceVisibility(this.fromSquare)
398
+ this.moveInputCanceledCallback(square, null, MOVE_CANCELED_REASON.draggedBack)
399
+ this.setMoveInputState(MOVE_INPUT_STATE.reset)
395
400
  } else {
396
401
  this.setMoveInputState(MOVE_INPUT_STATE.moveDone, {square: square})
397
402
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Author and copyright: Stefan Haack (https://shaack.com)
3
+ * Repository: https://github.com/shaack/cm-chessboard
4
+ * License: MIT, see file 'LICENSE'
5
+ */
6
+
7
+ import {describe, it, assert} from "../node_modules/teevi/src/teevi.js"
8
+ import {Chessboard} from "../src/Chessboard.js"
9
+ import {ARROW_TYPE, Arrows} from "../src/extensions/arrows/Arrows.js"
10
+
11
+ describe("TestArrows", () => {
12
+
13
+ it("should add, get and remove arrows", () => {
14
+ const chessboard = new Chessboard(document.getElementById("TestArrows"), {
15
+ assetsUrl: "../assets/",
16
+ extensions: [{class: Arrows}]
17
+ })
18
+ chessboard.addArrow(ARROW_TYPE.default, "e2", "e4")
19
+ chessboard.addArrow(ARROW_TYPE.danger, "d2", "d4")
20
+ assert.equal(chessboard.getArrows().length, 2)
21
+ assert.equal(chessboard.getArrows(ARROW_TYPE.danger).length, 1)
22
+ assert.equal(chessboard.getArrows(undefined, "e2").length, 1)
23
+ assert.equal(chessboard.getArrows(undefined, "e2", "e4").length, 1)
24
+ assert.equal(chessboard.getArrows(undefined, "a1").length, 0)
25
+ chessboard.removeArrows(undefined, "e2")
26
+ assert.equal(chessboard.getArrows().length, 1)
27
+ assert.equal(chessboard.getArrows()[0].from, "d2")
28
+ chessboard.removeArrows()
29
+ assert.equal(chessboard.getArrows().length, 0)
30
+ chessboard.destroy()
31
+ })
32
+
33
+ })
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import {describe, it, assert} from "../node_modules/teevi/src/teevi.js"
8
- import {PIECE, Chessboard} from "../src/Chessboard.js"
8
+ import {PIECE, COLOR, Chessboard} from "../src/Chessboard.js"
9
9
  import {FEN} from "../src/model/Position.js"
10
10
 
11
11
  describe("TestChessboard", () => {
@@ -69,6 +69,49 @@ describe("TestChessboard", () => {
69
69
  chessboard.destroy()
70
70
  })
71
71
 
72
+ it("should move a piece via the api", async () => {
73
+ const chessboard = new Chessboard(document.getElementById("TestBoard"), {
74
+ assetsUrl: "../assets/",
75
+ position: FEN.start,
76
+ style: {animationDuration: 0}
77
+ })
78
+ await chessboard.movePiece("e2", "e4", false)
79
+ assert.equal(chessboard.getPiece("e2"), null)
80
+ assert.equal(chessboard.getPiece("e4"), "wp")
81
+ chessboard.destroy()
82
+ })
83
+
84
+ it("should remove a piece when set to null", () => {
85
+ const chessboard = new Chessboard(document.getElementById("TestBoard"), {
86
+ assetsUrl: "../assets/",
87
+ position: FEN.start
88
+ })
89
+ chessboard.setPiece("e2", null)
90
+ assert.equal(chessboard.getPiece("e2"), null)
91
+ chessboard.destroy()
92
+ })
93
+
94
+ it("should create an empty board from FEN.empty", () => {
95
+ const chessboard = new Chessboard(document.getElementById("TestBoard"), {
96
+ assetsUrl: "../assets/",
97
+ position: FEN.empty
98
+ })
99
+ assert.equal("" + chessboard.getPosition(), "8/8/8/8/8/8/8/8")
100
+ chessboard.destroy()
101
+ })
102
+
103
+ it("should set and get the orientation", async () => {
104
+ const chessboard = new Chessboard(document.getElementById("TestBoard"), {
105
+ assetsUrl: "../assets/",
106
+ position: FEN.start,
107
+ style: {animationDuration: 0}
108
+ })
109
+ assert.equal(chessboard.getOrientation(), COLOR.white)
110
+ await chessboard.setOrientation(COLOR.black)
111
+ assert.equal(chessboard.getOrientation(), COLOR.black)
112
+ chessboard.destroy()
113
+ })
114
+
72
115
  // Regression for https://github.com/shaack/cm-chessboard/issues/154
73
116
  //
74
117
  // A setPosition() call with a null diff (positionFrom === positionTo)
@@ -39,4 +39,19 @@ describe("TestMarkers", () => {
39
39
  chessboard.destroy()
40
40
  })
41
41
 
42
+ it("should add different marker types and remove all markers at once", () => {
43
+ const chessboard = new Chessboard(document.getElementById("TestMarkers"), {
44
+ assetsUrl: "../assets/",
45
+ extensions: [{class: Markers}]
46
+ })
47
+ chessboard.addMarker(MARKER_TYPE.dot, "e4")
48
+ chessboard.addMarker(MARKER_TYPE.circle, "d4")
49
+ chessboard.addMarker(MARKER_TYPE.circleDanger, "c4")
50
+ assert.equal(chessboard.getMarkers().length, 3)
51
+ assert.equal(chessboard.getMarkers(MARKER_TYPE.dot).length, 1)
52
+ chessboard.removeMarkers()
53
+ assert.equal(chessboard.getMarkers().length, 0)
54
+ chessboard.destroy()
55
+ })
56
+
42
57
  })
@@ -39,5 +39,41 @@ describe("TestPosition", () => {
39
39
  assert.equal(rooks.length, 1)
40
40
  assert.equal(rooks[0].square, "f5")
41
41
  })
42
+ it("should round-trip every square through index conversion", () => {
43
+ for (let i = 0; i < 64; i++) {
44
+ assert.equal(Position.squareToIndex(Position.indexToSquare(i)), i)
45
+ }
46
+ })
47
+ it("should set, get and remove a piece", () => {
48
+ const position = new Position(FEN.empty)
49
+ assert.equal(position.getPiece("e4"), null)
50
+ position.setPiece("e4", "wq")
51
+ assert.equal(position.getPiece("e4"), "wq")
52
+ position.setPiece("e4", null)
53
+ assert.equal(position.getPiece("e4"), null)
54
+ })
55
+ it("should move a piece", () => {
56
+ const position = new Position("8/8/8/8/8/8/4P3/8")
57
+ position.movePiece("e2", "e4")
58
+ assert.equal(position.getPiece("e2"), null)
59
+ assert.equal(position.getPiece("e4"), "wp")
60
+ })
61
+ it("should represent an empty board as FEN", () => {
62
+ const position = new Position(FEN.empty)
63
+ assert.equal(position.getFen(), "8/8/8/8/8/8/8/8")
64
+ })
65
+ it("should store only the piece placement of a full FEN", () => {
66
+ const position = new Position("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
67
+ assert.equal(position.getFen(), "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR")
68
+ })
69
+ it("should count the pieces of the start position", () => {
70
+ const position = new Position(FEN.start)
71
+ assert.equal(position.getPieces().length, 32)
72
+ assert.equal(position.getPieces(COLOR.white).length, 16)
73
+ assert.equal(position.getPieces(COLOR.black, PIECE_TYPE.pawn).length, 8)
74
+ })
75
+ it("should return no pieces for an empty board", () => {
76
+ assert.equal(new Position(FEN.empty).getPieces().length, 0)
77
+ })
42
78
  })
43
79
 
@@ -6,10 +6,35 @@
6
6
 
7
7
  import {describe, it, assert} from "../node_modules/teevi/src/teevi.js"
8
8
  import {Chessboard} from "../src/Chessboard.js"
9
+ import {COLOR, INPUT_EVENT_TYPE} from "../src/view/ChessboardView.js"
10
+ import {MOVE_CANCELED_REASON} from "../src/view/VisualMoveInput.js"
9
11
  import {FEN} from "../src/model/Position.js"
10
12
 
11
13
  const STATE_WAIT_FOR_INPUT_START = "waitForInputStart"
12
14
  const STATE_PIECE_CLICKED_THRESHOLD = "pieceClickedThreshold"
15
+ const STATE_CLICK_TO = "clickTo"
16
+ const STATE_DRAG_TO = "dragTo"
17
+
18
+ // Synthetic pointer events. The mouse path reads e.target / e.clientX directly,
19
+ // so it drives the real state machine without needing document.elementFromPoint.
20
+ const squareTarget = (name) => ({getAttribute: (n) => (n === "data-square" ? name : null)})
21
+ const mouseDown = (name, x = 100, y = 100) => ({
22
+ type: "mousedown", button: 0, target: squareTarget(name),
23
+ clientX: x, clientY: y, preventDefault: () => {}
24
+ })
25
+ const mouseMove = (name, x, y) => ({
26
+ type: "mousemove", target: squareTarget(name), clientX: x, clientY: y, pageX: x, pageY: y
27
+ })
28
+ const mouseUp = (name) => ({type: "mouseup", target: squareTarget(name)})
29
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 30))
30
+
31
+ function newBoard() {
32
+ return new Chessboard(document.getElementById("TestBoard"), {
33
+ assetsUrl: "../assets/",
34
+ position: FEN.start,
35
+ style: {animationDuration: 0}
36
+ })
37
+ }
13
38
 
14
39
  describe("TestVisualMoveInput", () => {
15
40
 
@@ -37,4 +62,173 @@ describe("TestVisualMoveInput", () => {
37
62
  chessboard.destroy()
38
63
  })
39
64
 
65
+ // Regression for https://github.com/shaack/cm-chessboard/pull/174
66
+ it("should validate once and finish the move when moving into own piece is marked as valid", async () => {
67
+ const chessboard = new Chessboard(document.getElementById("TestBoard"), {
68
+ assetsUrl: "../assets/",
69
+ position: FEN.start
70
+ })
71
+ let validateCallCount = 0
72
+ let moveInputFinishedCount = 0
73
+ chessboard.enableMoveInput((event) => {
74
+ if (event.type === INPUT_EVENT_TYPE.validateMoveInput) {
75
+ validateCallCount++
76
+ return true // accept the castling-by-clicking-the-rook move
77
+ } else if (event.type === INPUT_EVENT_TYPE.moveInputFinished) {
78
+ moveInputFinishedCount++
79
+ }
80
+ return true
81
+ }, COLOR.white)
82
+ const visualMoveInput = chessboard.view.visualMoveInput
83
+
84
+ // simulate an existing click-to-move selection of the white king on e1
85
+ visualMoveInput.moveInputStartedCallback("e1")
86
+ visualMoveInput.moveInputState = STATE_CLICK_TO
87
+ visualMoveInput.fromSquare = "e1"
88
+
89
+ // castle by clicking the rook on h1 -> same-color-click branch
90
+ visualMoveInput.onPointerDown({
91
+ type: "touchstart",
92
+ target: {getAttribute: (name) => (name === "data-square" ? "h1" : null)},
93
+ touches: [{clientX: 100, clientY: 100}],
94
+ preventDefault: () => {}
95
+ })
96
+
97
+ assert.equal(validateCallCount, 1)
98
+
99
+ await new Promise((resolve) => setTimeout(resolve))
100
+ // the move must complete (not get stuck in `clickTo`), firing moveInputFinished exactly once
101
+ assert.equal(moveInputFinishedCount, 1)
102
+
103
+ chessboard.destroy()
104
+ })
105
+
106
+ it("should complete a click-to-move (select, then click destination)", async () => {
107
+ const board = newBoard()
108
+ let finished = 0
109
+ board.enableMoveInput((e) => {
110
+ if (e.type === INPUT_EVENT_TYPE.moveInputFinished) finished++
111
+ return true
112
+ }, COLOR.white)
113
+ const vmi = board.view.visualMoveInput
114
+
115
+ vmi.onPointerDown(mouseDown("e2"))
116
+ vmi.onPointerUp(mouseUp("e2"))
117
+ assert.equal(vmi.moveInputState, STATE_CLICK_TO)
118
+
119
+ vmi.onPointerDown(mouseDown("e4"))
120
+ await tick()
121
+ assert.equal(board.getPiece("e4"), "wp")
122
+ assert.equal(board.getPiece("e2"), null)
123
+ assert.equal(finished, 1)
124
+ board.destroy()
125
+ })
126
+
127
+ it("should complete a drag move to a new square", async () => {
128
+ const board = newBoard()
129
+ board.enableMoveInput(() => true, COLOR.white)
130
+ const vmi = board.view.visualMoveInput
131
+
132
+ vmi.onPointerDown(mouseDown("e2", 100, 100))
133
+ vmi.onPointerMove(mouseMove("e4", 100, 140)) // past DRAG_THRESHOLD -> dragTo
134
+ assert.equal(vmi.moveInputState, STATE_DRAG_TO)
135
+ vmi.onPointerUp(mouseUp("e4"))
136
+ await tick()
137
+ assert.equal(board.getPiece("e4"), "wp")
138
+ assert.equal(board.getPiece("e2"), null)
139
+ board.destroy()
140
+ })
141
+
142
+ // Regression for https://github.com/shaack/cm-chessboard/issues/170 (PR #171)
143
+ it("should cancel when a piece is dragged back to its origin", async () => {
144
+ const board = newBoard()
145
+ let canceledReason
146
+ board.enableMoveInput((e) => {
147
+ if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceledReason = e.reason
148
+ return true
149
+ }, COLOR.white)
150
+ const vmi = board.view.visualMoveInput
151
+
152
+ vmi.onPointerDown(mouseDown("e2", 100, 100))
153
+ vmi.onPointerMove(mouseMove("e2", 100, 140)) // -> dragTo
154
+ assert.equal(vmi.moveInputState, STATE_DRAG_TO)
155
+ vmi.onPointerUp(mouseUp("e2")) // dropped back on origin
156
+ await tick()
157
+
158
+ assert.equal(canceledReason, MOVE_CANCELED_REASON.draggedBack)
159
+ assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
160
+ assert.equal(board.getPiece("e2"), "wp") // piece stays put
161
+ board.destroy()
162
+ })
163
+
164
+ // Regression for https://github.com/shaack/cm-chessboard/issues/170
165
+ it("should re-select when clicking another own piece", async () => {
166
+ const board = newBoard()
167
+ let canceledReason
168
+ board.enableMoveInput((e) => {
169
+ if (e.type === INPUT_EVENT_TYPE.validateMoveInput) return false // not a legal move onto own piece
170
+ if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceledReason = e.reason
171
+ return true
172
+ }, COLOR.white)
173
+ const vmi = board.view.visualMoveInput
174
+
175
+ vmi.onPointerDown(mouseDown("e2"))
176
+ vmi.onPointerUp(mouseUp("e2"))
177
+ assert.equal(vmi.moveInputState, STATE_CLICK_TO)
178
+
179
+ vmi.onPointerDown(mouseDown("d1")) // own white queen
180
+ assert.equal(canceledReason, MOVE_CANCELED_REASON.clickedAnotherPiece)
181
+ assert.equal(vmi.fromSquare, "d1")
182
+ assert.equal(vmi.moveInputState, STATE_PIECE_CLICKED_THRESHOLD)
183
+ await tick()
184
+ board.destroy()
185
+ })
186
+
187
+ // Regression for https://github.com/shaack/cm-chessboard/pull/173
188
+ it("should cancel an in-progress move on right click", async () => {
189
+ const board = newBoard()
190
+ let canceledReason
191
+ board.enableMoveInput((e) => {
192
+ if (e.type === INPUT_EVENT_TYPE.moveInputCanceled) canceledReason = e.reason
193
+ return true
194
+ }, COLOR.white)
195
+ const vmi = board.view.visualMoveInput
196
+
197
+ vmi.onPointerDown(mouseDown("e2"))
198
+ vmi.onPointerUp(mouseUp("e2"))
199
+ assert.equal(vmi.moveInputState, STATE_CLICK_TO)
200
+
201
+ vmi.onContextMenu({preventDefault: () => {}})
202
+ assert.equal(canceledReason, MOVE_CANCELED_REASON.secondaryClick)
203
+ assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
204
+ await tick()
205
+ board.destroy()
206
+ })
207
+
208
+ it("should ignore input when move input is disabled", () => {
209
+ const board = newBoard()
210
+ const vmi = board.view.visualMoveInput
211
+ vmi.onPointerDown(mouseDown("e2"))
212
+ assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
213
+ board.destroy()
214
+ })
215
+
216
+ it("should ignore a piece of the disabled color", () => {
217
+ const board = newBoard()
218
+ board.enableMoveInput(() => true, COLOR.white)
219
+ const vmi = board.view.visualMoveInput
220
+ vmi.onPointerDown(mouseDown("e7")) // black pawn, black input disabled
221
+ assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
222
+ board.destroy()
223
+ })
224
+
225
+ it("should not start a move when moveInputStarted returns false", () => {
226
+ const board = newBoard()
227
+ board.enableMoveInput((e) => e.type !== INPUT_EVENT_TYPE.moveInputStarted, COLOR.white)
228
+ const vmi = board.view.visualMoveInput
229
+ vmi.onPointerDown(mouseDown("e2"))
230
+ assert.equal(vmi.moveInputState, STATE_WAIT_FOR_INPUT_START)
231
+ board.destroy()
232
+ })
233
+
40
234
  })
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Author and copyright: Stefan Haack (https://shaack.com)
3
+ * Repository: https://github.com/shaack/cm-chessboard
4
+ * License: MIT, see file 'LICENSE'
5
+ *
6
+ * Optional headless test runner. Serves the project over a tiny static server
7
+ * and runs test/index.html in headless Chrome, then prints the Teevi summary
8
+ * and exits non-zero on failure.
9
+ *
10
+ * Puppeteer is intentionally NOT a project dependency (cm-chessboard ships
11
+ * without dependencies). Install it globally to use this runner:
12
+ *
13
+ * npm install -g puppeteer
14
+ * npm run test:headless
15
+ *
16
+ * The regular `npm test` just points you at test/index.html in a browser and
17
+ * needs nothing installed.
18
+ */
19
+
20
+ import {createServer} from "http"
21
+ import {createRequire} from "module"
22
+ import {execSync} from "child_process"
23
+ import {readFile} from "fs/promises"
24
+ import {fileURLToPath} from "url"
25
+ import {dirname, join, normalize, extname} from "path"
26
+
27
+ const projectRoot = normalize(join(dirname(fileURLToPath(import.meta.url)), ".."))
28
+
29
+ // Resolve the globally installed puppeteer without adding a project dependency.
30
+ function loadPuppeteer() {
31
+ let globalRoot
32
+ try {
33
+ globalRoot = execSync("npm root -g", {encoding: "utf8"}).trim()
34
+ } catch {
35
+ globalRoot = ""
36
+ }
37
+ for (const base of [globalRoot + "/", projectRoot + "/"]) {
38
+ try {
39
+ return createRequire(base)("puppeteer")
40
+ } catch { /* try next */ }
41
+ }
42
+ console.error(
43
+ "\nCould not find puppeteer. This headless runner needs it installed globally:\n" +
44
+ " npm install -g puppeteer\n" +
45
+ "Or just open test/index.html in a browser (see `npm test`).\n")
46
+ process.exit(2)
47
+ }
48
+
49
+ const MIME = {
50
+ ".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript",
51
+ ".css": "text/css", ".svg": "image/svg+xml", ".json": "application/json",
52
+ ".png": "image/png", ".map": "application/json"
53
+ }
54
+
55
+ function startServer() {
56
+ const server = createServer(async (req, res) => {
57
+ const urlPath = decodeURIComponent(req.url.split("?")[0])
58
+ const filePath = normalize(join(projectRoot, urlPath))
59
+ if (!filePath.startsWith(projectRoot)) { // block path traversal
60
+ res.writeHead(403).end()
61
+ return
62
+ }
63
+ try {
64
+ const body = await readFile(filePath)
65
+ res.writeHead(200, {"Content-Type": MIME[extname(filePath)] || "application/octet-stream"})
66
+ res.end(body)
67
+ } catch {
68
+ res.writeHead(404).end()
69
+ }
70
+ })
71
+ return new Promise((resolve) => {
72
+ server.listen(0, "127.0.0.1", () => resolve({server, port: server.address().port}))
73
+ })
74
+ }
75
+
76
+ const puppeteer = loadPuppeteer()
77
+ const {server, port} = await startServer()
78
+ const url = `http://127.0.0.1:${port}/test/index.html`
79
+
80
+ let exitCode = 1
81
+ const browser = await puppeteer.launch({headless: "new"})
82
+ try {
83
+ const page = await browser.newPage()
84
+ const errors = []
85
+ page.on("pageerror", (e) => errors.push(e.message))
86
+ await page.goto(url, {waitUntil: "networkidle0", timeout: 30000})
87
+ await page.waitForFunction(
88
+ () => /All \d+ tests passed|\d+ tests, \d+ passed, \d+ failed/.test(document.body.innerText),
89
+ {timeout: 30000})
90
+ const {summary, fails} = await page.evaluate(() => {
91
+ const text = document.body.innerText
92
+ const m = text.match(/All \d+ tests passed|\d+ tests, \d+ passed, \d+ failed/)
93
+ const fails = []
94
+ document.querySelectorAll("div").forEach((d) => {
95
+ if (d.innerText && d.innerText.includes("→ fail")) {
96
+ fails.push(d.innerText.replace(/\s+/g, " ").trim().slice(0, 500))
97
+ }
98
+ })
99
+ return {summary: m ? m[0] : "NO SUMMARY", fails}
100
+ })
101
+ console.log(summary)
102
+ for (const f of fails) console.log(" FAIL: " + f)
103
+ for (const e of errors.slice(0, 10)) console.log(" pageerror: " + e)
104
+ exitCode = /failed|NO SUMMARY/.test(summary) ? 1 : 0
105
+ } finally {
106
+ await browser.close()
107
+ server.close()
108
+ }
109
+ process.exit(exitCode)
package/test/index.html CHANGED
@@ -20,11 +20,13 @@
20
20
  <div class="board" id="TestBoard"></div>
21
21
  <div class="board" id="TestPosition"></div>
22
22
  <div class="board" id="TestMarkers"></div>
23
+ <div class="board" id="TestArrows"></div>
23
24
  <script type="module">
24
25
  import {teevi} from "../node_modules/teevi/src/teevi.js"
25
26
  import "./TestChessboard.js"
26
27
  import "./TestPiecesAnimation.js"
27
28
  import "./TestMarkers.js"
29
+ import "./TestArrows.js"
28
30
  import "./TestPosition.js"
29
31
  import "./TestVisualMoveInput.js"
30
32
  teevi.run()