From: tangkaiyuan Date: Tue, 7 Mar 2023 07:36:11 +0000 (+0800) Subject: [common][mediacapture][add new mediacapture-streams test cases] X-Git-Url: http://review.tizen.org/git/?a=commitdiff_plain;h=d7a41a50c5c7cc47048780dee554ee6e9e7f06bf;p=test%2Ftct%2Fweb%2Fapi.git [common][mediacapture][add new mediacapture-streams test cases] Change-Id: I6ca919cc0a3af7c188dc5d5475f9189a60904730 Signed-off-by: tangkaiyuan --- diff --git a/common/tct-mediacapture-w3c-tests/config.xml b/common/tct-mediacapture-w3c-tests/config.xml index 0a105c45f..f14f6eb4d 100755 --- a/common/tct-mediacapture-w3c-tests/config.xml +++ b/common/tct-mediacapture-w3c-tests/config.xml @@ -3,6 +3,14 @@ tct-mediacapture-w3c-tests + + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/inst.wgt.py b/common/tct-mediacapture-w3c-tests/inst.wgt.py index 3bcf73639..8e3380a44 100755 --- a/common/tct-mediacapture-w3c-tests/inst.wgt.py +++ b/common/tct-mediacapture-w3c-tests/inst.wgt.py @@ -160,16 +160,15 @@ def instPKGs(): action_status = False break - # Do some special copy/delete... steps - ''' - (return_code, output) = doRemoteCMD( - "mkdir -p %s/tests" % PKG_SRC_DIR) - if return_code != 0: - action_status = False - - if not doRemoteCopy("specname/tests", "%s/tests" % PKG_SRC_DIR): - action_status = False - ''' + for item in glob.glob("%s/*" % SCRIPT_DIR): + if item.endswith(".wgt"): + continue + elif item.endswith("inst.py"): + continue + else: + item_name = os.path.basename(item) + if not doRemoteCopy(item, "%s/%s" % (PKG_SRC_DIR, item_name)): + action_status = False return action_status diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-api.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-api.https.html new file mode 100755 index 000000000..108cc425b --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-api.https.html @@ -0,0 +1,22 @@ + + + +getUserMedia: test that getUserMedia is present + + + + + +

Description

+

This test checks for the presence of the +navigator.mediaDevices.getUserMedia method.

+
+ + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-empty-option-param.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-empty-option-param.https.html new file mode 100755 index 000000000..571d58eb9 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-empty-option-param.https.html @@ -0,0 +1,34 @@ + + + +getUserMedia({}) rejects with TypeError + + + + +

Description

+

This test checks that getUserMedia with no value in the +options parameter raises a TypeError exception.

+ +
+ + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-impossible-constraint.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-impossible-constraint.https.html new file mode 100755 index 000000000..b18769393 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-impossible-constraint.https.html @@ -0,0 +1,37 @@ + + + +Trivial mandatory constraint in getUserMedia + + + + + +

When prompted, accept to share your video stream.

+

Description

+

This test checks that setting an impossible mandatory +constraint (width >=1G) in getUserMedia works

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-invalid-facing-mode.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-invalid-facing-mode.https.html new file mode 100755 index 000000000..9fadeb078 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-invalid-facing-mode.https.html @@ -0,0 +1,31 @@ + + + +Invalid facingMode in getUserMedia + + + +

Description

+

This test checks that trying to set an empty facingMode + value in getUserMedia results in an OverconstrainedError. +

+ + + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-non-applicable-constraint.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-non-applicable-constraint.https.html new file mode 100755 index 000000000..cd937d408 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-non-applicable-constraint.https.html @@ -0,0 +1,77 @@ + +non-applicable constraint in getUserMedia + + + + + + + + +

When prompted, accept to share your audio and video stream.

+ + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-optional-constraint.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-optional-constraint.https.html new file mode 100755 index 000000000..c6bec6ba0 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-optional-constraint.https.html @@ -0,0 +1,32 @@ + + + +Optional constraint recognized as optional in getUserMedia + + + + +

When prompted, accept to share your video stream.

+

Description

+

This test checks that setting an optional constraint in +getUserMedia is handled as optional

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-required-constraint-with-ideal-value.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-required-constraint-with-ideal-value.https.html new file mode 100755 index 000000000..ee74aa115 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-required-constraint-with-ideal-value.https.html @@ -0,0 +1,33 @@ + + + +Ideal value in required constraint in getUserMedia + + + + + + + + + +

When prompted, accept to share your video stream.

+

Description

+

This test checks that setting a required constraint +with an ideal value in getUserMedia works

+
+ + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-trivial-constraint.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-trivial-constraint.https.html new file mode 100755 index 000000000..b69f5f5d6 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-trivial-constraint.https.html @@ -0,0 +1,32 @@ + + + +Trivial mandatory constraint in getUserMedia + + + + +

When prompted, accept to share your video stream.

+

Description

+

This test checks that setting a trivial mandatory +constraint (width >=0) in getUserMedia works

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-unknownkey-option-param.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-unknownkey-option-param.https.html new file mode 100755 index 000000000..a89553758 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/GUM-unknownkey-option-param.https.html @@ -0,0 +1,31 @@ + + + +getUserMedia({doesnotexist:true}) rejects with TypeError + + + + +

Description

+

This test checks that getUserMedia with an unknown value +in the constraints parameter rejects with a TypeError.

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/META.yml b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/META.yml new file mode 100755 index 000000000..97363cf36 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/META.yml @@ -0,0 +1,5 @@ +spec: https://w3c.github.io/mediacapture-main/ +suggested_reviewers: + - alvestrand + - youennf + - jan-ivar diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-after-discard.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-after-discard.https.html new file mode 100755 index 000000000..e7ed9c987 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-after-discard.https.html @@ -0,0 +1,62 @@ + +Test promises from MediaDevices methods in a discarded browsing + context + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices-per-origin-ids.sub.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices-per-origin-ids.sub.https.html new file mode 100755 index 000000000..462415cba --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices-per-origin-ids.sub.https.html @@ -0,0 +1,87 @@ + + + +enumerateDevices rotates deviceId across origins and after cookies get cleared + + + + + + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices-persistent-permission.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices-persistent-permission.https.html new file mode 100755 index 000000000..28081ba78 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices-persistent-permission.https.html @@ -0,0 +1,38 @@ + + + +enumerateDevices depends only on capture state, not permission state + + + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices-returned-objects.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices-returned-objects.https.html new file mode 100755 index 000000000..5ab0cffa8 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices-returned-objects.https.html @@ -0,0 +1,59 @@ + + + +enumerateDevices is returning new MediaDeviceInfo objects every time + + + + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices.https.html new file mode 100755 index 000000000..13cc53ded --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-enumerateDevices.https.html @@ -0,0 +1,38 @@ + + + +enumerateDevices: test that enumerateDevices is present + + + + + +

Description

+

This test checks for the presence of the +navigator.mediaDevices.enumerateDevices() method.

+
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-getSupportedConstraints.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-getSupportedConstraints.https.html new file mode 100755 index 000000000..94cf2b14d --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-getSupportedConstraints.https.html @@ -0,0 +1,48 @@ + + + +Test navigator.mediaDevices.getSupportedConstraints() + + + + +

Description

+

This test checks for the presence of the +navigator.mediaDevices.getSupportedConstraints() method.

+
+ + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-getUserMedia.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-getUserMedia.https.html new file mode 100755 index 000000000..e8d8d2abd --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaDevices-getUserMedia.https.html @@ -0,0 +1,126 @@ + + + +getUserMedia: test that mediaDevices.getUserMedia is present + + + + + +

Description

+

This test checks for the presence of the +navigator.mediaDevices.getUserMedia method.

+
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-MediaElement-firstframe.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-MediaElement-firstframe.https.html new file mode 100755 index 000000000..36b33da4a --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-MediaElement-firstframe.https.html @@ -0,0 +1,102 @@ + + + +Assigning a MediaStream to a media element and not playing it results in rendering a first frame + + +

When prompted, accept to share your video stream.

+

Description

+

This test checks that a HTMLMediaElement with an +assigned MediaStream with a video track fires the appropriate events to reach +the "canplay" event and readyState HAVE_ENOUGH_DATA even when not playing or +autoplaying.

+ + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-MediaElement-preload-none.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-MediaElement-preload-none.https.html new file mode 100755 index 000000000..330d1f3d4 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-MediaElement-preload-none.https.html @@ -0,0 +1,85 @@ + + + + Test that the HTMLMediaElement preload 'none' attribute value is ignored for MediaStream used as srcObject and MediaStream object URLs used as src. + + + + + + + + +

When prompted, accept to share your audio and video streams.

+

This test checks that the HTMLMediaElement preload 'none' attribute value is ignored for MediaStream used as srcObject and MediaStream object URLs used as src.

+
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-MediaElement-srcObject.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-MediaElement-srcObject.https.html new file mode 100755 index 000000000..8e34ccb3c --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-MediaElement-srcObject.https.html @@ -0,0 +1,238 @@ + + + + +Assigning mediastream to a video element + + + + +

When prompted, accept to share your video stream.

+

Description

+

This test checks that the MediaStream object returned by +the success callback in getUserMedia can be properly assigned to a video element +via the srcObject attribute.

+ + + + +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-add-audio-track.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-add-audio-track.https.html new file mode 100755 index 000000000..edbfaa448 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-add-audio-track.https.html @@ -0,0 +1,42 @@ + + + +Adding a track to a MediaStream + + + + + +

When prompted, accept to share your audio stream, then your video stream.

+

Description

+

This test checks that adding a track to a MediaStream works as expected.

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-audio-only.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-audio-only.https.html new file mode 100755 index 000000000..458307411 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-audio-only.https.html @@ -0,0 +1,32 @@ + + + +getUserMedia({audio:true}) creates a stream with at least an audio track + + + + + +

When prompted, accept to share your audio stream.

+

Description

+

This test checks that the MediaStream object returned by +the success callback in getUserMedia has exactly one audio track.

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-clone.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-clone.https.html new file mode 100755 index 000000000..7b6f890fb --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-clone.https.html @@ -0,0 +1,98 @@ + + + +MediaStream and MediaStreamTrack clone() + + + + +

When prompted, accept to give permission to use your audio and video devices.

+

Description

+

This test checks that cloning MediaStreams and MediaStreamTracks works as expected.

+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-default-feature-policy.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-default-feature-policy.https.html new file mode 100755 index 000000000..9d1b3164c --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-default-feature-policy.https.html @@ -0,0 +1,86 @@ + + + + + + + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-finished-add.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-finished-add.https.html new file mode 100755 index 000000000..9fd2f7646 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-finished-add.https.html @@ -0,0 +1,35 @@ + + + +Adding a track to an inactive MediaStream + + + + + +

When prompted, accept to share your audio stream, then +your video stream.

+

Description

+

This test checks that adding a track to an inactive +MediaStream is allowed.

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-gettrackid.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-gettrackid.https.html new file mode 100755 index 000000000..3abcc24f8 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-gettrackid.https.html @@ -0,0 +1,29 @@ + + + +Retrieving a track from a MediaStream + + + + +

When prompted, accept to share your video stream.

+

Description

+

This test checks that MediaStream.getTrackById behaves as expected

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-id.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-id.https.html new file mode 100755 index 000000000..037a5408a --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-id.https.html @@ -0,0 +1,31 @@ + + + +getUserMedia() creates a stream with a proper id + + + + +

When prompted, accept to share your video stream.

+

Description

+

This test checks that the MediaStream object returned by +the success callback in getUserMedia has a correct id.

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-idl.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-idl.https.html new file mode 100755 index 000000000..c6b796130 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-idl.https.html @@ -0,0 +1,77 @@ + + + +MediaStream constructor algorithm + + + + + + + + + +

When prompted, accept to share your video and audio stream.

+

Description

+

This test checks that the MediaStream constructor +follows the algorithm set in the spec.

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-removetrack.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-removetrack.https.html new file mode 100755 index 000000000..a00e5bd65 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-removetrack.https.html @@ -0,0 +1,140 @@ + + + +Removing a track from a MediaStream + + + + + +

When prompted, accept to share your audio stream, then your video stream.

+

Description

+

This test checks that removinging a track from a MediaStream works as expected.

+ + +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-supported-by-feature-policy.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-supported-by-feature-policy.html new file mode 100755 index 000000000..ac1860462 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-supported-by-feature-policy.html @@ -0,0 +1,15 @@ + +Test that camera and microphone are advertised in the feature list + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-video-only.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-video-only.https.html new file mode 100755 index 000000000..9c8c867ab --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStream-video-only.https.html @@ -0,0 +1,32 @@ + + + +getUserMedia({video:true}) creates a stream with one video track + + + + + +

When prompted, accept to share your video stream.

+

Description

+

This test checks that the MediaStream object returned by +the success callback in getUserMedia has exactly one video track and no audio.

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-MediaElement-disabled-audio-is-silence.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-MediaElement-disabled-audio-is-silence.https.html new file mode 100755 index 000000000..5162be386 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-MediaElement-disabled-audio-is-silence.https.html @@ -0,0 +1,59 @@ + + + +A disabled audio track is rendered as silence + + + + + +

When prompted, accept to share your audio stream.

+

Description

+

This test checks that a disabled audio track in a +MediaStream is rendered as silence. It relies on the + +Web Audio API.

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-MediaElement-disabled-video-is-black.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-MediaElement-disabled-video-is-black.https.html new file mode 100755 index 000000000..61ca64722 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-MediaElement-disabled-video-is-black.https.html @@ -0,0 +1,56 @@ + + + +A disabled video track is rendered as blackness + + + + + +

When prompted, accept to share your video stream.

+

Description

+

This test checks that a disabled video track in a +MediaStream is rendered as blackness.

+ + +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-applyConstraints.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-applyConstraints.https.html new file mode 100755 index 000000000..4464aae07 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-applyConstraints.https.html @@ -0,0 +1,83 @@ + +MediaStreamTrack applyConstraints +

When prompted, accept to share your video stream.

+ + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-end-manual.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-end-manual.https.html new file mode 100755 index 000000000..f73e72aef --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-end-manual.https.html @@ -0,0 +1,54 @@ + + + +Test that mediastreamtrack are properly ended + + + + +

When prompted, accept to share your video and audio +stream, and then revoke that permission.

+

Description

+

This test checks that the video and audio tracks of +MediaStream object returned by the success callback in getUserMedia are +correctly set into inactive state when permission is revoked.

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-getCapabilities.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-getCapabilities.https.html new file mode 100755 index 000000000..c07eaffaa --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-getCapabilities.https.html @@ -0,0 +1,154 @@ + +MediaStreamTrack and InputDeviceInfo GetCapabilities + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-getSettings.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-getSettings.https.html new file mode 100755 index 000000000..8ad829e57 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-getSettings.https.html @@ -0,0 +1,222 @@ + +MediaStreamTrack GetSettings +

When prompted, accept to share your video stream.

+ + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-id.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-id.https.html new file mode 100755 index 000000000..22501481a --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-id.https.html @@ -0,0 +1,27 @@ + + + +Distinct id for distinct mediastream tracks + + + + +

When prompted, accept to share your audio and video stream.

+

Description

+

This test checks that distinct mediastream tracks have distinct ids.

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-iframe-transfer.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-iframe-transfer.https.html new file mode 100755 index 000000000..2b3e09ec9 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-iframe-transfer.https.html @@ -0,0 +1,30 @@ + +MediaStreamTrack transfer to iframe + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-init.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-init.https.html new file mode 100755 index 000000000..e5ed49064 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-init.https.html @@ -0,0 +1,39 @@ + + + +getUserMedia({video:true}) creates a stream with a properly initialized video track + + + + + + + + +

When prompted, accept to share your video stream.

+

Description

+

This test checks that the video track of MediaStream +object returned by the success callback in getUserMedia is correctly initialized.

+ +
+ + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-transfer-video.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-transfer-video.https.html new file mode 100755 index 000000000..9fc2e6473 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-transfer-video.https.html @@ -0,0 +1,26 @@ + +MediaStreamTrack transfer to iframe + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-transfer.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-transfer.https.html new file mode 100755 index 000000000..6f6e950f6 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrack-transfer.https.html @@ -0,0 +1,50 @@ + +MediaStreamTrack transfer to Worker + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrackEvent-constructor.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrackEvent-constructor.https.html new file mode 100755 index 000000000..6bd64b310 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/MediaStreamTrackEvent-constructor.https.html @@ -0,0 +1,42 @@ + +MediaStreamTrackEvent constructor + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/blank.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/blank.html new file mode 100755 index 000000000..e69de29bb diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/crashtests/enumerateDevices-after-discard-1.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/crashtests/enumerateDevices-after-discard-1.https.html new file mode 100755 index 000000000..d1f4bab14 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/crashtests/enumerateDevices-after-discard-1.https.html @@ -0,0 +1,18 @@ + + + + Test enumerateDevices() calls either side of browsing context discard + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/enumerateDevices-with-navigation.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/enumerateDevices-with-navigation.https.html new file mode 100755 index 000000000..2f7095f76 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/enumerateDevices-with-navigation.https.html @@ -0,0 +1,78 @@ + +enumerateDevices() with navigation + + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/historical.https.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/historical.https.html new file mode 100755 index 000000000..31ac1e774 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/historical.https.html @@ -0,0 +1,25 @@ + +Historical Media Capture and Streams features + + +
+ diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/idlharness.https.window.js b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/idlharness.https.window.js new file mode 100755 index 000000000..e8e3cdab2 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/idlharness.https.window.js @@ -0,0 +1,50 @@ +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js +// META: timeout=long + +'use strict'; + +// https://w3c.github.io/mediacapture-main/ + +idl_test( + ['mediacapture-streams'], + ['webidl', 'dom', 'html'], + async idl_array => { + const inputDevices = []; + const outputDevices = []; + try { + const list = await navigator.mediaDevices.enumerateDevices(); + for (const device of list) { + if (device.kind in self) { + continue; + } + assert_in_array(device.kind, ['audioinput', 'videoinput', 'audiooutput']); + self[device.kind] = device; + if (device.kind.endsWith('input')) { + inputDevices.push(device.kind); + } else { + outputDevices.push(device.kind); + } + } + } catch (e) {} + + try { + self.stream = await navigator.mediaDevices.getUserMedia({audio: true}); + self.track = stream.getTracks()[0]; + self.trackEvent = new MediaStreamTrackEvent("type", { + track: track, + }); + } catch (e) {} + + idl_array.add_objects({ + InputDeviceInfo: inputDevices, + MediaStream: ['stream', 'new MediaStream()'], + Navigator: ['navigator'], + MediaDevices: ['navigator.mediaDevices'], + MediaDeviceInfo: outputDevices, + MediaStreamTrack: ['track'], + MediaStreamTrackEvent: ['trackEvent'], + OverconstrainedError: ['new OverconstrainedError("constraint")'], + }); + } +); diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/iframe-enumerate-cleared.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/iframe-enumerate-cleared.html new file mode 100755 index 000000000..27dd046ac --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/iframe-enumerate-cleared.html @@ -0,0 +1,2 @@ + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/iframe-enumerate.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/iframe-enumerate.html new file mode 100755 index 000000000..27dd046ac --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/iframe-enumerate.html @@ -0,0 +1,2 @@ + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/index.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/index.html new file mode 100755 index 000000000..7e1de80cf --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/index.html @@ -0,0 +1,51 @@ + + +Directory listing for /mediacapture-streams/ +

Directory listing for /mediacapture-streams/

+ diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/message-enumerateddevices.js b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/message-enumerateddevices.js new file mode 100755 index 000000000..4541636b4 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/message-enumerateddevices.js @@ -0,0 +1,8 @@ +onmessage = async e => { + const stream = await navigator.mediaDevices.getUserMedia({audio: true, video: true}); + stream.getTracks().forEach(t => t.stop()); + const devices = await navigator.mediaDevices.enumerateDevices(); + e.source.postMessage({ + devices: devices.map(d => d.toJSON()) + }, '*'); +} diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/permission-helper.js b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/permission-helper.js new file mode 100755 index 000000000..769f3ee24 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/permission-helper.js @@ -0,0 +1,24 @@ +// Set permissions for camera and microphone using Web Driver +// Status can be one of "granted" or "denied" +// Scope take values from permission names +async function setMediaPermission(status="granted", scope=["camera", "microphone"]) { + try { + for (let s of scope) { + await test_driver.set_permission({ name: s }, status, true); + } + } catch (e) { + const noSetPermissionSupport = typeof e === "string" && e.match(/set_permission not implemented/); + if (!(noSetPermissionSupport || + (e instanceof Error && e.message.match("unimplemented")) )) { + throw e; + } + // Web Driver not implemented action + // FF: https://bugzilla.mozilla.org/show_bug.cgi?id=1524074 + + // with current WPT runners, will default to granted state for FF and Safari + // throw if status!="granted" to invalidate test results + if (status === "denied") { + assert_implements_optional(!noSetPermissionSupport, "Unable to set permission to denied for this test"); + } + } +} diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/support/iframe-MediaStreamTrack-transfer-video.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/support/iframe-MediaStreamTrack-transfer-video.html new file mode 100755 index 000000000..9f37ba0ff --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/support/iframe-MediaStreamTrack-transfer-video.html @@ -0,0 +1,27 @@ + + + + iframe + + + + + + diff --git a/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/support/iframe-MediaStreamTrack-transfer.html b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/support/iframe-MediaStreamTrack-transfer.html new file mode 100755 index 000000000..8273e6e78 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/mediacapture/w3c/mediacapture-streams/support/iframe-MediaStreamTrack-transfer.html @@ -0,0 +1,22 @@ + + + + iframe + + + + + diff --git a/common/tct-mediacapture-w3c-tests/resources/featurepolicy.js b/common/tct-mediacapture-w3c-tests/resources/featurepolicy.js new file mode 100755 index 000000000..864c434c6 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/resources/featurepolicy.js @@ -0,0 +1,454 @@ +// Feature test to avoid timeouts +function assert_feature_policy_supported() { + assert_not_equals(document.featurePolicy, undefined, + 'Feature Policy is supported'); +} +// Tests whether a feature that is enabled/disabled by feature policy works +// as expected. +// Arguments: +// feature_description: a short string describing what feature is being +// tested. Examples: "usb.GetDevices()", "PaymentRequest()". +// test: test created by testharness. Examples: async_test, promise_test. +// src: URL where a feature's availability is checked. Examples: +// "/feature-policy/resources/feature-policy-payment.html", +// "/feature-policy/resources/feature-policy-usb.html". +// expect_feature_available: a callback(data, feature_description) to +// verify if a feature is available or unavailable as expected. +// The file under the path "src" defines what "data" is sent back as a +// postMessage. Inside the callback, some tests (e.g., EXPECT_EQ, +// EXPECT_TRUE, etc) are run accordingly to test a feature's +// availability. +// Example: expect_feature_available_default(data, feature_description). +// feature_name: Optional argument, only provided when testing iframe allow +// attribute. "feature_name" is the feature name of a policy controlled +// feature (https://wicg.github.io/feature-policy/#features). +// See examples at: +// https://github.com/WICG/feature-policy/blob/master/features.md +// allow_attribute: Optional argument, only used for testing fullscreen: +// "allowfullscreen" +function test_feature_availability( + feature_description, test, src, expect_feature_available, feature_name, + allow_attribute) { + let frame = document.createElement('iframe'); + frame.src = src; + + if (typeof feature_name !== 'undefined') { + frame.allow = frame.allow.concat(";" + feature_name); + } + + if (typeof allow_attribute !== 'undefined') { + frame.setAttribute(allow_attribute, true); + } + + window.addEventListener('message', test.step_func(function handler(evt) { + if (evt.source === frame.contentWindow) { + expect_feature_available(evt.data, feature_description); + document.body.removeChild(frame); + window.removeEventListener('message', handler); + test.done(); + } + })); + + document.body.appendChild(frame); +} + +// Default helper functions to test a feature's availability: +function expect_feature_available_default(data, feature_description) { + assert_true(data.enabled, feature_description); +} + +function expect_feature_unavailable_default(data, feature_description) { + assert_false(data.enabled, feature_description); +} + +// This is the same as test_feature_availability() but instead of passing in a +// function to check the result of the message sent back from an iframe, instead +// just compares the result to an expected result passed in. +// Arguments: +// test: test created by testharness. Examples: async_test, promise_test. +// src: the URL to load in an iframe in which to test the feature. +// expected_result: the expected value to compare to the data passed back +// from the src page by postMessage. +// allow_attribute: Optional argument, only provided when an allow +// attribute should be specified on the iframe. +function test_feature_availability_with_post_message_result( + test, src, expected_result, allow_attribute) { + var test_result = function(data, feature_description) { + assert_equals(data, expected_result); + }; + test_feature_availability(null, test, src, test_result, allow_attribute); +} + +// If this page is intended to test the named feature (according to the URL), +// tests the feature availability and posts the result back to the parent. +// Otherwise, does nothing. +function test_feature_in_iframe(feature_name, feature_promise_factory) { + if (location.hash.endsWith(`#${feature_name}`)) { + feature_promise_factory().then( + () => window.parent.postMessage('#OK', '*'), + (e) => window.parent.postMessage('#' + e.name, '*')); + } +} + +// Returns true if the URL for this page indicates that it is embedded in an +// iframe. +function page_loaded_in_iframe() { + return location.hash.startsWith('#iframe'); +} + +// Returns a same-origin (relative) URL suitable for embedding in an iframe for +// testing the availability of the feature. +function same_origin_url(feature_name) { + // Append #iframe to the URL so we can detect the iframe'd version of the + // page. + return location.pathname + '#iframe#' + feature_name; +} + +// Returns a cross-origin (absolute) URL suitable for embedding in an iframe for +// testing the availability of the feature. +function cross_origin_url(base_url, feature_name) { + return base_url + same_origin_url(feature_name); +} + +// This function runs all feature policy tests for a particular feature that +// has a default policy of "self". This includes testing: +// 1. Feature usage succeeds by default in the top level frame. +// 2. Feature usage succeeds by default in a same-origin iframe. +// 3. Feature usage fails by default in a cross-origin iframe. +// 4. Feature usage suceeds when an allow attribute is specified on a +// cross-origin iframe. +// +// The same page which called this function will be loaded in the iframe in +// order to test feature usage there. When this function is called in that +// context it will simply run the feature and return a result back via +// postMessage. +// +// Arguments: +// cross_origin: A cross-origin URL base to be used to load the page which +// called into this function. +// feature_name: The name of the feature as it should be specified in an +// allow attribute. +// error_name: If feature usage does not succeed, this is the string +// representation of the error that will be passed in the rejected +// promise. +// feature_promise_factory: A function which returns a promise which tests +// feature usage. If usage succeeds, the promise should resolve. If it +// fails, the promise should reject with an error that can be +// represented as a string. +function run_all_fp_tests_allow_self( + cross_origin, feature_name, error_name, feature_promise_factory) { + // This may be the version of the page loaded up in an iframe. If so, just + // post the result of running the feature promise back to the parent. + if (page_loaded_in_iframe()) { + test_feature_in_iframe(feature_name, feature_promise_factory); + return; + } + + // Run the various tests. + // 1. Allowed in top-level frame. + promise_test( + () => feature_promise_factory(), + 'Default "' + feature_name + + '" feature policy ["self"] allows the top-level document.'); + + // 2. Allowed in same-origin iframe. + const same_origin_frame_pathname = same_origin_url(feature_name); + async_test( + t => { + test_feature_availability_with_post_message_result( + t, same_origin_frame_pathname, '#OK'); + }, + 'Default "' + feature_name + + '" feature policy ["self"] allows same-origin iframes.'); + + // 3. Blocked in cross-origin iframe. + const cross_origin_frame_url = cross_origin_url(cross_origin, feature_name); + async_test( + t => { + test_feature_availability_with_post_message_result( + t, cross_origin_frame_url, '#' + error_name); + }, + 'Default "' + feature_name + + '" feature policy ["self"] disallows cross-origin iframes.'); + + // 4. Allowed in cross-origin iframe with "allow" attribute. + async_test( + t => { + test_feature_availability_with_post_message_result( + t, cross_origin_frame_url, '#OK', feature_name); + }, + 'Feature policy "' + feature_name + + '" can be enabled in cross-origin iframes using "allow" attribute.'); +} + +// This function runs all feature policy tests for a particular feature that +// has a default policy of "*". This includes testing: +// 1. Feature usage succeeds by default in the top level frame. +// 2. Feature usage succeeds by default in a same-origin iframe. +// 3. Feature usage succeeds by default in a cross-origin iframe. +// 4. Feature usage fails when an allow attribute is specified on a +// cross-origin iframe with a value of "feature-name 'none'". +// +// The same page which called this function will be loaded in the iframe in +// order to test feature usage there. When this function is called in that +// context it will simply run the feature and return a result back via +// postMessage. +// +// Arguments: +// cross_origin: A cross-origin URL base to be used to load the page which +// called into this function. +// feature_name: The name of the feature as it should be specified in an +// allow attribute. +// error_name: If feature usage does not succeed, this is the string +// representation of the error that will be passed in the rejected +// promise. +// feature_promise_factory: A function which returns a promise which tests +// feature usage. If usage succeeds, the promise should resolve. If it +// fails, the promise should reject with an error that can be +// represented as a string. +function run_all_fp_tests_allow_all( + cross_origin, feature_name, error_name, feature_promise_factory) { + // This may be the version of the page loaded up in an iframe. If so, just + // post the result of running the feature promise back to the parent. + if (page_loaded_in_iframe()) { + test_feature_in_iframe(feature_name, feature_promise_factory); + return; + } + + // Run the various tests. + // 1. Allowed in top-level frame. + promise_test( + () => feature_promise_factory(), + 'Default "' + feature_name + + '" feature policy ["*"] allows the top-level document.'); + + // 2. Allowed in same-origin iframe. + const same_origin_frame_pathname = same_origin_url(feature_name); + async_test( + t => { + test_feature_availability_with_post_message_result( + t, same_origin_frame_pathname, '#OK'); + }, + 'Default "' + feature_name + + '" feature policy ["*"] allows same-origin iframes.'); + + // 3. Allowed in cross-origin iframe. + const cross_origin_frame_url = cross_origin_url(cross_origin, feature_name); + async_test( + t => { + test_feature_availability_with_post_message_result( + t, cross_origin_frame_url, '#OK'); + }, + 'Default "' + feature_name + + '" feature policy ["*"] allows cross-origin iframes.'); + + // 4. Blocked in cross-origin iframe with "allow" attribute set to 'none'. + async_test( + t => { + test_feature_availability_with_post_message_result( + t, cross_origin_frame_url, '#' + error_name, + feature_name + " 'none'"); + }, + 'Feature policy "' + feature_name + + '" can be disabled in cross-origin iframes using "allow" attribute.'); + + // 5. Blocked in same-origin iframe with "allow" attribute set to 'none'. + async_test( + t => { + test_feature_availability_with_post_message_result( + t, same_origin_frame_pathname, '#' + error_name, + feature_name + " 'none'"); + }, + 'Feature policy "' + feature_name + + '" can be disabled in same-origin iframes using "allow" attribute.'); +} + +// This function tests that a given policy allows each feature for the correct +// list of origins specified by the |expected_policy|. +// Arguments: +// expected_policy: A list of {feature, allowlist} pairs where the feature is +// enabled for every origin in the allowlist, in the |policy|. +// policy: Either a document.featurePolicy or an iframe.featurePolicy to be +// tested. +// message: A short description of what policy is being tested. +function test_allowlists(expected_policy, policy, message) { + for (var allowlist of allowlists) { + test(function() { + assert_array_equals( + policy.getAllowlistForFeature(allowlist.feature), + allowlist.allowlist); + }, message + ' for feature ' + allowlist.feature); + } +} + +// This function tests that a subframe's document policy allows a given feature. +// A feature is allowed in a frame either through inherited policy or specified +// by iframe allow attribute. +// Arguments: +// test: test created by testharness. Examples: async_test, promise_test. +// feature: feature name that should be allowed in the frame. +// src: the URL to load in the frame. +// allow: the allow attribute (container policy) of the iframe +function test_allowed_feature_for_subframe(message, feature, src, allow) { + let frame = document.createElement('iframe'); + if (typeof allow !== 'undefined') { + frame.allow = allow; + } + promise_test(function() { + assert_feature_policy_supported(); + frame.src = src; + return new Promise(function(resolve, reject) { + window.addEventListener('message', function handler(evt) { + resolve(evt.data); + }, { once: true }); + document.body.appendChild(frame); + }).then(function(data) { + assert_true(data.includes(feature), feature); + }); + }, message); +} + +// This function tests that a subframe's document policy disallows a given +// feature. A feature is allowed in a frame either through inherited policy or +// specified by iframe allow attribute. +// Arguments: +// test: test created by testharness. Examples: async_test, promise_test. +// feature: feature name that should not be allowed in the frame. +// src: the URL to load in the frame. +// allow: the allow attribute (container policy) of the iframe +function test_disallowed_feature_for_subframe(message, feature, src, allow) { + let frame = document.createElement('iframe'); + if (typeof allow !== 'undefined') { + frame.allow = allow; + } + promise_test(function() { + assert_feature_policy_supported(); + frame.src = src; + return new Promise(function(resolve, reject) { + window.addEventListener('message', function handler(evt) { + resolve(evt.data); + }, { once: true }); + document.body.appendChild(frame); + }).then(function(data) { + assert_false(data.includes(feature), feature); + }); + }, message); +} + +// This function tests that a subframe with header policy defined on a given +// feature allows and disallows the feature as expected. +// Arguments: +// feature: feature name. +// frame_header_policy: either *, 'self' or 'none', defines the frame +// document's header policy on |feature|. +// src: the URL to load in the frame. +// test_expects: contains 6 expected results of either |feature| is allowed +// or not inside of a local or remote iframe nested inside +// the subframe given the header policy to be either *, +// 'slef', or 'none'. +// test_name: name of the test. +function test_subframe_header_policy( + feature, frame_header_policy, src, test_expects, test_name) { + let frame = document.createElement('iframe'); + promise_test(function() { + assert_feature_policy_supported() + frame.src = src + '?pipe=sub|header(Feature-Policy,' + feature + ' ' + + frame_header_policy + ';)'; + return new Promise(function(resolve) { + window.addEventListener('message', function handler(evt) { + resolve(evt.data); + }); + document.body.appendChild(frame); + }).then(function(results) { + for (var j = 0; j < results.length; j++) { + var data = results[j]; + + function test_result(message, test_expect) { + if (test_expect) { + assert_true(data.allowedfeatures.includes(feature), message); + } else { + assert_false(data.allowedfeatures.includes(feature), message); + } + } + + if (data.frame === 'local') { + if (data.policy === '*') { + test_result('local_all:', test_expects.local_all); + } + if (data.policy === '\'self\'') { + test_result('local_self:', test_expects.local_self); + } + if (data.policy === '\'none\'') { + test_result('local_none:', test_expects.local_none); + } + } + + if (data.frame === 'remote') { + if (data.policy === '*') { + test_result('remote_all:', test_expects.remote_all); + } + if (data.policy === '\'self\'') { + test_result('remote_self:', test_expects.remote_self); + } + if (data.policy === '\'none\'') { + test_result('remote_none:', test_expects.remote_none); + } + } + } + }); + }, test_name); +} + +// This function tests that frame policy allows a given feature correctly. A +// feature is allowed in a frame either through inherited policy or specified +// by iframe allow attribute. +// Arguments: +// feature: feature name. +// src: the URL to load in the frame. If undefined, the iframe will have a +// srcdoc="" attribute +// test_expect: boolean value of whether the feature should be allowed. +// allow: optional, the allow attribute (container policy) of the iframe. +// allowfullscreen: optional, boolean value of allowfullscreen attribute. +// sandbox: optional boolean. If true, the frame will be sandboxed (with +// allow-scripts, so that tests can run in it.) +function test_frame_policy( + feature, src, srcdoc, test_expect, allow, allowfullscreen, sandbox) { + let frame = document.createElement('iframe'); + document.body.appendChild(frame); + // frame_policy should be dynamically updated as allow and allowfullscreen is + // updated. + var frame_policy = frame.featurePolicy; + if (typeof allow !== 'undefined') { + frame.setAttribute('allow', allow); + } + if (!!allowfullscreen) { + frame.setAttribute('allowfullscreen', true); + } + if (!!sandbox) { + frame.setAttribute('sandbox', 'allow-scripts'); + } + if (!!src) { + frame.src = src; + } + if (!!srcdoc) { + frame.srcdoc = "

Hello world!

"; + } + if (test_expect) { + assert_true(frame_policy.allowedFeatures().includes(feature)); + } else { + assert_false(frame_policy.allowedFeatures().includes(feature)); + } +} + +function expect_reports(report_count, policy_name, description) { + async_test(t => { + var num_received_reports = 0; + new ReportingObserver(t.step_func((reports, observer) => { + const relevant_reports = reports.filter(r => (r.body.featureId === policy_name)); + num_received_reports += relevant_reports.length; + if (num_received_reports >= report_count) { + t.done(); + } + }), {types: ['permissions-policy-violation'], buffered: true}).observe(); + }, description); +} diff --git a/common/tct-mediacapture-w3c-tests/resources/get-host-info.sub.js b/common/tct-mediacapture-w3c-tests/resources/get-host-info.sub.js new file mode 100755 index 000000000..ca5ff05a3 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/resources/get-host-info.sub.js @@ -0,0 +1,63 @@ +/** + * Host information for cross-origin tests. + * @returns {Object} with properties for different host information. + */ +function get_host_info() { + + var HTTP_PORT = '80'; + var HTTP_PORT2 = '81'; + var HTTPS_PORT = '443'; + var HTTPS_PORT2 = '444'; + var PROTOCOL = self.location.protocol; + var IS_HTTPS = (PROTOCOL == "https:"); + var PORT = IS_HTTPS ? HTTPS_PORT : HTTP_PORT; + var PORT2 = IS_HTTPS ? HTTPS_PORT2 : HTTP_PORT2; + var HTTP_PORT_ELIDED = HTTP_PORT == "80" ? "" : (":" + HTTP_PORT); + var HTTP_PORT2_ELIDED = HTTP_PORT2 == "80" ? "" : (":" + HTTP_PORT2); + var HTTPS_PORT_ELIDED = HTTPS_PORT == "443" ? "" : (":" + HTTPS_PORT); + var PORT_ELIDED = IS_HTTPS ? HTTPS_PORT_ELIDED : HTTP_PORT_ELIDED; + var ORIGINAL_HOST = 'w3c-test.org'; + var REMOTE_HOST = (ORIGINAL_HOST === 'localhost') ? '127.0.0.1' : ('www.' + ORIGINAL_HOST); + var OTHER_HOST = 'www2.w3c-test.org'; + var NOTSAMESITE_HOST = (ORIGINAL_HOST === 'localhost') ? '127.0.0.1' : ('not-web-platform.test'); + + return { + HTTP_PORT: HTTP_PORT, + HTTP_PORT2: HTTP_PORT2, + HTTPS_PORT: HTTPS_PORT, + HTTPS_PORT2: HTTPS_PORT2, + PORT: PORT, + PORT2: PORT2, + ORIGINAL_HOST: ORIGINAL_HOST, + REMOTE_HOST: REMOTE_HOST, + + ORIGIN: PROTOCOL + "//" + ORIGINAL_HOST + PORT_ELIDED, + HTTP_ORIGIN: 'http://' + ORIGINAL_HOST + HTTP_PORT_ELIDED, + HTTPS_ORIGIN: 'https://' + ORIGINAL_HOST + HTTPS_PORT_ELIDED, + HTTPS_ORIGIN_WITH_CREDS: 'https://foo:bar@' + ORIGINAL_HOST + HTTPS_PORT_ELIDED, + HTTP_ORIGIN_WITH_DIFFERENT_PORT: 'http://' + ORIGINAL_HOST + HTTP_PORT2_ELIDED, + REMOTE_ORIGIN: PROTOCOL + "//" + REMOTE_HOST + PORT_ELIDED, + OTHER_ORIGIN: PROTOCOL + "//" + OTHER_HOST + PORT_ELIDED, + HTTP_REMOTE_ORIGIN: 'http://' + REMOTE_HOST + HTTP_PORT_ELIDED, + HTTP_NOTSAMESITE_ORIGIN: 'http://' + NOTSAMESITE_HOST + HTTP_PORT_ELIDED, + HTTP_REMOTE_ORIGIN_WITH_DIFFERENT_PORT: 'http://' + REMOTE_HOST + HTTP_PORT2_ELIDED, + HTTPS_REMOTE_ORIGIN: 'https://' + REMOTE_HOST + HTTPS_PORT_ELIDED, + HTTPS_REMOTE_ORIGIN_WITH_CREDS: 'https://foo:bar@' + REMOTE_HOST + HTTPS_PORT_ELIDED, + HTTPS_NOTSAMESITE_ORIGIN: 'https://' + NOTSAMESITE_HOST + HTTPS_PORT_ELIDED, + UNAUTHENTICATED_ORIGIN: 'http://' + OTHER_HOST + HTTP_PORT_ELIDED, + AUTHENTICATED_ORIGIN: 'https://' + OTHER_HOST + HTTPS_PORT_ELIDED + }; +} + +/** + * When a default port is used, location.port returns the empty string. + * This function attempts to provide an exact port, assuming we are running under wptserve. + * @param {*} loc - can be Location///URL, but assumes http/https only. + * @returns {string} The port number. + */ +function get_port(loc) { + if (loc.port) { + return loc.port; + } + return loc.protocol === 'https:' ? '443' : '80'; +} diff --git a/common/tct-mediacapture-w3c-tests/resources/testdriver-vendor.js b/common/tct-mediacapture-w3c-tests/resources/testdriver-vendor.js new file mode 100755 index 000000000..e69de29bb diff --git a/common/tct-mediacapture-w3c-tests/resources/testdriver.js b/common/tct-mediacapture-w3c-tests/resources/testdriver.js new file mode 100755 index 000000000..9f2bfb0c5 --- /dev/null +++ b/common/tct-mediacapture-w3c-tests/resources/testdriver.js @@ -0,0 +1,734 @@ +(function() { + "use strict"; + var idCounter = 0; + let testharness_context = null; + + function getInViewCenterPoint(rect) { + var left = Math.max(0, rect.left); + var right = Math.min(window.innerWidth, rect.right); + var top = Math.max(0, rect.top); + var bottom = Math.min(window.innerHeight, rect.bottom); + + var x = 0.5 * (left + right); + var y = 0.5 * (top + bottom); + + return [x, y]; + } + + function getPointerInteractablePaintTree(element) { + let elementDocument = element.ownerDocument; + if (!elementDocument.contains(element)) { + return []; + } + + var rectangles = element.getClientRects(); + + if (rectangles.length === 0) { + return []; + } + + var centerPoint = getInViewCenterPoint(rectangles[0]); + + if ("elementsFromPoint" in elementDocument) { + return elementDocument.elementsFromPoint(centerPoint[0], centerPoint[1]); + } else if ("msElementsFromPoint" in elementDocument) { + var rv = elementDocument.msElementsFromPoint(centerPoint[0], centerPoint[1]); + return Array.prototype.slice.call(rv ? rv : []); + } else { + throw new Error("document.elementsFromPoint unsupported"); + } + } + + function inView(element) { + var pointerInteractablePaintTree = getPointerInteractablePaintTree(element); + return pointerInteractablePaintTree.indexOf(element) !== -1; + } + + + /** + * @namespace {test_driver} + */ + window.test_driver = { + /** + * Set the context in which testharness.js is loaded + * + * @param {WindowProxy} context - the window containing testharness.js + **/ + set_test_context: function(context) { + if (window.test_driver_internal.set_test_context) { + window.test_driver_internal.set_test_context(context); + } + testharness_context = context; + }, + + /** + * postMessage to the context containing testharness.js + * + * @param {Object} msg - the data to POST + **/ + message_test: function(msg) { + let target = testharness_context; + if (testharness_context === null) { + target = window; + } + target.postMessage(msg, "*"); + }, + + /** + * Trigger user interaction in order to grant additional privileges to + * a provided function. + * + * See `triggered by user activation + * `_. + * + * @example + * var mediaElement = document.createElement('video'); + * + * test_driver.bless('initiate media playback', function () { + * mediaElement.play(); + * }); + * + * @param {String} intent - a description of the action which must be + * triggered by user interaction + * @param {Function} action - code requiring escalated privileges + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled following user interaction and + * execution of the provided `action` function; + * rejected if interaction fails or the provided + * function throws an error + */ + bless: function(intent, action, context=null) { + let contextDocument = context ? context.document : document; + var button = contextDocument.createElement("button"); + button.innerHTML = "This test requires user interaction.
" + + "Please click here to allow " + intent + "."; + button.id = "wpt-test-driver-bless-" + (idCounter += 1); + const elem = contextDocument.body || contextDocument.documentElement; + elem.appendChild(button); + + let wait_click = new Promise(resolve => button.addEventListener("click", resolve)); + + return test_driver.click(button) + .then(wait_click) + .then(function() { + button.remove(); + + if (typeof action === "function") { + return action(); + } + return null; + }); + }, + + /** + * Triggers a user-initiated click + * + * If ``element`` isn't inside the + * viewport, it will be scrolled into view before the click + * occurs. + * + * If ``element`` is from a different browsing context, the + * command will be run in that context. + * + * Matches the behaviour of the `Element Click + * `_ + * WebDriver command. + * + * **Note:** If the element to be clicked does not have a + * unique ID, the document must not have any DOM mutations + * made between the function being called and the promise + * settling. + * + * @param {Element} element - element to be clicked + * @returns {Promise} fulfilled after click occurs, or rejected in + * the cases the WebDriver command errors + */ + click: function(element) { + if (!inView(element)) { + element.scrollIntoView({behavior: "instant", + block: "end", + inline: "nearest"}); + } + + var pointerInteractablePaintTree = getPointerInteractablePaintTree(element); + if (pointerInteractablePaintTree.length === 0 || + !element.contains(pointerInteractablePaintTree[0])) { + return Promise.reject(new Error("element click intercepted error")); + } + + var rect = element.getClientRects()[0]; + var centerPoint = getInViewCenterPoint(rect); + return window.test_driver_internal.click(element, + {x: centerPoint[0], + y: centerPoint[1]}); + }, + + /** + * Deletes all cookies. + * + * Matches the behaviour of the `Delete All Cookies + * `_ + * WebDriver command. + * + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled after cookies are deleted, or rejected in + * the cases the WebDriver command errors + */ + delete_all_cookies: function(context=null) { + return window.test_driver_internal.delete_all_cookies(context); + }, + + /** + * Send keys to an element. + * + * If ``element`` isn't inside the + * viewport, it will be scrolled into view before the click + * occurs. + * + * If ``element`` is from a different browsing context, the + * command will be run in that context. + * + * To send special keys, send the respective key's codepoint, + * as defined by `WebDriver + * `_. For + * example, the "tab" key is represented as "``\uE004``". + * + * **Note:** these special-key codepoints are not necessarily + * what you would expect. For example, Esc is the + * invalid Unicode character ``\uE00C``, not the ``\u001B`` Escape + * character from ASCII. + * + * This matches the behaviour of the + * `Send Keys + * `_ + * WebDriver command. + * + * **Note:** If the element to be clicked does not have a + * unique ID, the document must not have any DOM mutations + * made between the function being called and the promise + * settling. + * + * @param {Element} element - element to send keys to + * @param {String} keys - keys to send to the element + * @returns {Promise} fulfilled after keys are sent, or rejected in + * the cases the WebDriver command errors + */ + send_keys: function(element, keys) { + if (!inView(element)) { + element.scrollIntoView({behavior: "instant", + block: "end", + inline: "nearest"}); + } + + var pointerInteractablePaintTree = getPointerInteractablePaintTree(element); + if (pointerInteractablePaintTree.length === 0 || + !element.contains(pointerInteractablePaintTree[0])) { + return Promise.reject(new Error("element send_keys intercepted error")); + } + + return window.test_driver_internal.send_keys(element, keys); + }, + + /** + * Freeze the current page + * + * The freeze function transitions the page from the HIDDEN state to + * the FROZEN state as described in `Lifecycle API for Web Pages + * `_. + * + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled after the freeze request is sent, or rejected + * in case the WebDriver command errors + */ + freeze: function(context=null) { + return window.test_driver_internal.freeze(); + }, + + /** + * Minimizes the browser window. + * + * Matches the the behaviour of the `Minimize + * `_ + * WebDriver command + * + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled with the previous {@link + * https://www.w3.org/TR/webdriver/#dfn-windowrect-object|WindowRect} + * value, after the window is minimized. + */ + minimize_window: function(context=null) { + return window.test_driver_internal.minimize_window(context); + }, + + /** + * Restore the window from minimized/maximized state to a given rect. + * + * Matches the behaviour of the `Set Window Rect + * `_ + * WebDriver command + * + * @param {Object} rect - A {@link + * https://www.w3.org/TR/webdriver/#dfn-windowrect-object|WindowRect} + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled after the window is restored to the given rect. + */ + set_window_rect: function(rect, context=null) { + return window.test_driver_internal.set_window_rect(rect, context); + }, + + /** + * Send a sequence of actions + * + * This function sends a sequence of actions to perform. + * + * Matches the behaviour of the `Actions + * `_ feature in + * WebDriver. + * + * Authors are encouraged to use the + * :js:class:`test_driver.Actions` builder rather than + * invoking this API directly. + * + * @param {Array} actions - an array of actions. The format is + * the same as the actions property + * of the `Perform Actions + * `_ + * WebDriver command. Each element is + * an object representing an input + * source and each input source + * itself has an actions property + * detailing the behaviour of that + * source at each timestep (or + * tick). Authors are not expected to + * construct the actions sequence by + * hand, but to use the builder api + * provided in testdriver-actions.js + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled after the actions are performed, or rejected in + * the cases the WebDriver command errors + */ + action_sequence: function(actions, context=null) { + return window.test_driver_internal.action_sequence(actions, context); + }, + + /** + * Generates a test report on the current page + * + * The generate_test_report function generates a report (to be + * observed by ReportingObserver) for testing purposes. + * + * Matches the `Generate Test Report + * `_ + * WebDriver command. + * + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled after the report is generated, or + * rejected if the report generation fails + */ + generate_test_report: function(message, context=null) { + return window.test_driver_internal.generate_test_report(message, context); + }, + + /** + * Sets the state of a permission + * + * This function simulates a user setting a permission into a + * particular state. + * + * Matches the `Set Permission + * `_ + * WebDriver command. + * + * @example + * await test_driver.set_permission({ name: "background-fetch" }, "denied"); + * await test_driver.set_permission({ name: "push", userVisibleOnly: true }, "granted", true); + * + * @param {Object} descriptor - a `PermissionDescriptor + * `_ + * object + * @param {String} state - the state of the permission + * @param {boolean} one_realm - Optional. Whether the permission applies to only one realm + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * @returns {Promise} fulfilled after the permission is set, or rejected if setting the + * permission fails + */ + set_permission: function(descriptor, state, one_realm=false, context=null) { + let permission_params = { + descriptor, + state, + oneRealm: one_realm, + }; + return window.test_driver_internal.set_permission(permission_params, context); + + }, + + /** + * Creates a virtual authenticator + * + * This function creates a virtual authenticator for use with + * the U2F and WebAuthn APIs. + * + * Matches the `Add Virtual Authenticator + * `_ + * WebDriver command. + * + * @param {Object} config - an `Authenticator Configuration + * `_ + * object + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled after the authenticator is added, or + * rejected in the cases the WebDriver command + * errors. Returns the ID of the authenticator + */ + add_virtual_authenticator: function(config, context=null) { + return window.test_driver_internal.add_virtual_authenticator(config, context); + }, + + /** + * Removes a virtual authenticator + * + * This function removes a virtual authenticator that has been + * created by :js:func:`add_virtual_authenticator`. + * + * Matches the `Remove Virtual Authenticator + * `_ + * WebDriver command. + * + * @param {String} authenticator_id - the ID of the authenticator to be + * removed. + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled after the authenticator is removed, or + * rejected in the cases the WebDriver command + * errors + */ + remove_virtual_authenticator: function(authenticator_id, context=null) { + return window.test_driver_internal.remove_virtual_authenticator(authenticator_id, context); + }, + + /** + * Adds a credential to a virtual authenticator + * + * Matches the `Add Credential + * `_ + * WebDriver command. + * + * @param {String} authenticator_id - the ID of the authenticator + * @param {Object} credential - A `Credential Parameters + * `_ + * object + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled after the credential is added, or + * rejected in the cases the WebDriver command + * errors + */ + add_credential: function(authenticator_id, credential, context=null) { + return window.test_driver_internal.add_credential(authenticator_id, credential, context); + }, + + /** + * Gets all the credentials stored in an authenticator + * + * This function retrieves all the credentials (added via the U2F API, + * WebAuthn, or the add_credential function) stored in a virtual + * authenticator + * + * Matches the `Get Credentials + * `_ + * WebDriver command. + * + * @param {String} authenticator_id - the ID of the authenticator + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled after the credentials are + * returned, or rejected in the cases the + * WebDriver command errors. Returns an + * array of `Credential Parameters + * `_ + */ + get_credentials: function(authenticator_id, context=null) { + return window.test_driver_internal.get_credentials(authenticator_id, context=null); + }, + + /** + * Remove a credential stored in an authenticator + * + * Matches the `Remove Credential + * `_ + * WebDriver command. + * + * @param {String} authenticator_id - the ID of the authenticator + * @param {String} credential_id - the ID of the credential + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled after the credential is removed, or + * rejected in the cases the WebDriver command + * errors. + */ + remove_credential: function(authenticator_id, credential_id, context=null) { + return window.test_driver_internal.remove_credential(authenticator_id, credential_id, context); + }, + + /** + * Removes all the credentials stored in a virtual authenticator + * + * Matches the `Remove All Credentials + * `_ + * WebDriver command. + * + * @param {String} authenticator_id - the ID of the authenticator + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} fulfilled after the credentials are removed, or + * rejected in the cases the WebDriver command + * errors. + */ + remove_all_credentials: function(authenticator_id, context=null) { + return window.test_driver_internal.remove_all_credentials(authenticator_id, context); + }, + + /** + * Sets the User Verified flag on an authenticator + * + * Sets whether requests requiring user verification will succeed or + * fail on a given virtual authenticator + * + * Matches the `Set User Verified + * `_ + * WebDriver command. + * + * @param {String} authenticator_id - the ID of the authenticator + * @param {boolean} uv - the User Verified flag + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + */ + set_user_verified: function(authenticator_id, uv, context=null) { + return window.test_driver_internal.set_user_verified(authenticator_id, uv, context); + }, + + /** + * Sets the storage access rule for an origin when embedded + * in a third-party context. + * + * Matches the `Set Storage Access + * `_ + * WebDriver command. + * + * @param {String} origin - A third-party origin to block or allow. + * May be "*" to indicate all origins. + * @param {String} embedding_origin - an embedding (first-party) origin + * on which {origin}'s access should + * be blocked or allowed. + * May be "*" to indicate all origins. + * @param {String} state - The storage access setting. + * Must be either "allowed" or "blocked". + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} Fulfilled after the storage access rule has been + * set, or rejected if setting the rule fails. + */ + set_storage_access: function(origin, embedding_origin, state, context=null) { + if (state !== "allowed" && state !== "blocked") { + throw new Error("storage access status must be 'allowed' or 'blocked'"); + } + const blocked = state === "blocked"; + return window.test_driver_internal.set_storage_access(origin, embedding_origin, blocked, context); + }, + + /** + * Sets the current transaction automation mode for Secure Payment + * Confirmation. + * + * This function places `Secure Payment + * Confirmation `_ into + * an automated 'autoaccept' or 'autoreject' mode, to allow testing + * without user interaction with the transaction UX prompt. + * + * Matches the `Set SPC Transaction Mode + * `_ + * WebDriver command. + * + * @example + * await test_driver.set_spc_transaction_mode("autoaccept"); + * test.add_cleanup(() => { + * return test_driver.set_spc_transaction_mode("none"); + * }); + * + * // Assumption: `request` is a PaymentRequest with a secure-payment-confirmation + * // payment method. + * const response = await request.show(); + * + * @param {String} mode - The `transaction mode + * `_ + * to set. Must be one of "``none``", + * "``autoaccept``", or + * "``autoreject``". + * @param {WindowProxy} context - Browsing context in which + * to run the call, or null for the current + * browsing context. + * + * @returns {Promise} Fulfilled after the transaction mode has been set, + * or rejected if setting the mode fails. + */ + set_spc_transaction_mode: function(mode, context=null) { + return window.test_driver_internal.set_spc_transaction_mode(mode, context); + }, + }; + + window.test_driver_internal = { + /** + * This flag should be set to `true` by any code which implements the + * internal methods defined below for automation purposes. Doing so + * allows the library to signal failure immediately when an automated + * implementation of one of the methods is not available. + */ + in_automation: false, + + click: function(element, coords) { + if (this.in_automation) { + return Promise.reject(new Error('Not implemented')); + } + + return new Promise(function(resolve, reject) { + element.addEventListener("click", resolve); + }); + }, + + delete_all_cookies: function(context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + send_keys: function(element, keys) { + if (this.in_automation) { + return Promise.reject(new Error('Not implemented')); + } + + return new Promise(function(resolve, reject) { + var seen = ""; + + function remove() { + element.removeEventListener("keydown", onKeyDown); + } + + function onKeyDown(event) { + if (event.key.length > 1) { + return; + } + + seen += event.key; + + if (keys.indexOf(seen) !== 0) { + reject(new Error("Unexpected key sequence: " + seen)); + remove(); + } else if (seen === keys) { + resolve(); + remove(); + } + } + + element.addEventListener("keydown", onKeyDown); + }); + }, + + freeze: function(context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + minimize_window: function(context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + set_window_rect: function(rect, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + action_sequence: function(actions, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + generate_test_report: function(message, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + set_permission: function(permission_params, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + add_virtual_authenticator: function(config, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + remove_virtual_authenticator: function(authenticator_id, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + add_credential: function(authenticator_id, credential, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + get_credentials: function(authenticator_id, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + remove_credential: function(authenticator_id, credential_id, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + remove_all_credentials: function(authenticator_id, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + set_user_verified: function(authenticator_id, uv, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + set_storage_access: function(origin, embedding_origin, blocked, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + set_spc_transaction_mode: function(mode, context=null) { + return Promise.reject(new Error("unimplemented")); + }, + + }; +})(); diff --git a/common/tct-mediacapture-w3c-tests/resources/testharness.js b/common/tct-mediacapture-w3c-tests/resources/testharness.js index ecc050c28..d35e297aa 100755 --- a/common/tct-mediacapture-w3c-tests/resources/testharness.js +++ b/common/tct-mediacapture-w3c-tests/resources/testharness.js @@ -1,20 +1,11 @@ /*global self*/ /*jshint latedef: nofunc*/ -/* -Distributed under both the W3C Test Suite License [1] and the W3C -3-clause BSD License [2]. To contribute to a W3C Test Suite, see the -policies and contribution forms [3]. -[1] http://www.w3.org/Consortium/Legal/2008/04-testsuite-license -[2] http://www.w3.org/Consortium/Legal/2008/03-bsd-license -[3] http://www.w3.org/2004/10/27-testcases -*/ +/* Documentation: https://web-platform-tests.org/writing-tests/testharness-api.html + * (../docs/_writing-tests/testharness-api.md) */ -/* Documentation is in docs/api.md */ - -(function () +(function (global_scope) { - var debug = false; // default timeout is 10 seconds, test can override if needed var settings = { output:true, @@ -22,7 +13,9 @@ policies and contribution forms [3]. "normal":10000, "long":60000 }, - test_timeout:null + test_timeout:null, + message_events: ["start", "test_state", "result", "completion"], + debug: false, }; var xhtml_ns = "http://www.w3.org/1999/xhtml"; @@ -46,9 +39,6 @@ policies and contribution forms [3]. * * // Should return the test harness timeout duration in milliseconds. * float test_timeout(); - * - * // Should return the global scope object. - * object global_scope(); * }; */ @@ -64,21 +54,76 @@ policies and contribution forms [3]. this.output_handler = null; this.all_loaded = false; var this_obj = this; + this.message_events = []; + this.dispatched_messages = []; + + this.message_functions = { + start: [add_start_callback, remove_start_callback, + function (properties) { + this_obj._dispatch("start_callback", [properties], + {type: "start", properties: properties}); + }], + + test_state: [add_test_state_callback, remove_test_state_callback, + function(test) { + this_obj._dispatch("test_state_callback", [test], + {type: "test_state", + test: test.structured_clone()}); + }], + result: [add_result_callback, remove_result_callback, + function (test) { + this_obj.output_handler.show_status(); + this_obj._dispatch("result_callback", [test], + {type: "result", + test: test.structured_clone()}); + }], + completion: [add_completion_callback, remove_completion_callback, + function (tests, harness_status, asserts) { + var cloned_tests = map(tests, function(test) { + return test.structured_clone(); + }); + this_obj._dispatch("completion_callback", [tests, harness_status], + {type: "complete", + tests: cloned_tests, + status: harness_status.structured_clone(), + asserts: asserts.map(assert => assert.structured_clone())}); + }] + } + on_event(window, 'load', function() { this_obj.all_loaded = true; }); + + on_event(window, 'message', function(event) { + if (event.data && event.data.type === "getmessages" && event.source) { + // A window can post "getmessages" to receive a duplicate of every + // message posted by this environment so far. This allows subscribers + // from fetch_tests_from_window to 'catch up' to the current state of + // this environment. + for (var i = 0; i < this_obj.dispatched_messages.length; ++i) + { + event.source.postMessage(this_obj.dispatched_messages[i], "*"); + } + } + }); } WindowTestEnvironment.prototype._dispatch = function(selector, callback_args, message_arg) { + this.dispatched_messages.push(message_arg); this._forEach_windows( - function(w, is_same_origin) { - if (is_same_origin && selector in w) { + function(w, same_origin) { + if (same_origin) { try { - w[selector].apply(undefined, callback_args); - } catch (e) { - if (debug) { - throw e; - } + var has_selector = selector in w; + } catch(e) { + // If document.domain was set at some point same_origin can be + // wrong and the above will fail. + has_selector = false; + } + if (has_selector) { + try { + w[selector].apply(undefined, callback_args); + } catch (e) {} } } if (supports_post_message(w) && w !== self) { @@ -88,9 +133,9 @@ policies and contribution forms [3]. }; WindowTestEnvironment.prototype._forEach_windows = function(callback) { - // Iterate of the the windows [self ... top, opener]. The callback is passed - // two objects, the first one is the windows object itself, the second one - // is a boolean indicating whether or not its on the same origin as the + // Iterate over the windows [self ... top, opener]. The callback is passed + // two objects, the first one is the window object itself, the second one + // is a boolean indicating whether or not it's on the same origin as the // current window. var cache = this.window_cache; if (!cache) { @@ -98,33 +143,14 @@ policies and contribution forms [3]. var w = self; var i = 0; var so; - var origins = location.ancestorOrigins; while (w != w.parent) { w = w.parent; - // In WebKit, calls to parent windows' properties that aren't on the same - // origin cause an error message to be displayed in the error console but - // don't throw an exception. This is a deviation from the current HTML5 - // spec. See: https://bugs.webkit.org/show_bug.cgi?id=43504 - // The problem with WebKit's behavior is that it pollutes the error console - // with error messages that can't be caught. - // - // This issue can be mitigated by relying on the (for now) proprietary - // `location.ancestorOrigins` property which returns an ordered list of - // the origins of enclosing windows. See: - // http://trac.webkit.org/changeset/113945. - if (origins) { - so = (location.origin == origins[i]); - } else { - so = is_same_origin(w); - } + so = is_same_origin(w); cache.push([w, so]); i++; } w = window.opener; if (w) { - // window.opener isn't included in the `location.ancestorOrigins` prop. - // We'll just have to deal with a simple check and an error msg on WebKit - // browsers in this case. cache.push([w, is_same_origin(w)]); } this.window_cache = cache; @@ -141,41 +167,50 @@ policies and contribution forms [3]. this.output_handler = output; var this_obj = this; + add_start_callback(function (properties) { this_obj.output_handler.init(properties); - this_obj._dispatch("start_callback", [properties], - { type: "start", properties: properties }); }); + add_test_state_callback(function(test) { this_obj.output_handler.show_status(); - this_obj._dispatch("test_state_callback", [test], - { type: "test_state", test: test.structured_clone() }); }); + add_result_callback(function (test) { this_obj.output_handler.show_status(); - this_obj._dispatch("result_callback", [test], - { type: "result", test: test.structured_clone() }); }); - add_completion_callback(function (tests, harness_status) { - this_obj.output_handler.show_results(tests, harness_status); - var cloned_tests = map(tests, function(test) { return test.structured_clone(); }); - this_obj._dispatch("completion_callback", [tests, harness_status], - { type: "complete", tests: cloned_tests, - status: harness_status.structured_clone() }); + + add_completion_callback(function (tests, harness_status, asserts_run) { + this_obj.output_handler.show_results(tests, harness_status, asserts_run); }); + this.setup_messages(settings.message_events); }; + WindowTestEnvironment.prototype.setup_messages = function(new_events) { + var this_obj = this; + forEach(settings.message_events, function(x) { + var current_dispatch = this_obj.message_events.indexOf(x) !== -1; + var new_dispatch = new_events.indexOf(x) !== -1; + if (!current_dispatch && new_dispatch) { + this_obj.message_functions[x][0](this_obj.message_functions[x][2]); + } else if (current_dispatch && !new_dispatch) { + this_obj.message_functions[x][1](this_obj.message_functions[x][2]); + } + }); + this.message_events = new_events; + } + WindowTestEnvironment.prototype.next_default_test_name = function() { - //Don't use document.title to work around an Opera bug in XHTML documents - var title = document.getElementsByTagName("title")[0]; - var prefix = (title && title.firstChild && title.firstChild.data) || "Untitled"; var suffix = this.name_counter > 0 ? " " + this.name_counter : ""; this.name_counter++; - return prefix + suffix; + return get_title() + suffix; }; WindowTestEnvironment.prototype.on_new_harness_properties = function(properties) { this.output_handler.setup(properties); + if (properties.hasOwnProperty("message_events")) { + this.setup_messages(properties.message_events); + } }; WindowTestEnvironment.prototype.add_on_loaded_callback = function(callback) { @@ -195,10 +230,6 @@ policies and contribution forms [3]. return settings.harness_timeout.normal; }; - WindowTestEnvironment.prototype.global_scope = function() { - return window; - }; - /* * Base TestEnvironment implementation for a generic web worker. * @@ -242,7 +273,7 @@ policies and contribution forms [3]. WorkerTestEnvironment.prototype.next_default_test_name = function() { var suffix = this.name_counter > 0 ? " " + this.name_counter : ""; this.name_counter++; - return "Untitled" + suffix; + return get_title() + suffix; }; WorkerTestEnvironment.prototype.on_new_harness_properties = function() {}; @@ -271,14 +302,15 @@ policies and contribution forms [3]. }); }); add_completion_callback( - function(tests, harness_status) { + function(tests, harness_status, asserts) { this_obj._dispatch({ type: "complete", tests: map(tests, function(test) { return test.structured_clone(); }), - status: harness_status.structured_clone() + status: harness_status.structured_clone(), + asserts: asserts.map(assert => assert.structured_clone()), }); }); }; @@ -291,10 +323,6 @@ policies and contribution forms [3]. return null; }; - WorkerTestEnvironment.prototype.global_scope = function() { - return self; - }; - /* * Dedicated web workers. * https://html.spec.whatwg.org/multipage/workers.html#dedicatedworkerglobalscope @@ -333,7 +361,7 @@ policies and contribution forms [3]. self.addEventListener("connect", function(message_event) { this_obj._add_message_port(message_event.source); - }); + }, false); } SharedWorkerTestEnvironment.prototype = Object.create(WorkerTestEnvironment.prototype); @@ -358,25 +386,34 @@ policies and contribution forms [3]. var this_obj = this; self.addEventListener("message", function(event) { - if (event.data.type && event.data.type === "connect") { - this_obj._add_message_port(event.ports[0]); - event.ports[0].start(); + if (event.data && event.data.type && event.data.type === "connect") { + this_obj._add_message_port(event.source); } - }); + }, false); // The oninstall event is received after the service worker script and // all imported scripts have been fetched and executed. It's the // equivalent of an onload event for a document. All tests should have // been added by the time this event is received, thus it's not - // necessary to wait until the onactivate event. - on_event(self, "install", - function(event) { - this_obj.all_loaded = true; - if (this_obj.on_loaded_callback) { - this_obj.on_loaded_callback(); - } - }); + // necessary to wait until the onactivate event. However, tests for + // installed service workers need another event which is equivalent to + // the onload event because oninstall is fired only on installation. The + // onmessage event is used for that purpose since tests using + // testharness.js should ask the result to its service worker by + // PostMessage. If the onmessage event is triggered on the service + // worker's context, that means the worker's script has been evaluated. + on_event(self, "install", on_all_loaded); + on_event(self, "message", on_all_loaded); + function on_all_loaded() { + if (this_obj.all_loaded) + return; + this_obj.all_loaded = true; + if (this_obj.on_loaded_callback) { + this_obj.on_loaded_callback(); + } + } } + ServiceWorkerTestEnvironment.prototype = Object.create(WorkerTestEnvironment.prototype); ServiceWorkerTestEnvironment.prototype.add_on_loaded_callback = function(callback) { @@ -387,45 +424,153 @@ policies and contribution forms [3]. } }; + /* + * JavaScript shells. + * + * This class is used as the test_environment when testharness is running + * inside a JavaScript shell. + */ + function ShellTestEnvironment() { + this.name_counter = 0; + this.all_loaded = false; + this.on_loaded_callback = null; + Promise.resolve().then(function() { + this.all_loaded = true + if (this.on_loaded_callback) { + this.on_loaded_callback(); + } + }.bind(this)); + this.message_list = []; + this.message_ports = []; + } + + ShellTestEnvironment.prototype.next_default_test_name = function() { + var suffix = this.name_counter > 0 ? " " + this.name_counter : ""; + this.name_counter++; + return "Untitled" + suffix; + }; + + ShellTestEnvironment.prototype.on_new_harness_properties = function() {}; + + ShellTestEnvironment.prototype.on_tests_ready = function() {}; + + ShellTestEnvironment.prototype.add_on_loaded_callback = function(callback) { + if (this.all_loaded) { + callback(); + } else { + this.on_loaded_callback = callback; + } + }; + + ShellTestEnvironment.prototype.test_timeout = function() { + // Tests running in a shell don't have a default timeout, so behave as + // if settings.explicit_timeout is true. + return null; + }; + function create_test_environment() { - if ('document' in self) { + if ('document' in global_scope) { return new WindowTestEnvironment(); } - if ('DedicatedWorkerGlobalScope' in self && - self instanceof DedicatedWorkerGlobalScope) { + if ('DedicatedWorkerGlobalScope' in global_scope && + global_scope instanceof DedicatedWorkerGlobalScope) { return new DedicatedWorkerTestEnvironment(); } - if ('SharedWorkerGlobalScope' in self && - self instanceof SharedWorkerGlobalScope) { + if ('SharedWorkerGlobalScope' in global_scope && + global_scope instanceof SharedWorkerGlobalScope) { return new SharedWorkerTestEnvironment(); } - if ('ServiceWorkerGlobalScope' in self && - self instanceof ServiceWorkerGlobalScope) { + if ('ServiceWorkerGlobalScope' in global_scope && + global_scope instanceof ServiceWorkerGlobalScope) { return new ServiceWorkerTestEnvironment(); } - throw new Error("Unsupported test environment"); + if ('WorkerGlobalScope' in global_scope && + global_scope instanceof WorkerGlobalScope) { + return new DedicatedWorkerTestEnvironment(); + } + + return new ShellTestEnvironment(); } var test_environment = create_test_environment(); function is_shared_worker(worker) { - return 'SharedWorker' in self && worker instanceof SharedWorker; + return 'SharedWorker' in global_scope && worker instanceof SharedWorker; } function is_service_worker(worker) { - return 'ServiceWorker' in self && worker instanceof ServiceWorker; + // The worker object may be from another execution context, + // so do not use instanceof here. + return 'ServiceWorker' in global_scope && + Object.prototype.toString.call(worker) == '[object ServiceWorker]'; + } + + var seen_func_name = Object.create(null); + + function get_test_name(func, name) + { + if (name) { + return name; + } + + if (func) { + var func_code = func.toString(); + + // Try and match with brackets, but fallback to matching without + var arrow = func_code.match(/^\(\)\s*=>\s*(?:{(.*)}\s*|(.*))$/); + + // Check for JS line separators + if (arrow !== null && !/[\u000A\u000D\u2028\u2029]/.test(func_code)) { + var trimmed = (arrow[1] !== undefined ? arrow[1] : arrow[2]).trim(); + // drop trailing ; if there's no earlier ones + trimmed = trimmed.replace(/^([^;]*)(;\s*)+$/, "$1"); + + if (trimmed) { + let name = trimmed; + if (seen_func_name[trimmed]) { + // This subtest name already exists, so add a suffix. + name += " " + seen_func_name[trimmed]; + } else { + seen_func_name[trimmed] = 0; + } + seen_func_name[trimmed] += 1; + return name; + } + } + } + + return test_environment.next_default_test_name(); } /* * API functions */ - function test(func, name, properties) { - var test_name = name ? name : test_environment.next_default_test_name(); - properties = properties ? properties : {}; + if (tests.promise_setup_called) { + tests.status.status = tests.status.ERROR; + tests.status.message = '`test` invoked after `promise_setup`'; + tests.complete(); + } + var test_name = get_test_name(func, name); var test_obj = new Test(test_name, properties); - test_obj.step(func, test_obj, test_obj); + var value = test_obj.step(func, test_obj, test_obj); + + if (value !== undefined) { + var msg = 'Test named "' + test_name + + '" passed a function to `test` that returned a value.'; + + try { + if (value && typeof value.then === 'function') { + msg += ' Consider using `promise_test` instead when ' + + 'using Promises or async/await.'; + } + } catch (err) {} + + tests.status.status = tests.status.ERROR; + tests.status.message = msg; + } + if (test_obj.phase === test_obj.phases.STARTED) { test_obj.done(); } @@ -433,37 +578,305 @@ policies and contribution forms [3]. function async_test(func, name, properties) { + if (tests.promise_setup_called) { + tests.status.status = tests.status.ERROR; + tests.status.message = '`async_test` invoked after `promise_setup`'; + tests.complete(); + } if (typeof func !== "function") { properties = name; name = func; func = null; } - var test_name = name ? name : test_environment.next_default_test_name(); - properties = properties ? properties : {}; + var test_name = get_test_name(func, name); var test_obj = new Test(test_name, properties); if (func) { - test_obj.step(func, test_obj, test_obj); + var value = test_obj.step(func, test_obj, test_obj); + + // Test authors sometimes return values to async_test, expecting us + // to handle the value somehow. Make doing so a harness error to be + // clear this is invalid, and point authors to promise_test if it + // may be appropriate. + // + // Note that we only perform this check on the initial function + // passed to async_test, not on any later steps - we haven't seen a + // consistent problem with those (and it's harder to check). + if (value !== undefined) { + var msg = 'Test named "' + test_name + + '" passed a function to `async_test` that returned a value.'; + + try { + if (value && typeof value.then === 'function') { + msg += ' Consider using `promise_test` instead when ' + + 'using Promises or async/await.'; + } + } catch (err) {} + + tests.set_status(tests.status.ERROR, msg); + tests.complete(); + } } return test_obj; } function promise_test(func, name, properties) { - var test = async_test(name, properties); - Promise.resolve(test.step(func, test, test)) - .then( - function() { - test.done(); - }) - .catch(test.step_func( - function(value) { - if (value instanceof AssertionError) { - throw value; - } - assert(false, "promise_test", null, - "Unhandled rejection with value: ${value}", {value:value}); - })); + if (typeof func !== "function") { + properties = name; + name = func; + func = null; + } + var test_name = get_test_name(func, name); + var test = new Test(test_name, properties); + test._is_promise_test = true; + + // If there is no promise tests queue make one. + if (!tests.promise_tests) { + tests.promise_tests = Promise.resolve(); + } + tests.promise_tests = tests.promise_tests.then(function() { + return new Promise(function(resolve) { + var promise = test.step(func, test, test); + + test.step(function() { + assert(!!promise, "promise_test", null, + "test body must return a 'thenable' object (received ${value})", + {value:promise}); + assert(typeof promise.then === "function", "promise_test", null, + "test body must return a 'thenable' object (received an object with no `then` method)", + null); + }); + + // Test authors may use the `step` method within a + // `promise_test` even though this reflects a mixture of + // asynchronous control flow paradigms. The "done" callback + // should be registered prior to the resolution of the + // user-provided Promise to avoid timeouts in cases where the + // Promise does not settle but a `step` function has thrown an + // error. + add_test_done_callback(test, resolve); + + Promise.resolve(promise) + .catch(test.step_func( + function(value) { + if (value instanceof AssertionError) { + throw value; + } + assert(false, "promise_test", null, + "Unhandled rejection with value: ${value}", {value:value}); + })) + .then(function() { + test.done(); + }); + }); + }); } + /** + * Make a copy of a Promise in the current realm. + * + * @param {Promise} promise the given promise that may be from a different + * realm + * @returns {Promise} + * + * An arbitrary promise provided by the caller may have originated in + * another frame that have since navigated away, rendering the frame's + * document inactive. Such a promise cannot be used with `await` or + * Promise.resolve(), as microtasks associated with it may be prevented + * from being run. See https://github.com/whatwg/html/issues/5319 for a + * particular case. + * + * In functions we define here, there is an expectation from the caller + * that the promise is from the current realm, that can always be used with + * `await`, etc. We therefore create a new promise in this realm that + * inherit the value and status from the given promise. + */ + + function bring_promise_to_current_realm(promise) { + return new Promise(promise.then.bind(promise)); + } + + function promise_rejects_js(test, constructor, promise, description) { + return bring_promise_to_current_realm(promise) + .then(test.unreached_func("Should have rejected: " + description)) + .catch(function(e) { + assert_throws_js_impl(constructor, function() { throw e }, + description, "promise_rejects_js"); + }); + } + + /** + * Assert that a Promise is rejected with the right DOMException. + * + * @param test the test argument passed to promise_test + * @param {number|string} type. See documentation for assert_throws_dom. + * + * For the remaining arguments, there are two ways of calling + * promise_rejects_dom: + * + * 1) If the DOMException is expected to come from the current global, the + * third argument should be the promise expected to reject, and a fourth, + * optional, argument is the assertion description. + * + * 2) If the DOMException is expected to come from some other global, the + * third argument should be the DOMException constructor from that global, + * the fourth argument the promise expected to reject, and the fifth, + * optional, argument the assertion description. + */ + + function promise_rejects_dom(test, type, promiseOrConstructor, descriptionOrPromise, maybeDescription) { + let constructor, promise, description; + if (typeof promiseOrConstructor === "function" && + promiseOrConstructor.name === "DOMException") { + constructor = promiseOrConstructor; + promise = descriptionOrPromise; + description = maybeDescription; + } else { + constructor = self.DOMException; + promise = promiseOrConstructor; + description = descriptionOrPromise; + assert(maybeDescription === undefined, + "Too many args pased to no-constructor version of promise_rejects_dom"); + } + return bring_promise_to_current_realm(promise) + .then(test.unreached_func("Should have rejected: " + description)) + .catch(function(e) { + assert_throws_dom_impl(type, function() { throw e }, description, + "promise_rejects_dom", constructor); + }); + } + + function promise_rejects_exactly(test, exception, promise, description) { + return bring_promise_to_current_realm(promise) + .then(test.unreached_func("Should have rejected: " + description)) + .catch(function(e) { + assert_throws_exactly_impl(exception, function() { throw e }, + description, "promise_rejects_exactly"); + }); + } + + /** + * This constructor helper allows DOM events to be handled using Promises, + * which can make it a lot easier to test a very specific series of events, + * including ensuring that unexpected events are not fired at any point. + */ + function EventWatcher(test, watchedNode, eventTypes, timeoutPromise) + { + if (typeof eventTypes == 'string') { + eventTypes = [eventTypes]; + } + + var waitingFor = null; + + // This is null unless we are recording all events, in which case it + // will be an Array object. + var recordedEvents = null; + + var eventHandler = test.step_func(function(evt) { + assert_true(!!waitingFor, + 'Not expecting event, but got ' + evt.type + ' event'); + assert_equals(evt.type, waitingFor.types[0], + 'Expected ' + waitingFor.types[0] + ' event, but got ' + + evt.type + ' event instead'); + + if (Array.isArray(recordedEvents)) { + recordedEvents.push(evt); + } + + if (waitingFor.types.length > 1) { + // Pop first event from array + waitingFor.types.shift(); + return; + } + // We need to null out waitingFor before calling the resolve function + // since the Promise's resolve handlers may call wait_for() which will + // need to set waitingFor. + var resolveFunc = waitingFor.resolve; + waitingFor = null; + // Likewise, we should reset the state of recordedEvents. + var result = recordedEvents || evt; + recordedEvents = null; + resolveFunc(result); + }); + + for (var i = 0; i < eventTypes.length; i++) { + watchedNode.addEventListener(eventTypes[i], eventHandler, false); + } + + /** + * Returns a Promise that will resolve after the specified event or + * series of events has occurred. + * + * @param options An optional options object. If the 'record' property + * on this object has the value 'all', when the Promise + * returned by this function is resolved, *all* Event + * objects that were waited for will be returned as an + * array. + * + * For example, + * + * ```js + * const watcher = new EventWatcher(t, div, [ 'animationstart', + * 'animationiteration', + * 'animationend' ]); + * return watcher.wait_for([ 'animationstart', 'animationend' ], + * { record: 'all' }).then(evts => { + * assert_equals(evts[0].elapsedTime, 0.0); + * assert_equals(evts[1].elapsedTime, 2.0); + * }); + * ``` + */ + this.wait_for = function(types, options) { + if (waitingFor) { + return Promise.reject('Already waiting for an event or events'); + } + if (typeof types == 'string') { + types = [types]; + } + if (options && options.record && options.record === 'all') { + recordedEvents = []; + } + return new Promise(function(resolve, reject) { + var timeout = test.step_func(function() { + // If the timeout fires after the events have been received + // or during a subsequent call to wait_for, ignore it. + if (!waitingFor || waitingFor.resolve !== resolve) + return; + + // This should always fail, otherwise we should have + // resolved the promise. + assert_true(waitingFor.types.length == 0, + 'Timed out waiting for ' + waitingFor.types.join(', ')); + var result = recordedEvents; + recordedEvents = null; + var resolveFunc = waitingFor.resolve; + waitingFor = null; + resolveFunc(result); + }); + + if (timeoutPromise) { + timeoutPromise().then(timeout); + } + + waitingFor = { + types: types, + resolve: resolve, + reject: reject + }; + }); + }; + + function stop_watching() { + for (var i = 0; i < eventTypes.length; i++) { + watchedNode.removeEventListener(eventTypes[i], eventHandler, false); + } + }; + + test._add_cleanup(stop_watching); + + return this; + } + expose(EventWatcher, 'EventWatcher'); + function setup(func_or_properties, maybe_properties) { var func = null; @@ -480,11 +893,60 @@ policies and contribution forms [3]. test_environment.on_new_harness_properties(properties); } + function promise_setup(func, maybe_properties) + { + if (typeof func !== "function") { + tests.set_status(tests.status.ERROR, + "promise_test invoked without a function"); + tests.complete(); + return; + } + tests.promise_setup_called = true; + + if (!tests.promise_tests) { + tests.promise_tests = Promise.resolve(); + } + + tests.promise_tests = tests.promise_tests + .then(function() + { + var properties = maybe_properties || {}; + var result; + + tests.setup(null, properties); + result = func(); + test_environment.on_new_harness_properties(properties); + + if (!result || typeof result.then !== "function") { + throw "Non-thenable returned by function passed to `promise_setup`"; + } + return result; + }) + .catch(function(e) + { + tests.set_status(tests.status.ERROR, + String(e), + e && e.stack); + tests.complete(); + }); + } + function done() { if (tests.tests.length === 0) { - tests.set_file_is_test(); + // `done` is invoked after handling uncaught exceptions, so if the + // harness status is already set, the corresponding message is more + // descriptive than the generic message defined here. + if (tests.status.status === null) { + tests.status.status = tests.status.ERROR; + tests.status.message = "done() was called without first defining any tests"; + } + + tests.complete(); + return; } if (tests.file_is_test) { + // file is test files never have asynchronous cleanup logic, + // meaning the fully-synchronous `done` function can be used here. tests.tests[0].done(); } tests.end_wait(); @@ -503,18 +965,37 @@ policies and contribution forms [3]. }); } + /* + * Register a function as a DOM event listener to the given object for the + * event bubbling phase. + * + * This function was deprecated in November of 2019. + */ function on_event(object, event, callback) { object.addEventListener(event, callback, false); } + function step_timeout(f, t) { + var outer_this = this; + var args = Array.prototype.slice.call(arguments, 2); + return setTimeout(function() { + f.apply(outer_this, args); + }, t * tests.timeout_multiplier); + } + expose(test, 'test'); expose(async_test, 'async_test'); expose(promise_test, 'promise_test'); + expose(promise_rejects_js, 'promise_rejects_js'); + expose(promise_rejects_dom, 'promise_rejects_dom'); + expose(promise_rejects_exactly, 'promise_rejects_exactly'); expose(generate_tests, 'generate_tests'); expose(setup, 'setup'); + expose(promise_setup, 'promise_setup'); expose(done, 'done'); expose(on_event, 'on_event'); + expose(step_timeout, 'step_timeout'); /* * Return a string truncated to the given length, with ... added at the end @@ -537,10 +1018,17 @@ policies and contribution forms [3]. // instanceof doesn't work if the node is from another window (like an // iframe's contentWindow): // http://www.w3.org/Bugs/Public/show_bug.cgi?id=12295 - if ("nodeType" in object && - "nodeName" in object && - "nodeValue" in object && - "childNodes" in object) { + try { + var has_node_properties = ("nodeType" in object && + "nodeName" in object && + "nodeValue" in object && + "childNodes" in object); + } catch (e) { + // We're probably cross-origin, which means we aren't a node + return false; + } + + if (has_node_properties) { try { object.nodeType; } catch (e) { @@ -553,6 +1041,44 @@ policies and contribution forms [3]. return false; } + var replacements = { + "0": "0", + "1": "x01", + "2": "x02", + "3": "x03", + "4": "x04", + "5": "x05", + "6": "x06", + "7": "x07", + "8": "b", + "9": "t", + "10": "n", + "11": "v", + "12": "f", + "13": "r", + "14": "x0e", + "15": "x0f", + "16": "x10", + "17": "x11", + "18": "x12", + "19": "x13", + "20": "x14", + "21": "x15", + "22": "x16", + "23": "x17", + "24": "x18", + "25": "x19", + "26": "x1a", + "27": "x1b", + "28": "x1c", + "29": "x1d", + "30": "x1e", + "31": "x1f", + "0xfffd": "ufffd", + "0xfffe": "ufffe", + "0xffff": "uffff", + }; + /* * Convert a value to a nice, human-readable string */ @@ -568,49 +1094,23 @@ policies and contribution forms [3]. seen.push(val); } if (Array.isArray(val)) { - return "[" + val.map(function(x) {return format_value(x, seen);}).join(", ") + "]"; + let output = "["; + if (val.beginEllipsis !== undefined) { + output += "…, "; + } + output += val.map(function(x) {return format_value(x, seen);}).join(", "); + if (val.endEllipsis !== undefined) { + output += ", …"; + } + return output + "]"; } switch (typeof val) { case "string": - val = val.replace("\\", "\\\\"); - for (var i = 0; i < 32; i++) { - var replace = "\\"; - switch (i) { - case 0: replace += "0"; break; - case 1: replace += "x01"; break; - case 2: replace += "x02"; break; - case 3: replace += "x03"; break; - case 4: replace += "x04"; break; - case 5: replace += "x05"; break; - case 6: replace += "x06"; break; - case 7: replace += "x07"; break; - case 8: replace += "b"; break; - case 9: replace += "t"; break; - case 10: replace += "n"; break; - case 11: replace += "v"; break; - case 12: replace += "f"; break; - case 13: replace += "r"; break; - case 14: replace += "x0e"; break; - case 15: replace += "x0f"; break; - case 16: replace += "x10"; break; - case 17: replace += "x11"; break; - case 18: replace += "x12"; break; - case 19: replace += "x13"; break; - case 20: replace += "x14"; break; - case 21: replace += "x15"; break; - case 22: replace += "x16"; break; - case 23: replace += "x17"; break; - case 24: replace += "x18"; break; - case 25: replace += "x19"; break; - case 26: replace += "x1a"; break; - case 27: replace += "x1b"; break; - case 28: replace += "x1c"; break; - case 29: replace += "x1d"; break; - case 30: replace += "x1e"; break; - case 31: replace += "x1f"; break; - } - val = val.replace(RegExp(String.fromCharCode(i), "g"), replace); + val = val.replace(/\\/g, "\\\\"); + for (var p in replacements) { + var replace = "\\" + replacements[p]; + val = val.replace(RegExp(String.fromCharCode(p), "g"), replace); } return '"' + val.replace(/"/g, '\\"') + '"'; case "boolean": @@ -658,7 +1158,12 @@ policies and contribution forms [3]. /* falls through */ default: - return typeof val + ' "' + truncate(String(val), 60) + '"'; + try { + return typeof val + ' "' + truncate(String(val), 1000) + '"'; + } catch(e) { + return ("[stringifying object threw " + String(e) + + " with type " + String(typeof e) + "]"); + } } } expose(format_value, "format_value"); @@ -667,19 +1172,53 @@ policies and contribution forms [3]. * Assertions */ + function expose_assert(f, name) { + function assert_wrapper(...args) { + let status = Test.statuses.TIMEOUT; + let stack = null; + try { + if (settings.debug) { + console.debug("ASSERT", name, tests.current_test && tests.current_test.name, args); + } + if (tests.output) { + tests.set_assert(name, args); + } + const rv = f.apply(undefined, args); + status = Test.statuses.PASS; + return rv; + } catch(e) { + if (e instanceof AssertionError) { + status = Test.statuses.FAIL; + stack = e.stack; + } else { + status = Test.statuses.ERROR; + } + throw e; + } finally { + if (tests.output && !stack) { + stack = get_stack(); + } + if (tests.output) { + tests.set_assert_status(status, stack); + } + } + } + expose(assert_wrapper, name); + } + function assert_true(actual, description) { assert(actual === true, "assert_true", description, "expected true got ${actual}", {actual:actual}); } - expose(assert_true, "assert_true"); + expose_assert(assert_true, "assert_true"); function assert_false(actual, description) { assert(actual === false, "assert_false", description, "expected false got ${actual}", {actual:actual}); } - expose(assert_false, "assert_false"); + expose_assert(assert_false, "assert_false"); function same_value(x, y) { if (y !== y) { @@ -709,7 +1248,7 @@ policies and contribution forms [3]. "expected ${expected} but got ${actual}", {expected:expected, actual:actual}); } - expose(assert_equals, "assert_equals"); + expose_assert(assert_equals, "assert_equals"); function assert_not_equals(actual, expected, description) { @@ -721,7 +1260,7 @@ policies and contribution forms [3]. "got disallowed value ${actual}", {actual:actual}); } - expose(assert_not_equals, "assert_not_equals"); + expose_assert(assert_not_equals, "assert_not_equals"); function assert_in_array(actual, expected, description) { @@ -729,10 +1268,15 @@ policies and contribution forms [3]. "value ${actual} not in array ${expected}", {actual:actual, expected:expected}); } - expose(assert_in_array, "assert_in_array"); + expose_assert(assert_in_array, "assert_in_array"); + // This function was deprecated in July of 2015. + // See https://github.com/web-platform-tests/wpt/issues/2033 function assert_object_equals(actual, expected, description) { + assert(typeof actual === "object" && actual !== null, "assert_object_equals", description, + "value is ${actual}, expected object", + {actual: actual}); //This needs to be improved a great deal function check_equal(actual, expected, stack) { @@ -750,7 +1294,7 @@ policies and contribution forms [3]. } else { assert(same_value(actual[p], expected[p]), "assert_object_equals", description, "property ${p} expected ${expected} got ${actual}", - {p:p, expected:expected, actual:actual}); + {p:p, expected:expected[p], actual:actual[p]}); } } for (p in expected) { @@ -762,45 +1306,115 @@ policies and contribution forms [3]. } check_equal(actual, expected, []); } - expose(assert_object_equals, "assert_object_equals"); + expose_assert(assert_object_equals, "assert_object_equals"); function assert_array_equals(actual, expected, description) { + const max_array_length = 20; + function shorten_array(arr, offset = 0) { + // Make ", …" only show up when it would likely reduce the length, not accounting for + // fonts. + if (arr.length < max_array_length + 2) { + return arr; + } + // By default we want half the elements after the offset and half before + // But if that takes us past the end of the array, we have more before, and + // if it takes us before the start we have more after. + const length_after_offset = Math.floor(max_array_length / 2); + let upper_bound = Math.min(length_after_offset + offset, arr.length); + const lower_bound = Math.max(upper_bound - max_array_length, 0); + + if (lower_bound === 0) { + upper_bound = max_array_length; + } + + const output = arr.slice(lower_bound, upper_bound); + if (lower_bound > 0) { + output.beginEllipsis = true; + } + if (upper_bound < arr.length) { + output.endEllipsis = true; + } + return output; + } + + assert(typeof actual === "object" && actual !== null && "length" in actual, + "assert_array_equals", description, + "value is ${actual}, expected array", + {actual:actual}); assert(actual.length === expected.length, "assert_array_equals", description, - "lengths differ, expected ${expected} got ${actual}", - {expected:expected.length, actual:actual.length}); + "lengths differ, expected array ${expected} length ${expectedLength}, got ${actual} length ${actualLength}", + {expected:shorten_array(expected, expected.length - 1), expectedLength:expected.length, + actual:shorten_array(actual, actual.length - 1), actualLength:actual.length + }); for (var i = 0; i < actual.length; i++) { assert(actual.hasOwnProperty(i) === expected.hasOwnProperty(i), "assert_array_equals", description, - "property ${i}, property expected to be ${expected} but was ${actual}", + "expected property ${i} to be ${expected} but was ${actual} (expected array ${arrayExpected} got ${arrayActual})", {i:i, expected:expected.hasOwnProperty(i) ? "present" : "missing", - actual:actual.hasOwnProperty(i) ? "present" : "missing"}); + actual:actual.hasOwnProperty(i) ? "present" : "missing", + arrayExpected:shorten_array(expected, i), arrayActual:shorten_array(actual, i)}); assert(same_value(expected[i], actual[i]), "assert_array_equals", description, - "property ${i}, expected ${expected} but got ${actual}", - {i:i, expected:expected[i], actual:actual[i]}); + "expected property ${i} to be ${expected} but got ${actual} (expected array ${arrayExpected} got ${arrayActual})", + {i:i, expected:expected[i], actual:actual[i], + arrayExpected:shorten_array(expected, i), arrayActual:shorten_array(actual, i)}); } } - expose(assert_array_equals, "assert_array_equals"); + expose_assert(assert_array_equals, "assert_array_equals"); + + function assert_array_approx_equals(actual, expected, epsilon, description) + { + /* + * Test if two primitive arrays are equal within +/- epsilon + */ + assert(actual.length === expected.length, + "assert_array_approx_equals", description, + "lengths differ, expected ${expected} got ${actual}", + {expected:expected.length, actual:actual.length}); + + for (var i = 0; i < actual.length; i++) { + assert(actual.hasOwnProperty(i) === expected.hasOwnProperty(i), + "assert_array_approx_equals", description, + "property ${i}, property expected to be ${expected} but was ${actual}", + {i:i, expected:expected.hasOwnProperty(i) ? "present" : "missing", + actual:actual.hasOwnProperty(i) ? "present" : "missing"}); + assert(typeof actual[i] === "number", + "assert_array_approx_equals", description, + "property ${i}, expected a number but got a ${type_actual}", + {i:i, type_actual:typeof actual[i]}); + assert(Math.abs(actual[i] - expected[i]) <= epsilon, + "assert_array_approx_equals", description, + "property ${i}, expected ${expected} +/- ${epsilon}, expected ${expected} but got ${actual}", + {i:i, expected:expected[i], actual:actual[i], epsilon:epsilon}); + } + } + expose_assert(assert_array_approx_equals, "assert_array_approx_equals"); function assert_approx_equals(actual, expected, epsilon, description) { /* - * Test if two primitive numbers are equal withing +/- epsilon + * Test if two primitive numbers are equal within +/- epsilon */ assert(typeof actual === "number", "assert_approx_equals", description, "expected a number but got a ${type_actual}", {type_actual:typeof actual}); - assert(Math.abs(actual - expected) <= epsilon, - "assert_approx_equals", description, - "expected ${expected} +/- ${epsilon} but got ${actual}", - {expected:expected, actual:actual, epsilon:epsilon}); + // The epsilon math below does not place nice with NaN and Infinity + // But in this case Infinity = Infinity and NaN = NaN + if (isFinite(actual) || isFinite(expected)) { + assert(Math.abs(actual - expected) <= epsilon, + "assert_approx_equals", description, + "expected ${expected} +/- ${epsilon} but got ${actual}", + {expected:expected, actual:actual, epsilon:epsilon}); + } else { + assert_equals(actual, expected); + } } - expose(assert_approx_equals, "assert_approx_equals"); + expose_assert(assert_approx_equals, "assert_approx_equals"); function assert_less_than(actual, expected, description) { @@ -817,7 +1431,7 @@ policies and contribution forms [3]. "expected a number less than ${expected} but got ${actual}", {expected:expected, actual:actual}); } - expose(assert_less_than, "assert_less_than"); + expose_assert(assert_less_than, "assert_less_than"); function assert_greater_than(actual, expected, description) { @@ -834,7 +1448,25 @@ policies and contribution forms [3]. "expected a number greater than ${expected} but got ${actual}", {expected:expected, actual:actual}); } - expose(assert_greater_than, "assert_greater_than"); + expose_assert(assert_greater_than, "assert_greater_than"); + + function assert_between_exclusive(actual, lower, upper, description) + { + /* + * Test if a primitive number is between two others + */ + assert(typeof actual === "number", + "assert_between_exclusive", description, + "expected a number but got a ${type_actual}", + {type_actual:typeof actual}); + + assert(actual > lower && actual < upper, + "assert_between_exclusive", description, + "expected a number greater than ${lower} " + + "and less than ${upper} but got ${actual}", + {lower:lower, upper:upper, actual:actual}); + } + expose_assert(assert_between_exclusive, "assert_between_exclusive"); function assert_less_than_equal(actual, expected, description) { @@ -847,11 +1479,11 @@ policies and contribution forms [3]. {type_actual:typeof actual}); assert(actual <= expected, - "assert_less_than", description, + "assert_less_than_equal", description, "expected a number less than or equal to ${expected} but got ${actual}", {expected:expected, actual:actual}); } - expose(assert_less_than_equal, "assert_less_than_equal"); + expose_assert(assert_less_than_equal, "assert_less_than_equal"); function assert_greater_than_equal(actual, expected, description) { @@ -868,7 +1500,25 @@ policies and contribution forms [3]. "expected a number greater than or equal to ${expected} but got ${actual}", {expected:expected, actual:actual}); } - expose(assert_greater_than_equal, "assert_greater_than_equal"); + expose_assert(assert_greater_than_equal, "assert_greater_than_equal"); + + function assert_between_inclusive(actual, lower, upper, description) + { + /* + * Test if a primitive number is between to two others or equal to either of them + */ + assert(typeof actual === "number", + "assert_between_inclusive", description, + "expected a number but got a ${type_actual}", + {type_actual:typeof actual}); + + assert(actual >= lower && actual <= upper, + "assert_between_inclusive", description, + "expected a number greater than or equal to ${lower} " + + "and less than or equal to ${upper} but got ${actual}", + {lower:lower, upper:upper, actual:actual}); + } + expose_assert(assert_between_inclusive, "assert_between_inclusive"); function assert_regexp_match(actual, expected, description) { /* @@ -879,38 +1529,38 @@ policies and contribution forms [3]. "expected ${expected} but got ${actual}", {expected:expected, actual:actual}); } - expose(assert_regexp_match, "assert_regexp_match"); + expose_assert(assert_regexp_match, "assert_regexp_match"); function assert_class_string(object, class_string, description) { - assert_equals({}.toString.call(object), "[object " + class_string + "]", - description); + var actual = {}.toString.call(object); + var expected = "[object " + class_string + "]"; + assert(same_value(actual, expected), "assert_class_string", description, + "expected ${expected} but got ${actual}", + {expected:expected, actual:actual}); } - expose(assert_class_string, "assert_class_string"); + expose_assert(assert_class_string, "assert_class_string"); - - function _assert_own_property(name) { - return function(object, property_name, description) - { - assert(property_name in object, - name, description, - "expected property ${p} missing", {p:property_name}); - }; + function assert_own_property(object, property_name, description) { + assert(object.hasOwnProperty(property_name), + "assert_own_property", description, + "expected property ${p} missing", {p:property_name}); } - expose(_assert_own_property("assert_exists"), "assert_exists"); - expose(_assert_own_property("assert_own_property"), "assert_own_property"); + expose_assert(assert_own_property, "assert_own_property"); - function assert_not_exists(object, property_name, description) - { + function assert_not_own_property(object, property_name, description) { assert(!object.hasOwnProperty(property_name), - "assert_not_exists", description, - "unexpected property ${p} found", {p:property_name}); + "assert_not_own_property", description, + "unexpected property ${p} is found on object", {p:property_name}); } - expose(assert_not_exists, "assert_not_exists"); + expose_assert(assert_not_own_property, "assert_not_own_property"); function _assert_inherits(name) { return function (object, property_name, description) { - assert(typeof object === "object", + assert((typeof object === "object" && object !== null) || + typeof object === "function" || + // Or has [[IsHTMLDDA]] slot + String(object) === "[object HTMLAllCollection]", name, description, "provided value is not an object"); @@ -929,8 +1579,8 @@ policies and contribution forms [3]. {p:property_name}); }; } - expose(_assert_inherits("assert_inherits"), "assert_inherits"); - expose(_assert_inherits("assert_idl_attribute"), "assert_idl_attribute"); + expose_assert(_assert_inherits("assert_inherits"), "assert_inherits"); + expose_assert(_assert_inherits("assert_idl_attribute"), "assert_idl_attribute"); function assert_readonly(object, property_name, description) { @@ -947,32 +1597,156 @@ policies and contribution forms [3]. object[property_name] = initial_value; } } - expose(assert_readonly, "assert_readonly"); + expose_assert(assert_readonly, "assert_readonly"); + + /** + * Assert a JS Error with the expected constructor is thrown. + * + * @param {object} constructor The expected exception constructor. + * @param {Function} func Function which should throw. + * @param {string} description Error description for the case that the error is not thrown. + */ + function assert_throws_js(constructor, func, description) + { + assert_throws_js_impl(constructor, func, description, + "assert_throws_js"); + } + expose_assert(assert_throws_js, "assert_throws_js"); - function assert_throws(code, func, description) + /** + * Like assert_throws_js but allows specifying the assertion type + * (assert_throws_js or promise_rejects_js, in practice). + */ + function assert_throws_js_impl(constructor, func, description, + assertion_type) { try { func.call(this); - assert(false, "assert_throws", description, + assert(false, assertion_type, description, "${func} did not throw", {func:func}); } catch (e) { if (e instanceof AssertionError) { throw e; } - if (code === null) { - return; + + // Basic sanity-checks on the thrown exception. + assert(typeof e === "object", + assertion_type, description, + "${func} threw ${e} with type ${type}, not an object", + {func:func, e:e, type:typeof e}); + + assert(e !== null, + assertion_type, description, + "${func} threw null, not an object", + {func:func}); + + // Basic sanity-check on the passed-in constructor + assert(typeof constructor == "function", + assertion_type, description, + "${constructor} is not a constructor", + {constructor:constructor}); + var obj = constructor; + while (obj) { + if (typeof obj === "function" && + obj.name === "Error") { + break; + } + obj = Object.getPrototypeOf(obj); } - if (typeof code === "object") { - assert(typeof e == "object" && "name" in e && e.name == code.name, - "assert_throws", description, - "${func} threw ${actual} (${actual_name}) expected ${expected} (${expected_name})", - {func:func, actual:e, actual_name:e.name, - expected:code, - expected_name:code.name}); - return; + assert(obj != null, + assertion_type, description, + "${constructor} is not an Error subtype", + {constructor:constructor}); + + // And checking that our exception is reasonable + assert(e.constructor === constructor && + e.name === constructor.name, + assertion_type, description, + "${func} threw ${actual} (${actual_name}) expected instance of ${expected} (${expected_name})", + {func:func, actual:e, actual_name:e.name, + expected:constructor, + expected_name:constructor.name}); + } + } + + /** + * Assert a DOMException with the expected type is thrown. + * + * @param {number|string} type The expected exception name or code. See the + * table of names and codes at + * https://heycam.github.io/webidl/#dfn-error-names-table + * If a number is passed it should be one of the numeric code values + * in that table (e.g. 3, 4, etc). If a string is passed it can + * either be an exception name (e.g. "HierarchyRequestError", + * "WrongDocumentError") or the name of the corresponding error code + * (e.g. "HIERARCHY_REQUEST_ERR", "WRONG_DOCUMENT_ERR"). + * + * For the remaining arguments, there are two ways of calling + * promise_rejects_dom: + * + * 1) If the DOMException is expected to come from the current global, the + * second argument should be the function expected to throw and a third, + * optional, argument is the assertion description. + * + * 2) If the DOMException is expected to come from some other global, the + * second argument should be the DOMException constructor from that global, + * the third argument the function expected to throw, and the fourth, optional, + * argument the assertion description. + */ + function assert_throws_dom(type, funcOrConstructor, descriptionOrFunc, maybeDescription) + { + let constructor, func, description; + if (funcOrConstructor.name === "DOMException") { + constructor = funcOrConstructor; + func = descriptionOrFunc; + description = maybeDescription; + } else { + constructor = self.DOMException; + func = funcOrConstructor; + description = descriptionOrFunc; + assert(maybeDescription === undefined, + "Too many args pased to no-constructor version of assert_throws_dom"); + } + assert_throws_dom_impl(type, func, description, "assert_throws_dom", constructor) + } + expose_assert(assert_throws_dom, "assert_throws_dom"); + + /** + * Similar to assert_throws_dom but allows specifying the assertion type + * (assert_throws_dom or promise_rejects_dom, in practice). The + * "constructor" argument must be the DOMException constructor from the + * global we expect the exception to come from. + */ + function assert_throws_dom_impl(type, func, description, assertion_type, constructor) + { + try { + func.call(this); + assert(false, assertion_type, description, + "${func} did not throw", {func:func}); + } catch (e) { + if (e instanceof AssertionError) { + throw e; } - var code_name_map = { + // Basic sanity-checks on the thrown exception. + assert(typeof e === "object", + assertion_type, description, + "${func} threw ${e} with type ${type}, not an object", + {func:func, e:e, type:typeof e}); + + assert(e !== null, + assertion_type, description, + "${func} threw null, not an object", + {func:func}); + + // Sanity-check our type + assert(typeof type == "number" || + typeof type == "string", + assertion_type, description, + "${type} is not a number or string", + {type:type}); + + var codename_name_map = { INDEX_SIZE_ERR: 'IndexSizeError', HIERARCHY_REQUEST_ERR: 'HierarchyRequestError', WRONG_DOCUMENT_ERR: 'WrongDocumentError', @@ -980,6 +1754,7 @@ policies and contribution forms [3]. NO_MODIFICATION_ALLOWED_ERR: 'NoModificationAllowedError', NOT_FOUND_ERR: 'NotFoundError', NOT_SUPPORTED_ERR: 'NotSupportedError', + INUSE_ATTRIBUTE_ERR: 'InUseAttributeError', INVALID_STATE_ERR: 'InvalidStateError', SYNTAX_ERR: 'SyntaxError', INVALID_MODIFICATION_ERR: 'InvalidModificationError', @@ -996,8 +1771,6 @@ policies and contribution forms [3]. DATA_CLONE_ERR: 'DataCloneError' }; - var name = code in code_name_map ? code_name_map[code] : code; - var name_code_map = { IndexSizeError: 1, HierarchyRequestError: 3, @@ -1006,6 +1779,7 @@ policies and contribution forms [3]. NoModificationAllowedError: 7, NotFoundError: 8, NotSupportedError: 9, + InUseAttributeError: 10, InvalidStateError: 11, SyntaxError: 12, InvalidModificationError: 13, @@ -1021,51 +1795,112 @@ policies and contribution forms [3]. InvalidNodeTypeError: 24, DataCloneError: 25, + EncodingError: 0, + NotReadableError: 0, UnknownError: 0, ConstraintError: 0, DataError: 0, TransactionInactiveError: 0, ReadOnlyError: 0, - VersionError: 0 + VersionError: 0, + OperationError: 0, + NotAllowedError: 0 }; - if (!(name in name_code_map)) { - throw new AssertionError('Test bug: unrecognized DOMException code "' + code + '" passed to assert_throws()'); + var code_name_map = {}; + for (var key in name_code_map) { + if (name_code_map[key] > 0) { + code_name_map[name_code_map[key]] = key; + } } - var required_props = { code: name_code_map[name] }; + var required_props = {}; + var name; + + if (typeof type === "number") { + if (type === 0) { + throw new AssertionError('Test bug: ambiguous DOMException code 0 passed to assert_throws_dom()'); + } else if (!(type in code_name_map)) { + throw new AssertionError('Test bug: unrecognized DOMException code "' + type + '" passed to assert_throws_dom()'); + } + name = code_name_map[type]; + required_props.code = type; + } else if (typeof type === "string") { + name = type in codename_name_map ? codename_name_map[type] : type; + if (!(name in name_code_map)) { + throw new AssertionError('Test bug: unrecognized DOMException code name or name "' + type + '" passed to assert_throws_dom()'); + } + + required_props.code = name_code_map[name]; + } if (required_props.code === 0 || - ("name" in e && e.name !== e.name.toUpperCase() && e.name !== "DOMException")) { + ("name" in e && + e.name !== e.name.toUpperCase() && + e.name !== "DOMException")) { // New style exception: also test the name property. required_props.name = name; } - //We'd like to test that e instanceof the appropriate interface, - //but we can't, because we don't know what window it was created - //in. It might be an instanceof the appropriate interface on some - //unknown other window. TODO: Work around this somehow? - - assert(typeof e == "object", - "assert_throws", description, - "${func} threw ${e} with type ${type}, not an object", - {func:func, e:e, type:typeof e}); - for (var prop in required_props) { - assert(typeof e == "object" && prop in e && e[prop] == required_props[prop], - "assert_throws", description, - "${func} threw ${e} that is not a DOMException " + code + ": property ${prop} is equal to ${actual}, expected ${expected}", + assert(prop in e && e[prop] == required_props[prop], + assertion_type, description, + "${func} threw ${e} that is not a DOMException " + type + ": property ${prop} is equal to ${actual}, expected ${expected}", {func:func, e:e, prop:prop, actual:e[prop], expected:required_props[prop]}); } + + // Check that the exception is from the right global. This check is last + // so more specific, and more informative, checks on the properties can + // happen in case a totally incorrect exception is thrown. + assert(e.constructor === constructor, + assertion_type, description, + "${func} threw an exception from the wrong global", + {func}); + + } + } + + /** + * Assert the provided value is thrown. + * + * @param {value} exception The expected exception. + * @param {Function} func Function which should throw. + * @param {string} description Error description for the case that the error is not thrown. + */ + function assert_throws_exactly(exception, func, description) + { + assert_throws_exactly_impl(exception, func, description, + "assert_throws_exactly"); + } + expose_assert(assert_throws_exactly, "assert_throws_exactly"); + + /** + * Like assert_throws_exactly but allows specifying the assertion type + * (assert_throws_exactly or promise_rejects_exactly, in practice). + */ + function assert_throws_exactly_impl(exception, func, description, + assertion_type) + { + try { + func.call(this); + assert(false, assertion_type, description, + "${func} did not throw", {func:func}); + } catch (e) { + if (e instanceof AssertionError) { + throw e; + } + + assert(same_value(e, exception), assertion_type, description, + "${func} threw ${e} but we expected it to throw ${exception}", + {func:func, e:e, exception:exception}); } } - expose(assert_throws, "assert_throws"); function assert_unreached(description) { assert(false, "assert_unreached", description, "Reached unreachable code"); } - expose(assert_unreached, "assert_unreached"); + expose_assert(assert_unreached, "assert_unreached"); function assert_any(assert_func, actual, expected_array) { @@ -1086,8 +1921,48 @@ policies and contribution forms [3]. throw new AssertionError(errors.join("\n\n")); } } + // FIXME: assert_any cannot use expose_assert, because assert_wrapper does + // not support nested assert calls (e.g. to assert_func). We need to + // support bypassing assert_wrapper for the inner asserts here. expose(assert_any, "assert_any"); + /** + * Assert that a feature is implemented, based on a 'truthy' condition. + * + * This function should be used to early-exit from tests in which there is + * no point continuing without support for a non-optional spec or spec + * feature. For example: + * + * assert_implements(window.Foo, 'Foo is not supported'); + * + * @param {object} condition The truthy value to test + * @param {string} description Error description for the case that the condition is not truthy. + */ + function assert_implements(condition, description) { + assert(!!condition, "assert_implements", description); + } + expose_assert(assert_implements, "assert_implements") + + /** + * Assert that an optional feature is implemented, based on a 'truthy' condition. + * + * This function should be used to early-exit from tests in which there is + * no point continuing without support for an explicitly optional spec or + * spec feature. For example: + * + * assert_implements_optional(video.canPlayType("video/webm"), + * "webm video playback not supported"); + * + * @param {object} condition The truthy value to test + * @param {string} description Error description for the case that the condition is not truthy. + */ + function assert_implements_optional(condition, description) { + if (!condition) { + throw new OptionalFeatureUnsupportedError(description); + } + } + expose_assert(assert_implements_optional, "assert_implements_optional") + function Test(name, properties) { if (tests.file_is_test && tests.tests.length) { @@ -1095,25 +1970,35 @@ policies and contribution forms [3]. } this.name = name; - this.phase = this.phases.INITIAL; + this.phase = (tests.is_aborted || tests.phase === tests.phases.COMPLETE) ? + this.phases.COMPLETE : this.phases.INITIAL; this.status = this.NOTRUN; this.timeout_id = null; this.index = null; - this.properties = properties; - var timeout = properties.timeout ? properties.timeout : settings.test_timeout; - if (timeout !== null) { - this.timeout_length = timeout * tests.timeout_multiplier; - } else { - this.timeout_length = null; + this.properties = properties || {}; + this.timeout_length = settings.test_timeout; + if (this.timeout_length !== null) { + this.timeout_length *= tests.timeout_multiplier; } this.message = null; + this.stack = null; this.steps = []; + this._is_promise_test = false; this.cleanup_callbacks = []; + this._user_defined_cleanup_count = 0; + this._done_callbacks = []; + + // Tests declared following harness completion are likely an indication + // of a programming error, but they cannot be reported + // deterministically. + if (tests.phase === tests.phases.COMPLETE) { + return; + } tests.push(this); } @@ -1122,7 +2007,8 @@ policies and contribution forms [3]. PASS:0, FAIL:1, TIMEOUT:2, - NOTRUN:3 + NOTRUN:3, + PRECONDITION_FAILED:4 }; Test.prototype = merge({}, Test.statuses); @@ -1131,9 +2017,22 @@ policies and contribution forms [3]. INITIAL:0, STARTED:1, HAS_RESULT:2, - COMPLETE:3 + CLEANING:3, + COMPLETE:4 }; + Test.prototype.status_formats = { + 0: "Pass", + 1: "Fail", + 2: "Timeout", + 3: "Not Run", + 4: "Optional Feature Unsupported", + } + + Test.prototype.format_status = function() { + return this.status_formats[this.status]; + } + Test.prototype.structured_clone = function() { if (!this._structured_clone) { @@ -1142,11 +2041,14 @@ policies and contribution forms [3]. this._structured_clone = merge({ name:String(this.name), properties:merge({}, this.properties), + phases:merge({}, this.phases) }, Test.statuses); } this._structured_clone.status = this.status; this._structured_clone.message = this.message; + this._structured_clone.stack = this.stack; this._structured_clone.index = this.index; + this._structured_clone.phase = this.phase; return this._structured_clone; }; @@ -1155,11 +2057,16 @@ policies and contribution forms [3]. if (this.phase > this.phases.STARTED) { return; } + + if (settings.debug && this.phase !== this.phases.STARTED) { + console.log("TEST START", this.name); + } this.phase = this.phases.STARTED; - //If we don't get a result before the harness times out that will be a test timout + //If we don't get a result before the harness times out that will be a test timeout this.set_status(this.TIMEOUT, "Test timed out"); tests.started = true; + tests.current_test = this; tests.notify_test_state(this); if (this.timeout_id === null) { @@ -1172,23 +2079,25 @@ policies and contribution forms [3]. this_obj = this; } + if (settings.debug) { + console.debug("TEST STEP", this.name); + } + try { return func.apply(this_obj, Array.prototype.slice.call(arguments, 2)); } catch (e) { if (this.phase >= this.phases.HAS_RESULT) { return; } - var message = (typeof e === "object" && e !== null) ? e.message : e; - if (typeof e.stack != "undefined" && typeof e.message == "string") { - //Try to make it more informative for some exceptions, at least - //in Gecko and WebKit. This results in a stack dump instead of - //just errors like "Cannot read property 'parentNode' of null" - //or "root is null". Makes it a lot longer, of course. - message += "(stack: " + e.stack + ")"; - } - this.set_status(this.FAIL, message); + var status = e instanceof OptionalFeatureUnsupportedError ? this.PRECONDITION_FAILED : this.FAIL; + var message = String((typeof e === "object" && e !== null) ? e.message : e); + var stack = e.stack ? e.stack : null; + + this.set_status(status, message, stack); this.phase = this.phases.HAS_RESULT; this.done(); + } finally { + this.current_test = null; } }; @@ -1232,13 +2141,132 @@ policies and contribution forms [3]. }); }; - Test.prototype.add_cleanup = function(callback) { + Test.prototype.step_timeout = function(f, timeout) { + var test_this = this; + var args = Array.prototype.slice.call(arguments, 2); + return setTimeout(this.step_func(function() { + return f.apply(test_this, args); + }), timeout * tests.timeout_multiplier); + }; + + Test.prototype.step_wait_func = function(cond, func, description, + timeout=3000, interval=100) { + /** + * Poll for a function to return true, and call a callback + * function once it does, or assert if a timeout is + * reached. This is preferred over a simple step_timeout + * whenever possible since it allows the timeout to be longer + * to reduce intermittents without compromising test execution + * speed when the condition is quickly met. + * + * @param {Function} cond A function taking no arguments and + * returning a boolean. The callback is called + * when this function returns true. + * @param {Function} func A function taking no arguments to call once + * the condition is met. + * @param {string} description Error message to add to assert in case of + * failure. + * @param {number} timeout Timeout in ms. This is multiplied by the global + * timeout_multiplier + * @param {number} interval Polling interval in ms + * + **/ + + var timeout_full = timeout * tests.timeout_multiplier; + var remaining = Math.ceil(timeout_full / interval); + var test_this = this; + + var wait_for_inner = test_this.step_func(() => { + if (cond()) { + func(); + } else { + if(remaining === 0) { + assert(false, "step_wait_func", description, + "Timed out waiting on condition"); + } + remaining--; + setTimeout(wait_for_inner, interval); + } + }); + + wait_for_inner(); + }; + + Test.prototype.step_wait_func_done = function(cond, func, description, + timeout=3000, interval=100) { + /** + * Poll for a function to return true, and invoke a callback + * followed by this.done() once it does, or assert if a timeout + * is reached. This is preferred over a simple step_timeout + * whenever possible since it allows the timeout to be longer + * to reduce intermittents without compromising test execution speed + * when the condition is quickly met. + * + * @param {Function} cond A function taking no arguments and + * returning a boolean. The callback is called + * when this function returns true. + * @param {Function} func A function taking no arguments to call once + * the condition is met. + * @param {string} description Error message to add to assert in case of + * failure. + * @param {number} timeout Timeout in ms. This is multiplied by the global + * timeout_multiplier + * @param {number} interval Polling interval in ms + * + **/ + + this.step_wait_func(cond, () => { + if (func) { + func(); + } + this.done(); + }, description, timeout, interval); + } + + Test.prototype.step_wait = function(cond, description, timeout=3000, interval=100) { + /** + * Poll for a function to return true, and resolve a promise + * once it does, or assert if a timeout is reached. This is + * preferred over a simple step_timeout whenever possible + * since it allows the timeout to be longer to reduce + * intermittents without compromising test execution speed + * when the condition is quickly met. + * + * @param {Function} cond A function taking no arguments and + * returning a boolean. + * @param {string} description Error message to add to assert in case of + * failure. + * @param {number} timeout Timeout in ms. This is multiplied by the global + * timeout_multiplier + * @param {number} interval Polling interval in ms + * @returns {Promise} Promise resolved once cond is met. + * + **/ + + return new Promise(resolve => { + this.step_wait_func(cond, resolve, description, timeout, interval); + }); + } + + /* + * Private method for registering cleanup functions. `testharness.js` + * internals should use this method instead of the public `add_cleanup` + * method in order to hide implementation details from the harness status + * message in the case errors. + */ + Test.prototype._add_cleanup = function(callback) { this.cleanup_callbacks.push(callback); }; - Test.prototype.force_timeout = function() { - this.set_status(this.TIMEOUT); - this.phase = this.phases.HAS_RESULT; + /* + * Schedule a function to be run after the test result is known, regardless + * of passing or failing state. The behavior of this function will not + * influence the result of the test, but if an exception is thrown, the + * test harness will report an error. + */ + Test.prototype.add_cleanup = function(callback) { + this._user_defined_cleanup_count += 1; + this._add_cleanup(callback); }; Test.prototype.set_timeout = function() @@ -1252,10 +2280,11 @@ policies and contribution forms [3]. } }; - Test.prototype.set_status = function(status, message) + Test.prototype.set_status = function(status, message, stack) { this.status = status; this.message = message; + this.stack = stack ? stack : null; }; Test.prototype.timeout = function() @@ -1266,9 +2295,15 @@ policies and contribution forms [3]. this.done(); }; + Test.prototype.force_timeout = Test.prototype.timeout; + + /** + * Update the test status, initiate "cleanup" functions, and signal test + * completion. + */ Test.prototype.done = function() { - if (this.phase == this.phases.COMPLETE) { + if (this.phase >= this.phases.CLEANING) { return; } @@ -1276,20 +2311,139 @@ policies and contribution forms [3]. this.set_status(this.PASS, null); } - this.phase = this.phases.COMPLETE; + if (global_scope.clearTimeout) { + clearTimeout(this.timeout_id); + } + + if (settings.debug) { + console.log("TEST DONE", + this.status, + this.name,) + } - clearTimeout(this.timeout_id); - tests.result(this); this.cleanup(); }; + function add_test_done_callback(test, callback) + { + if (test.phase === test.phases.COMPLETE) { + callback(); + return; + } + + test._done_callbacks.push(callback); + } + + /* + * Invoke all specified cleanup functions. If one or more produce an error, + * the context is in an unpredictable state, so all further testing should + * be cancelled. + */ Test.prototype.cleanup = function() { + var error_count = 0; + var bad_value_count = 0; + function on_error() { + error_count += 1; + // Abort tests immediately so that tests declared within subsequent + // cleanup functions are not run. + tests.abort(); + } + var this_obj = this; + var results = []; + + this.phase = this.phases.CLEANING; + forEach(this.cleanup_callbacks, function(cleanup_callback) { - cleanup_callback(); + var result; + + try { + result = cleanup_callback(); + } catch (e) { + on_error(); + return; + } + + if (!is_valid_cleanup_result(this_obj, result)) { + bad_value_count += 1; + // Abort tests immediately so that tests declared + // within subsequent cleanup functions are not run. + tests.abort(); + } + + results.push(result); }); + + if (!this._is_promise_test) { + cleanup_done(this_obj, error_count, bad_value_count); + } else { + all_async(results, + function(result, done) { + if (result && typeof result.then === "function") { + result + .then(null, on_error) + .then(done); + } else { + done(); + } + }, + function() { + cleanup_done(this_obj, error_count, bad_value_count); + }); + } }; + /** + * Determine if the return value of a cleanup function is valid for a given + * test. Any test may return the value `undefined`. Tests created with + * `promise_test` may alternatively return "thenable" object values. + */ + function is_valid_cleanup_result(test, result) { + if (result === undefined) { + return true; + } + + if (test._is_promise_test) { + return result && typeof result.then === "function"; + } + + return false; + } + + function cleanup_done(test, error_count, bad_value_count) { + if (error_count || bad_value_count) { + var total = test._user_defined_cleanup_count; + + tests.status.status = tests.status.ERROR; + tests.status.message = "Test named '" + test.name + + "' specified " + total + + " 'cleanup' function" + (total > 1 ? "s" : ""); + + if (error_count) { + tests.status.message += ", and " + error_count + " failed"; + } + + if (bad_value_count) { + var type = test._is_promise_test ? + "non-thenable" : "non-undefined"; + tests.status.message += ", and " + bad_value_count + + " returned a " + type + " value"; + } + + tests.status.message += "."; + + tests.status.stack = null; + } + + test.phase = test.phases.COMPLETE; + tests.result(test); + forEach(test._done_callbacks, + function(callback) { + callback(); + }); + test._done_callbacks.length = 0; + } + /* * A RemoteTest object mirrors a Test object on a remote worker. The * associated RemoteWorker updates the RemoteTest object in response to @@ -1306,94 +2460,156 @@ policies and contribution forms [3]. this.index = null; this.phase = this.phases.INITIAL; this.update_state_from(clone); + this._done_callbacks = []; tests.push(this); } RemoteTest.prototype.structured_clone = function() { var clone = {}; Object.keys(this).forEach( - function(key) { - if (typeof(this[key]) === "object") { - clone[key] = merge({}, this[key]); + (function(key) { + var value = this[key]; + // `RemoteTest` instances are responsible for managing + // their own "done" callback functions, so those functions + // are not relevant in other execution contexts. Because of + // this (and because Function values cannot be serialized + // for cross-realm transmittance), the property should not + // be considered when cloning instances. + if (key === '_done_callbacks' ) { + return; + } + + if (typeof value === "object" && value !== null) { + clone[key] = merge({}, value); } else { - clone[key] = this[key]; + clone[key] = value; } - }); + }).bind(this)); clone.phases = merge({}, this.phases); return clone; }; - RemoteTest.prototype.cleanup = function() {}; + /** + * `RemoteTest` instances are objects which represent tests running in + * another realm. They do not define "cleanup" functions (if necessary, + * such functions are defined on the associated `Test` instance within the + * external realm). However, `RemoteTests` may have "done" callbacks (e.g. + * as attached by the `Tests` instance responsible for tracking the overall + * test status in the parent realm). The `cleanup` method delegates to + * `done` in order to ensure that such callbacks are invoked following the + * completion of the `RemoteTest`. + */ + RemoteTest.prototype.cleanup = function() { + this.done(); + }; RemoteTest.prototype.phases = Test.prototype.phases; RemoteTest.prototype.update_state_from = function(clone) { this.status = clone.status; this.message = clone.message; + this.stack = clone.stack; if (this.phase === this.phases.INITIAL) { this.phase = this.phases.STARTED; } }; RemoteTest.prototype.done = function() { this.phase = this.phases.COMPLETE; + + forEach(this._done_callbacks, + function(callback) { + callback(); + }); + } + + RemoteTest.prototype.format_status = function() { + return Test.prototype.status_formats[this.status]; } /* - * A RemoteWorker listens for test events from a worker. These events are - * then used to construct and maintain RemoteTest objects that mirror the - * tests running on the remote worker. + * A RemoteContext listens for test events from a remote test context, such + * as another window or a worker. These events are then used to construct + * and maintain RemoteTest objects that mirror the tests running in the + * remote context. + * + * An optional third parameter can be used as a predicate to filter incoming + * MessageEvents. */ - function RemoteWorker(worker) { + function RemoteContext(remote, message_target, message_filter) { this.running = true; + this.started = false; this.tests = new Array(); + this.early_exception = null; var this_obj = this; - worker.onerror = function(error) { this_obj.worker_error(error); }; + // If remote context is cross origin assigning to onerror is not + // possible, so silently catch those errors. + try { + remote.onerror = function(error) { this_obj.remote_error(error); }; + } catch (e) { + // Ignore. + } - var message_port; + // Keeping a reference to the remote object and the message handler until + // remote_done() is seen prevents the remote object and its message channel + // from going away before all the messages are dispatched. + this.remote = remote; + this.message_target = message_target; + this.message_handler = function(message) { + var passesFilter = !message_filter || message_filter(message); + // The reference to the `running` property in the following + // condition is unnecessary because that value is only set to + // `false` after the `message_handler` function has been + // unsubscribed. + // TODO: Simplify the condition by removing the reference. + if (this_obj.running && message.data && passesFilter && + (message.data.type in this_obj.message_handlers)) { + this_obj.message_handlers[message.data.type].call(this_obj, message.data); + } + }; - if (is_service_worker(worker)) { - // The ServiceWorker's implicit MessagePort is currently not - // reliably accessible from the ServiceWorkerGlobalScope due to - // Blink setting MessageEvent.source to null for messages sent via - // ServiceWorker.postMessage(). Until that's resolved, create an - // explicit MessageChannel and pass one end to the worker. - var message_channel = new MessageChannel(); - message_port = message_channel.port1; - message_port.start(); - worker.postMessage({type: "connect"}, [message_channel.port2]); - } else if (is_shared_worker(worker)) { - message_port = worker.port; - } else { - message_port = worker; + if (self.Promise) { + this.done = new Promise(function(resolve) { + this_obj.doneResolve = resolve; + }); } - // Keeping a reference to the worker until worker_done() is seen - // prevents the Worker object and its MessageChannel from going away - // before all the messages are dispatched. - this.worker = worker; - - message_port.onmessage = - function(message) { - if (this_obj.running && (message.data.type in this_obj.message_handlers)) { - this_obj.message_handlers[message.data.type].call(this_obj, message.data); - } - }; + this.message_target.addEventListener("message", this.message_handler); } - RemoteWorker.prototype.worker_error = function(error) { + RemoteContext.prototype.remote_error = function(error) { + if (error.preventDefault) { + error.preventDefault(); + } + + // Defer interpretation of errors until the testing protocol has + // started and the remote test's `allow_uncaught_exception` property + // is available. + if (!this.started) { + this.early_exception = error; + } else if (!this.allow_uncaught_exception) { + this.report_uncaught(error); + } + }; + + RemoteContext.prototype.report_uncaught = function(error) { var message = error.message || String(error); var filename = (error.filename ? " " + error.filename: ""); - // FIXME: Display worker error states separately from main document - // error state. - this.worker_done({ - status: { - status: tests.status.ERROR, - message: "Error in worker" + filename + ": " + message - } - }); - error.preventDefault(); + // FIXME: Display remote error states separately from main document + // error state. + tests.set_status(tests.status.ERROR, + "Error in remote" + filename + ": " + message, + error.stack); + }; + + RemoteContext.prototype.start = function(data) { + this.started = true; + this.allow_uncaught_exception = data.properties.allow_uncaught_exception; + + if (this.early_exception && !this.allow_uncaught_exception) { + this.report_uncaught(this.early_exception); + } }; - RemoteWorker.prototype.test_state = function(data) { + RemoteContext.prototype.test_state = function(data) { var remote_test = this.tests[data.test.index]; if (!remote_test) { remote_test = new RemoteTest(data.test); @@ -1403,30 +2619,56 @@ policies and contribution forms [3]. tests.notify_test_state(remote_test); }; - RemoteWorker.prototype.test_done = function(data) { + RemoteContext.prototype.test_done = function(data) { var remote_test = this.tests[data.test.index]; remote_test.update_state_from(data.test); remote_test.done(); tests.result(remote_test); }; - RemoteWorker.prototype.worker_done = function(data) { + RemoteContext.prototype.remote_done = function(data) { if (tests.status.status === null && data.status.status !== data.status.OK) { - tests.status.status = data.status.status; - tests.status.message = data.status.message; + tests.set_status(data.status.status, data.status.message, data.status.stack); + } + + for (let assert of data.asserts) { + var record = new AssertRecord(); + record.assert_name = assert.assert_name; + record.args = assert.args; + record.test = assert.test != null ? this.tests[assert.test.index] : null; + record.status = assert.status; + record.stack = assert.stack; + tests.asserts_run.push(record); } + + this.message_target.removeEventListener("message", this.message_handler); this.running = false; - this.worker = null; + + // If remote context is cross origin assigning to onerror is not + // possible, so silently catch those errors. + try { + this.remote.onerror = null; + } catch (e) { + // Ignore. + } + + this.remote = null; + this.message_target = null; + if (this.doneResolve) { + this.doneResolve(); + } + if (tests.all_done()) { tests.complete(); } }; - RemoteWorker.prototype.message_handlers = { - test_state: RemoteWorker.prototype.test_state, - result: RemoteWorker.prototype.test_done, - complete: RemoteWorker.prototype.worker_done + RemoteContext.prototype.message_handlers = { + start: RemoteContext.prototype.start, + test_state: RemoteContext.prototype.test_state, + result: RemoteContext.prototype.test_done, + complete: RemoteContext.prototype.remote_done }; /* @@ -1437,16 +2679,26 @@ policies and contribution forms [3]. { this.status = null; this.message = null; + this.stack = null; } TestsStatus.statuses = { OK:0, ERROR:1, - TIMEOUT:2 + TIMEOUT:2, + PRECONDITION_FAILED:3 }; TestsStatus.prototype = merge({}, TestsStatus.statuses); + TestsStatus.prototype.formats = { + 0: "OK", + 1: "Error", + 2: "Timeout", + 3: "Optional Feature Unsupported" + } + + TestsStatus.prototype.structured_clone = function() { if (!this._structured_clone) { @@ -1454,12 +2706,34 @@ policies and contribution forms [3]. msg = msg ? String(msg) : msg; this._structured_clone = merge({ status:this.status, - message:msg + message:msg, + stack:this.stack }, TestsStatus.statuses); } return this._structured_clone; }; + TestsStatus.prototype.format_status = function() { + return this.formats[this.status]; + } + + function AssertRecord(test, assert_name, args = []) { + this.assert_name = assert_name; + this.test = test; + // Avoid keeping complex objects alive + this.args = args.map(x => format_value(x).replace(/\n/g, " ")); + this.status = null; + } + + AssertRecord.prototype.structured_clone = function() { + return { + assert_name: this.assert_name, + test: this.test ? this.test.structured_clone() : null, + args: this.args, + status: this.status, + } + } + function Tests() { this.tests = []; @@ -1482,6 +2756,10 @@ policies and contribution forms [3]. this.allow_uncaught_exception = false; this.file_is_test = false; + // This value is lazily initialized in order to avoid introducing a + // dependency on ECMAScript 2015 Promises to all tests. + this.promise_tests = null; + this.promise_setup_called = false; this.timeout_multiplier = 1; this.timeout_length = test_environment.test_timeout(); @@ -1492,7 +2770,20 @@ policies and contribution forms [3]. this.test_done_callbacks = []; this.all_done_callbacks = []; - this.pending_workers = []; + this.hide_test_state = false; + this.pending_remotes = []; + + this.current_test = null; + this.asserts_run = []; + + // Track whether output is enabled, and thus whether or not we should + // track asserts. + // + // On workers we don't get properties set from testharnessreport.js, so + // we don't know whether or not to track asserts. To avoid the + // resulting performance hit, we assume we are not meant to. This means + // that assert tracking does not function on workers. + this.output = settings.output && 'document' in global_scope; this.status = new TestsStatus(); @@ -1532,8 +2823,19 @@ policies and contribution forms [3]. { clearTimeout(this.timeout_id); } + } else if (p == "single_test" && value) { + this.set_file_is_test(); } else if (p == "timeout_multiplier") { this.timeout_multiplier = value; + if (this.timeout_length) { + this.timeout_length *= this.timeout_multiplier; + } + } else if (p == "hide_test_state") { + this.hide_test_state = value; + } else if (p == "output") { + this.output = value; + } else if (p === "debug") { + settings.debug = value; } } } @@ -1542,8 +2844,10 @@ policies and contribution forms [3]. try { func(); } catch (e) { - this.status.status = this.status.ERROR; + this.status.status = e instanceof OptionalFeatureUnsupportedError ? this.status.PRECONDITION_FAILED : this.status.ERROR; this.status.message = String(e); + this.status.stack = e.stack ? e.stack : null; + this.complete(); } } this.set_timeout(); @@ -1556,23 +2860,58 @@ policies and contribution forms [3]. this.wait_for_finish = true; this.file_is_test = true; // Create the test, which will add it to the list of tests - async_test(); + tests.current_test = async_test(); + }; + + Tests.prototype.set_status = function(status, message, stack) + { + this.status.status = status; + this.status.message = message; + this.status.stack = stack ? stack : null; }; Tests.prototype.set_timeout = function() { - var this_obj = this; - clearTimeout(this.timeout_id); - if (this.timeout_length !== null) { - this.timeout_id = setTimeout(function() { - this_obj.timeout(); - }, this.timeout_length); + if (global_scope.clearTimeout) { + var this_obj = this; + clearTimeout(this.timeout_id); + if (this.timeout_length !== null) { + this.timeout_id = setTimeout(function() { + this_obj.timeout(); + }, this.timeout_length); + } } }; Tests.prototype.timeout = function() { + var test_in_cleanup = null; + if (this.status.status === null) { - this.status.status = this.status.TIMEOUT; + forEach(this.tests, + function(test) { + // No more than one test is expected to be in the + // "CLEANUP" phase at any time + if (test.phase === test.phases.CLEANING) { + test_in_cleanup = test; + } + + test.phase = test.phases.COMPLETE; + }); + + // Timeouts that occur while a test is in the "cleanup" phase + // indicate that some global state was not properly reverted. This + // invalidates the overall test execution, so the timeout should be + // reported as an error and cancel the execution of any remaining + // tests. + if (test_in_cleanup) { + this.status.status = this.status.ERROR; + this.status.message = "Timeout while running cleanup for " + + "test named \"" + test_in_cleanup.name + "\"."; + tests.status.stack = null; + } else { + this.status.status = this.status.TIMEOUT; + } } + this.complete(); }; @@ -1603,10 +2942,10 @@ policies and contribution forms [3]. }; Tests.prototype.all_done = function() { - return (this.tests.length > 0 && test_environment.all_loaded && - this.num_pending === 0 && !this.wait_for_finish && + return this.tests.length > 0 && test_environment.all_loaded && + (this.num_pending === 0 || this.is_aborted) && !this.wait_for_finish && !this.processing_callbacks && - !this.pending_workers.some(function(w) { return w.running; })); + !this.pending_remotes.some(function(w) { return w.running; }); }; Tests.prototype.start = function() { @@ -1625,10 +2964,11 @@ policies and contribution forms [3]. Tests.prototype.result = function(test) { - if (this.phase > this.phases.HAVE_RESULTS) { - return; + // If the harness has already transitioned beyond the `HAVE_RESULTS` + // phase, subsequent tests should not cause it to revert. + if (this.phase <= this.phases.HAVE_RESULTS) { + this.phase = this.phases.HAVE_RESULTS; } - this.phase = this.phases.HAVE_RESULTS; this.num_pending--; this.notify_result(test); }; @@ -1651,47 +2991,211 @@ policies and contribution forms [3]. if (this.phase === this.phases.COMPLETE) { return; } - this.phase = this.phases.COMPLETE; var this_obj = this; - this.tests.forEach( - function(x) - { - if (x.phase < x.phases.COMPLETE) { - this_obj.notify_result(x); - x.cleanup(); - x.phase = x.phases.COMPLETE; - } - } - ); - this.notify_complete(); + var all_complete = function() { + this_obj.phase = this_obj.phases.COMPLETE; + this_obj.notify_complete(); + }; + var incomplete = filter(this.tests, + function(test) { + return test.phase < test.phases.COMPLETE; + }); + + /** + * To preserve legacy behavior, overall test completion must be + * signaled synchronously. + */ + if (incomplete.length === 0) { + all_complete(); + return; + } + + all_async(incomplete, + function(test, testDone) + { + if (test.phase === test.phases.INITIAL) { + test.phase = test.phases.COMPLETE; + testDone(); + } else { + add_test_done_callback(test, testDone); + test.cleanup(); + } + }, + all_complete); + }; + + Tests.prototype.set_assert = function(assert_name, args) { + this.asserts_run.push(new AssertRecord(this.current_test, assert_name, args)) + } + + Tests.prototype.set_assert_status = function(status, stack) { + let assert_record = this.asserts_run[this.asserts_run.length - 1]; + assert_record.status = status; + assert_record.stack = stack; + } + + /** + * Update the harness status to reflect an unrecoverable harness error that + * should cancel all further testing. Update all previously-defined tests + * which have not yet started to indicate that they will not be executed. + */ + Tests.prototype.abort = function() { + this.status.status = this.status.ERROR; + this.is_aborted = true; + + forEach(this.tests, + function(test) { + if (test.phase === test.phases.INITIAL) { + test.phase = test.phases.COMPLETE; + } + }); + }; + + /* + * Determine if any tests share the same `name` property. Return an array + * containing the names of any such duplicates. + */ + Tests.prototype.find_duplicates = function() { + var names = Object.create(null); + var duplicates = []; + + forEach (this.tests, + function(test) + { + if (test.name in names && duplicates.indexOf(test.name) === -1) { + duplicates.push(test.name); + } + names[test.name] = true; + }); + + return duplicates; }; + function code_unit_str(char) { + return 'U+' + char.charCodeAt(0).toString(16); + } + + function sanitize_unpaired_surrogates(str) { + return str.replace( + /([\ud800-\udbff]+)(?![\udc00-\udfff])|(^|[^\ud800-\udbff])([\udc00-\udfff]+)/g, + function(_, low, prefix, high) { + var output = prefix || ""; // prefix may be undefined + var string = low || high; // only one of these alternates can match + for (var i = 0; i < string.length; i++) { + output += code_unit_str(string[i]); + } + return output; + }); + } + + function sanitize_all_unpaired_surrogates(tests) { + forEach (tests, + function (test) + { + var sanitized = sanitize_unpaired_surrogates(test.name); + + if (test.name !== sanitized) { + test.name = sanitized; + delete test._structured_clone; + } + }); + } + Tests.prototype.notify_complete = function() { var this_obj = this; + var duplicates; + if (this.status.status === null) { - this.status.status = this.status.OK; + duplicates = this.find_duplicates(); + + // Some transports adhere to UTF-8's restriction on unpaired + // surrogates. Sanitize the titles so that the results can be + // consistently sent via all transports. + sanitize_all_unpaired_surrogates(this.tests); + + // Test names are presumed to be unique within test files--this + // allows consumers to use them for identification purposes. + // Duplicated names violate this expectation and should therefore + // be reported as an error. + if (duplicates.length) { + this.status.status = this.status.ERROR; + this.status.message = + duplicates.length + ' duplicate test name' + + (duplicates.length > 1 ? 's' : '') + ': "' + + duplicates.join('", "') + '"'; + } else { + this.status.status = this.status.OK; + } } forEach (this.all_done_callbacks, function(callback) { - callback(this_obj.tests, this_obj.status); + callback(this_obj.tests, this_obj.status, this_obj.asserts_run); }); }; + /* + * Constructs a RemoteContext that tracks tests from a specific worker. + */ + Tests.prototype.create_remote_worker = function(worker) { + var message_port; + + if (is_service_worker(worker)) { + message_port = navigator.serviceWorker; + worker.postMessage({type: "connect"}); + } else if (is_shared_worker(worker)) { + message_port = worker.port; + message_port.start(); + } else { + message_port = worker; + } + + return new RemoteContext(worker, message_port); + }; + + /* + * Constructs a RemoteContext that tracks tests from a specific window. + */ + Tests.prototype.create_remote_window = function(remote) { + remote.postMessage({type: "getmessages"}, "*"); + return new RemoteContext( + remote, + window, + function(msg) { + return msg.source === remote; + } + ); + }; + Tests.prototype.fetch_tests_from_worker = function(worker) { if (this.phase >= this.phases.COMPLETE) { return; } - this.pending_workers.push(new RemoteWorker(worker)); + var remoteContext = this.create_remote_worker(worker); + this.pending_remotes.push(remoteContext); + return remoteContext.done; }; function fetch_tests_from_worker(port) { - tests.fetch_tests_from_worker(port); + return tests.fetch_tests_from_worker(port); } expose(fetch_tests_from_worker, 'fetch_tests_from_worker'); + Tests.prototype.fetch_tests_from_window = function(remote) { + if (this.phase >= this.phases.COMPLETE) { + return; + } + + this.pending_remotes.push(this.create_remote_window(remote)); + }; + + function fetch_tests_from_window(window) { + tests.fetch_tests_from_window(window); + } + expose(fetch_tests_from_window, 'fetch_tests_from_window'); + function timeout() { if (tests.timeout_length === null) { tests.timeout(); @@ -1707,14 +3211,12 @@ policies and contribution forms [3]. tests.test_state_callbacks.push(callback); } - function add_result_callback(callback) - { + function add_result_callback(callback) { tests.test_done_callbacks.push(callback); } - function add_completion_callback(callback) - { - tests.all_done_callbacks.push(callback); + function add_completion_callback(callback) { + tests.all_done_callbacks.push(callback); } expose(add_start_callback, 'add_start_callback'); @@ -1722,6 +3224,29 @@ policies and contribution forms [3]. expose(add_result_callback, 'add_result_callback'); expose(add_completion_callback, 'add_completion_callback'); + function remove(array, item) { + var index = array.indexOf(item); + if (index > -1) { + array.splice(index, 1); + } + } + + function remove_start_callback(callback) { + remove(tests.start_callbacks, callback); + } + + function remove_test_state_callback(callback) { + remove(tests.test_state_callbacks, callback); + } + + function remove_result_callback(callback) { + remove(tests.test_done_callbacks, callback); + } + + function remove_completion_callback(callback) { + remove(tests.all_done_callbacks, callback); + } + /* * Output listener */ @@ -1763,6 +3288,9 @@ policies and contribution forms [3]. Output.prototype.resolve_log = function() { var output_document; + if (this.output_node) { + return; + } if (typeof this.output_document === "function") { output_document = this.output_document.apply(undefined); } else { @@ -1773,12 +3301,34 @@ policies and contribution forms [3]. } var node = output_document.getElementById("log"); if (!node) { - if (!document.body || document.readyState == "loading") { + if (output_document.readyState === "loading") { return; } - node = output_document.createElement("div"); + node = output_document.createElementNS("http://www.w3.org/1999/xhtml", "div"); node.id = "log"; - output_document.body.appendChild(node); + if (output_document.body) { + output_document.body.appendChild(node); + } else { + var root = output_document.documentElement; + var is_html = (root && + root.namespaceURI == "http://www.w3.org/1999/xhtml" && + root.localName == "html"); + var is_svg = (output_document.defaultView && + "SVGSVGElement" in output_document.defaultView && + root instanceof output_document.defaultView.SVGSVGElement); + if (is_svg) { + var foreignObject = output_document.createElementNS("http://www.w3.org/2000/svg", "foreignObject"); + foreignObject.setAttribute("width", "100%"); + foreignObject.setAttribute("height", "100%"); + root.appendChild(foreignObject); + foreignObject.appendChild(node); + } else if (is_html) { + root.appendChild(output_document.createElementNS("http://www.w3.org/1999/xhtml", "body")) + .appendChild(node); + } else { + root.appendChild(node); + } + } } this.output_document = output_document; this.output_node = node; @@ -1788,15 +3338,15 @@ policies and contribution forms [3]. if (this.phase < this.STARTED) { this.init(); } - if (!this.enabled) { + if (!this.enabled || this.phase === this.COMPLETE) { return; } + this.resolve_log(); if (this.phase < this.HAVE_RESULTS) { - this.resolve_log(); this.phase = this.HAVE_RESULTS; } var done_count = tests.tests.length - tests.num_pending; - if (this.output_node) { + if (this.output_node && !tests.hide_test_state) { if (done_count < 100 || (done_count < 1000 && done_count % 100 === 0) || done_count % 1000 === 0) { @@ -1807,7 +3357,7 @@ policies and contribution forms [3]. } }; - Output.prototype.show_results = function (tests, harness_status) { + Output.prototype.show_results = function (tests, harness_status, asserts_run) { if (this.phase >= this.COMPLETE) { return; } @@ -1829,49 +3379,17 @@ policies and contribution forms [3]. log.removeChild(log.lastChild); } - var script_prefix = null; - var scripts = document.getElementsByTagName("script"); - for (var i = 0; i < scripts.length; i++) { - var src; - if (scripts[i].src) { - src = scripts[i].src; - } else if (scripts[i].href) { - //SVG case - src = scripts[i].href.baseVal; - } - - var matches = src && src.match(/^(.*\/|)testharness\.js$/); - if (matches) { - script_prefix = matches[1]; - break; - } - } - - if (script_prefix !== null) { - var stylesheet = output_document.createElementNS(xhtml_ns, "link"); - stylesheet.setAttribute("rel", "stylesheet"); - stylesheet.setAttribute("href", script_prefix + "testharness.css"); - var heads = output_document.getElementsByTagName("head"); - if (heads.length) { - heads[0].appendChild(stylesheet); - } + var stylesheet = output_document.createElementNS(xhtml_ns, "style"); + stylesheet.textContent = stylesheetContent; + var heads = output_document.getElementsByTagName("head"); + if (heads.length) { + heads[0].appendChild(stylesheet); } - var status_text_harness = {}; - status_text_harness[harness_status.OK] = "OK"; - status_text_harness[harness_status.ERROR] = "Error"; - status_text_harness[harness_status.TIMEOUT] = "Timeout"; - - var status_text = {}; - status_text[Test.prototype.PASS] = "Pass"; - status_text[Test.prototype.FAIL] = "Fail"; - status_text[Test.prototype.TIMEOUT] = "Timeout"; - status_text[Test.prototype.NOTRUN] = "Not Run"; - var status_number = {}; forEach(tests, function(test) { - var status = status_text[test.status]; + var status = test.format_status(); if (status_number.hasOwnProperty(status)) { status_number[status] += 1; } else { @@ -1888,8 +3406,7 @@ policies and contribution forms [3]. ["h2", {}, "Summary"], function() { - - var status = status_text_harness[harness_status.status]; + var status = harness_status.format_status(); var rv = [["section", {}, ["p", {}, "Harness status: ", @@ -1901,6 +3418,9 @@ policies and contribution forms [3]. if (harness_status.status === harness_status.ERROR) { rv[0].push(["pre", {}, harness_status.message]); + if (harness_status.stack) { + rv[0].push(["pre", {}, harness_status.stack]); + } } return rv; }, @@ -1908,13 +3428,14 @@ policies and contribution forms [3]. function() { var rv = [["div", {}]]; var i = 0; - while (status_text.hasOwnProperty(i)) { - if (status_number.hasOwnProperty(status_text[i])) { - var status = status_text[i]; - rv[0].push(["div", {"class":status_class(status)}, + while (Test.prototype.status_formats.hasOwnProperty(i)) { + if (status_number.hasOwnProperty(Test.prototype.status_formats[i])) { + var status = Test.prototype.status_formats[i]; + rv[0].push(["div", {}, ["label", {}, ["input", {type:"checkbox", checked:"checked"}], - status_number[status] + " " + status]]); + status_number[status] + " ", + ["span", {"class":status_class(status)}, status]]]); } i++; } @@ -1934,13 +3455,13 @@ policies and contribution forms [3]. e.preventDefault(); return; } - var result_class = element.parentNode.getAttribute("class"); + var result_class = element.querySelector("span[class]").getAttribute("class"); var style_element = output_document.querySelector("style#hide-" + result_class); var input_element = element.querySelector("input"); if (!style_element && !input_element.checked) { style_element = output_document.createElementNS(xhtml_ns, "style"); style_element.id = "hide-" + result_class; - style_element.textContent = "table#results > tbody > tr."+result_class+"{display:none}"; + style_element.textContent = "table#results > tbody > tr.overall-"+result_class+"{display:none}"; output_document.body.appendChild(style_element); } else if (style_element && input_element.checked) { style_element.parentNode.removeChild(style_element); @@ -1981,6 +3502,52 @@ policies and contribution forms [3]. return ''; } + var asserts_run_by_test = new Map(); + asserts_run.forEach(assert => { + if (!asserts_run_by_test.has(assert.test)) { + asserts_run_by_test.set(assert.test, []); + } + asserts_run_by_test.get(assert.test).push(assert); + }); + + function get_asserts_output(test) { + var asserts = asserts_run_by_test.get(test); + if (!asserts) { + return "No asserts ran"; + } + rv = ""; + rv += asserts.map(assert => { + var output_fn = "" + escape_html(assert.assert_name) + "("; + var prefix_len = output_fn.length; + var output_args = assert.args; + var output_len = output_args.reduce((prev, current) => prev+current, prefix_len); + if (output_len[output_len.length - 1] > 50) { + output_args = output_args.map((x, i) => + (i > 0 ? " ".repeat(prefix_len) : "" )+ x + (i < output_args.length - 1 ? ",\n" : "")); + } else { + output_args = output_args.map((x, i) => x + (i < output_args.length - 1 ? ", " : "")); + } + output_fn += escape_html(output_args.join("")); + output_fn += ')'; + var output_location; + if (assert.stack) { + output_location = assert.stack.split("\n", 1)[0].replace(/@?\w+:\/\/[^ "\/]+(?::\d+)?/g, " "); + } + return "" + + "" + + ""; + } + ).join("\n"); + rv += "
" + + Test.prototype.status_formats[assert.status] + "
" +
+                    output_fn +
+                    (output_location ? "\n" + escape_html(output_location) : "") +
+                    "
"; + return rv; + } + log.appendChild(document.createElementNS(xhtml_ns, "section")); var assertions = has_assertions(); var html = "

Details

" + @@ -1989,16 +3556,26 @@ policies and contribution forms [3]. "" + ""; for (var i = 0; i < tests.length; i++) { - html += '' + + '"; + (assertions ? escape_html(get_assertion(test)) + ""; } html += "
Message
' + - escape_html(status_text[tests[i].status]) + + var test = tests[i]; + html += '
' + + test.format_status() + "" + - escape_html(tests[i].name) + + escape_html(test.name) + "" + - (assertions ? escape_html(get_assertion(tests[i])) + "" : "") + - escape_html(tests[i].message ? tests[i].message : " ") + - "
" : "") + + escape_html(test.message ? tests[i].message : " ") + + (tests[i].stack ? "
" +
+                 escape_html(tests[i].stack) +
+                 "
": ""); + if (!(test instanceof RemoteTest)) { + html += "
Asserts run" + get_asserts_output(test) + "
" + } + html += "
"; try { @@ -2014,7 +3591,7 @@ policies and contribution forms [3]. /* * Template code * - * A template is just a javascript structure. An element is represented as: + * A template is just a JavaScript structure. An element is represented as: * * [tag_name, {attr_name:attr_value}, child1, child2] * @@ -2176,13 +3753,10 @@ policies and contribution forms [3]. } /* - * Utility funcions + * Utility functions */ function assert(expected_true, function_name, description, error, substitutions) { - if (tests.tests.length === 0) { - tests.set_file_is_test(); - } if (expected_true !== true) { var msg = make_message(function_name, description, error, substitutions); @@ -2192,12 +3766,68 @@ policies and contribution forms [3]. function AssertionError(message) { + if (typeof message == "string") { + message = sanitize_unpaired_surrogates(message); + } this.message = message; + this.stack = get_stack(); } + expose(AssertionError, "AssertionError"); - AssertionError.prototype.toString = function() { - return this.message; - }; + AssertionError.prototype = Object.create(Error.prototype); + + const get_stack = function() { + var stack = new Error().stack; + // IE11 does not initialize 'Error.stack' until the object is thrown. + if (!stack) { + try { + throw new Error(); + } catch (e) { + stack = e.stack; + } + } + + // 'Error.stack' is not supported in all browsers/versions + if (!stack) { + return "(Stack trace unavailable)"; + } + + var lines = stack.split("\n"); + + // Create a pattern to match stack frames originating within testharness.js. These include the + // script URL, followed by the line/col (e.g., '/resources/testharness.js:120:21'). + // Escape the URL per http://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript + // in case it contains RegExp characters. + var script_url = get_script_url(); + var re_text = script_url ? script_url.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&') : "\\btestharness.js"; + var re = new RegExp(re_text + ":\\d+:\\d+"); + + // Some browsers include a preamble that specifies the type of the error object. Skip this by + // advancing until we find the first stack frame originating from testharness.js. + var i = 0; + while (!re.test(lines[i]) && i < lines.length) { + i++; + } + + // Then skip the top frames originating from testharness.js to begin the stack at the test code. + while (re.test(lines[i]) && i < lines.length) { + i++; + } + + // Paranoid check that we didn't skip all frames. If so, return the original stack unmodified. + if (i >= lines.length) { + return stack; + } + + return lines.slice(i).join("\n"); + } + + function OptionalFeatureUnsupportedError(message) + { + AssertionError.call(this, message); + } + OptionalFeatureUnsupportedError.prototype = Object.create(AssertionError.prototype); + expose(OptionalFeatureUnsupportedError, "OptionalFeatureUnsupportedError"); function make_message(function_name, description, error, substitutions) { @@ -2243,7 +3873,7 @@ policies and contribution forms [3]. Array.prototype.push.apply(array, items); } - function forEach (array, callback, thisObj) + function forEach(array, callback, thisObj) { for (var i = 0; i < array.length; i++) { if (array.hasOwnProperty(i)) { @@ -2252,6 +3882,57 @@ policies and contribution forms [3]. } } + /** + * Immediately invoke a "iteratee" function with a series of values in + * parallel and invoke a final "done" function when all of the "iteratee" + * invocations have signaled completion. + * + * If all callbacks complete synchronously (or if no callbacks are + * specified), the `done_callback` will be invoked synchronously. It is the + * responsibility of the caller to ensure asynchronicity in cases where + * that is desired. + * + * @param {array} value Zero or more values to use in the invocation of + * `iter_callback` + * @param {function} iter_callback A function that will be invoked once for + * each of the provided `values`. Two + * arguments will be available in each + * invocation: the value from `values` and + * a function that must be invoked to + * signal completion + * @param {function} done_callback A function that will be invoked after + * all operations initiated by the + * `iter_callback` function have signaled + * completion + */ + function all_async(values, iter_callback, done_callback) + { + var remaining = values.length; + + if (remaining === 0) { + done_callback(); + } + + forEach(values, + function(element) { + var invoked = false; + var elDone = function() { + if (invoked) { + return; + } + + invoked = true; + remaining -= 1; + + if (remaining === 0) { + done_callback(); + } + }; + + iter_callback(element, elDone); + }); + } + function merge(a,b) { var rv = {}; @@ -2268,7 +3949,7 @@ policies and contribution forms [3]. function expose(object, name) { var components = name.split("."); - var target = test_environment.global_scope(); + var target = global_scope; for (var i = 0; i < components.length - 1; i++) { if (!(components[i] in target)) { target[components[i]] = {}; @@ -2287,18 +3968,62 @@ policies and contribution forms [3]. } } + /** Returns the 'src' URL of the first \n'; - return source; - }, - - /** - * Add element containing metadata source code - */ - addSourceElement: function(event) { - var sourceWrapper = document.createElement('div'); - sourceWrapper.setAttribute('id', 'metadata_source'); - - var instructions = document.createElement('p'); - if (this.cachedMetadata) { - this.appendText(instructions, - 'Replace the existing