mirror of
https://github.com/codeninjasllc/codecombat.git
synced 2024-11-30 10:56:53 -05:00
Show wev-dev iFrame error messages like Aether's
This heavily refactors SpellView and adds infrastructure for receiving and reporting Errors raised by the web-dev iFrame. The web-dev error system, the Aether error system, and the Ace html-worker avoid disturbing each others' errors/annotations (though currently Aether+web-dev errors won't coexist), and they clear/update their own asynchronously. Show web-dev iFrame errors as Ace annotations Add functional error banners (with poor messages) Improve error banners, don't allow duplicate Problems Refactor setAnnotations override Convert all constructor calls for Problems Add comments, clean up Clean up Don't clear things unnecessarily Clean up error message sending from iFrame Add web-dev:error schema Clarify error message attributes Refactor displaying AetherProblems Refactor displaying user problem banners Refactor onWebDevError Set ace styles on updating @problems Clean up, fix off-by-1 error Add comment Show stale web-dev errors differently Some web-dev errors are generated by "stale" code — code that's still running in the iFrame but doesn't have the player's recent changes. This shows those errors differently than if they weren't "stale", and suggests they re-run their code. Hook up web-dev event schema Destroy ignored duplicate problems Functionalize a bit of stuff Fix ProblemAlertView never loading
This commit is contained in:
parent
273845ce2e
commit
663c220eaf
12 changed files with 203 additions and 84 deletions
|
@ -1,5 +1,11 @@
|
||||||
// TODO: don't serve this script from codecombat.com; serve it from a harmless extra domain we don't have yet.
|
// TODO: don't serve this script from codecombat.com; serve it from a harmless extra domain we don't have yet.
|
||||||
|
|
||||||
|
var lastSource = null;
|
||||||
|
var lastOrigin = null;
|
||||||
|
window.onerror = function(message, url, line, column, error){
|
||||||
|
console.log("User script error on line " + line + ", column " + column + ": ", error);
|
||||||
|
lastSource.postMessage({ type: 'error', message: message, url: url, line: line, column: column }, lastOrigin);
|
||||||
|
}
|
||||||
window.addEventListener('message', receiveMessage, false);
|
window.addEventListener('message', receiveMessage, false);
|
||||||
|
|
||||||
var concreteDom;
|
var concreteDom;
|
||||||
|
@ -30,8 +36,9 @@ function receiveMessage(event) {
|
||||||
console.log('Ignoring message from bad origin:', origin);
|
console.log('Ignoring message from bad origin:', origin);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
lastOrigin = origin;
|
||||||
var data = event.data;
|
var data = event.data;
|
||||||
var source = event.source;
|
var source = lastSource = event.source;
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'create':
|
case 'create':
|
||||||
create(_.pick(data, 'dom', 'styles', 'scripts'));
|
create(_.pick(data, 'dom', 'styles', 'scripts'));
|
||||||
|
@ -79,11 +86,7 @@ function replaceNodes(selector, newNodes){
|
||||||
$newNodes.attr('for', firstNode.attr('for'));
|
$newNodes.attr('for', firstNode.attr('for'));
|
||||||
|
|
||||||
newFirstNode = $newNodes[0];
|
newFirstNode = $newNodes[0];
|
||||||
try {
|
|
||||||
firstNode.replaceWith(newFirstNode); // Removes newFirstNode from its array (!!)
|
firstNode.replaceWith(newFirstNode); // Removes newFirstNode from its array (!!)
|
||||||
} catch (e) {
|
|
||||||
console.log('Failed to update some nodes:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
$(newFirstNode).after($newNodes);
|
$(newFirstNode).after($newNodes);
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ channelSchemas =
|
||||||
'tome': require 'schemas/subscriptions/tome'
|
'tome': require 'schemas/subscriptions/tome'
|
||||||
'god': require 'schemas/subscriptions/god'
|
'god': require 'schemas/subscriptions/god'
|
||||||
'scripts': require 'schemas/subscriptions/scripts'
|
'scripts': require 'schemas/subscriptions/scripts'
|
||||||
|
'web-dev': require 'schemas/subscriptions/web-dev'
|
||||||
'world': require 'schemas/subscriptions/world'
|
'world': require 'schemas/subscriptions/world'
|
||||||
|
|
||||||
definitionSchemas =
|
definitionSchemas =
|
||||||
|
|
|
@ -89,7 +89,7 @@ module.exports =
|
||||||
'tome:problems-updated': c.object {title: 'Problems Updated', description: 'Published when problems have been updated', required: ['spell', 'problems', 'isCast']},
|
'tome:problems-updated': c.object {title: 'Problems Updated', description: 'Published when problems have been updated', required: ['spell', 'problems', 'isCast']},
|
||||||
spell: {type: 'object'}
|
spell: {type: 'object'}
|
||||||
problems: {type: 'array'}
|
problems: {type: 'array'}
|
||||||
isCast: {type: 'boolean'}
|
isCast: {type: 'boolean', description: 'Whether the code has been Run yet. Sometimes determines if error displays as just annotation or as full banner.'}
|
||||||
|
|
||||||
'tome:change-language': c.object {title: 'Tome Change Language', description: 'Published when the Tome should update its programming language', required: ['language']},
|
'tome:change-language': c.object {title: 'Tome Change Language', description: 'Published when the Tome should update its programming language', required: ['language']},
|
||||||
language: {type: 'string'}
|
language: {type: 'string'}
|
||||||
|
|
9
app/schemas/subscriptions/web-dev.coffee
Normal file
9
app/schemas/subscriptions/web-dev.coffee
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
c = require 'schemas/schemas'
|
||||||
|
|
||||||
|
module.exports =
|
||||||
|
'web-dev:error': c.object {title: 'Web Dev Error', description: 'Published when an uncaught error occurs in the web-dev iFrame', required: []},
|
||||||
|
message: { type: 'string' }
|
||||||
|
url: { type: 'string', description: 'URL of the host iFrame' }
|
||||||
|
line: { type: 'integer', description: 'Line number of the start of the code that threw the exception (relative to its <script> tag!)' }
|
||||||
|
column: { type: 'integer', description: 'Column number of the start of the code that threw the exception' }
|
||||||
|
error: { type: 'string', description: 'The .toString of the originally thrown exception' }
|
|
@ -188,10 +188,6 @@
|
||||||
.ace_gutter-cell.ace_error
|
.ace_gutter-cell.ace_error
|
||||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAABx0RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTM5jWRgMAAAAVdEVYdENyZWF0aW9uIFRpbWUAMi8xNy8wOCCcqlgAAAQRdEVYdFhNTDpjb20uYWRvYmUueG1wADw/eHBhY2tldCBiZWdpbj0iICAgIiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDQuMS1jMDM0IDQ2LjI3Mjk3NiwgU2F0IEphbiAyNyAyMDA3IDIyOjExOjQxICAgICAgICAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp4YXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iPgogICAgICAgICA8eGFwOkNyZWF0b3JUb29sPkFkb2JlIEZpcmV3b3JrcyBDUzM8L3hhcDpDcmVhdG9yVG9vbD4KICAgICAgICAgPHhhcDpDcmVhdGVEYXRlPjIwMDgtMDItMTdUMDI6MzY6NDVaPC94YXA6Q3JlYXRlRGF0ZT4KICAgICAgICAgPHhhcDpNb2RpZnlEYXRlPjIwMDgtMDMtMjRUMTk6MDA6NDJaPC94YXA6TW9kaWZ5RGF0ZT4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiAgICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2UvcG5nPC9kYzpmb3JtYXQ+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDUdUmQAAAD5SURBVDiNpZMxagMxEEWfgiCXcB3IbXwD7zbaM0nNyjdIl1O4Dk7pbsslEFbEZFKsJsiJrGDy4YM0M//zRyAoINAJyB8cS43RwwIdMFrvaeE8DADxXqQ3Jstn6GaQ5L3M0GQxsyaZoJtA3r2XCS6o+FkvZkdOIG/eywl+UVHrqcYm4BNIjb1rPdXYBTivj3gVtZ5q/p8gAfPhcLOBamzKcW41UI1dgA/qez4bU6muUE0zwVYEgKeKkWruEnTHENg4R8pFZblCyY1zHEMgQTQAe9gB8cE5XkO4GhugmIk76L+z+Wzy6FzT4CWLXf5MF8upSdMB4gC9Xr4AiezTJHGxdq0AAAAASUVORK5CYII=)
|
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAABx0RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTM5jWRgMAAAAVdEVYdENyZWF0aW9uIFRpbWUAMi8xNy8wOCCcqlgAAAQRdEVYdFhNTDpjb20uYWRvYmUueG1wADw/eHBhY2tldCBiZWdpbj0iICAgIiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDQuMS1jMDM0IDQ2LjI3Mjk3NiwgU2F0IEphbiAyNyAyMDA3IDIyOjExOjQxICAgICAgICAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp4YXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iPgogICAgICAgICA8eGFwOkNyZWF0b3JUb29sPkFkb2JlIEZpcmV3b3JrcyBDUzM8L3hhcDpDcmVhdG9yVG9vbD4KICAgICAgICAgPHhhcDpDcmVhdGVEYXRlPjIwMDgtMDItMTdUMDI6MzY6NDVaPC94YXA6Q3JlYXRlRGF0ZT4KICAgICAgICAgPHhhcDpNb2RpZnlEYXRlPjIwMDgtMDMtMjRUMTk6MDA6NDJaPC94YXA6TW9kaWZ5RGF0ZT4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgICAgIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICAgICAgICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiAgICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2UvcG5nPC9kYzpmb3JtYXQ+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDUdUmQAAAD5SURBVDiNpZMxagMxEEWfgiCXcB3IbXwD7zbaM0nNyjdIl1O4Dk7pbsslEFbEZFKsJsiJrGDy4YM0M//zRyAoINAJyB8cS43RwwIdMFrvaeE8DADxXqQ3Jstn6GaQ5L3M0GQxsyaZoJtA3r2XCS6o+FkvZkdOIG/eywl+UVHrqcYm4BNIjb1rPdXYBTivj3gVtZ5q/p8gAfPhcLOBamzKcW41UI1dgA/qez4bU6muUE0zwVYEgKeKkWruEnTHENg4R8pFZblCyY1zHEMgQTQAe9gB8cE5XkO4GhugmIk76L+z+Wzy6FzT4CWLXf5MF8upSdMB4gC9Xr4AiezTJHGxdq0AAAAASUVORK5CYII=)
|
||||||
|
|
||||||
// NOTE! This hides all info annotations because removing specific annotations with listeners means they flicker. This hides that flicker. see: SpellView.onChangeAnnotation
|
|
||||||
.ace_gutter-cell.ace_info
|
|
||||||
background-image: none
|
|
||||||
|
|
||||||
.ace_marker-layer
|
.ace_marker-layer
|
||||||
.ace_bracket
|
.ace_bracket
|
||||||
// Override faint gray
|
// Override faint gray
|
||||||
|
|
|
@ -88,8 +88,11 @@ module.exports = class WebSurfaceView extends CocoView
|
||||||
switch event.data.type
|
switch event.data.type
|
||||||
when 'goals-updated'
|
when 'goals-updated'
|
||||||
Backbone.Mediator.publish 'god:new-html-goal-states', goalStates: event.data.goalStates, overallStatus: event.data.overallStatus
|
Backbone.Mediator.publish 'god:new-html-goal-states', goalStates: event.data.goalStates, overallStatus: event.data.overallStatus
|
||||||
|
when 'error'
|
||||||
|
# NOTE: The line number in this is relative to the script tag, not the user code. The offset is added in SpellView.
|
||||||
|
Backbone.Mediator.publish 'web-dev:error', _.pick(event.data, ['message', 'line', 'column', 'url'])
|
||||||
else
|
else
|
||||||
console.warn 'Unknown message type', event.data.type, 'for message', e, 'from origin', origin
|
console.warn 'Unknown message type', event.data.type, 'for message', event, 'from origin', origin
|
||||||
|
|
||||||
destroy: ->
|
destroy: ->
|
||||||
window.removeEventListener 'message', @onIframeMessage
|
window.removeEventListener 'message', @onIframeMessage
|
||||||
|
|
|
@ -1,43 +1,96 @@
|
||||||
Range = ace.require('ace/range').Range
|
Range = ace.require('ace/range').Range
|
||||||
|
|
||||||
|
# This class can either wrap an AetherProblem,
|
||||||
|
# or act as a general runtime error container for web-dev iFrame errors.
|
||||||
|
# TODO: Use subclasses? Might need a factory pattern for that (bleh)
|
||||||
module.exports = class Problem
|
module.exports = class Problem
|
||||||
annotation: null
|
annotation: null
|
||||||
markerRange: null
|
markerRange: null
|
||||||
constructor: (@aether, @aetherProblem, @ace, isCast=false, @levelID) ->
|
# Construction with AetherProblem will include all but `error`
|
||||||
@buildAnnotation()
|
# Construction with a standard error will have `error`, `isCast`, `levelID`, `ace`
|
||||||
@buildMarkerRange() if isCast
|
constructor: ({ @aether, @aetherProblem, @ace, isCast=false, @levelID, error, userCodeHasChangedSinceLastCast }) ->
|
||||||
|
if @aetherProblem
|
||||||
|
@annotation = @buildAnnotationFromAetherProblem(@aetherProblem)
|
||||||
|
{ @lineMarkerRange, @textMarkerRange } = @buildMarkerRangesFromAetherProblem(@aetherProblem) if isCast
|
||||||
|
|
||||||
|
{ @level, @range, @message, @hint, @userInfo } = @aetherProblem
|
||||||
|
{ @row, @column: col } = @aetherProblem.range?[0]
|
||||||
|
@createdBy = 'aether'
|
||||||
|
else
|
||||||
|
unless userCodeHasChangedSinceLastCast
|
||||||
|
@annotation = @buildAnnotationFromWebDevError(error)
|
||||||
|
{ @lineMarkerRange, @textMarkerRange } = @buildMarkerRangesFromWebDevError(error)
|
||||||
|
|
||||||
|
@level = 'error'
|
||||||
|
@row = error.line
|
||||||
|
@column = error.column
|
||||||
|
@message = error.message or 'Unknown Error'
|
||||||
|
if error.line and not userCodeHasChangedSinceLastCast
|
||||||
|
@message = "Line #{error.line + 1}: " + @message # Ace's gutter numbers are 1-indexed but annotation.rows are 0-indexed
|
||||||
|
if userCodeHasChangedSinceLastCast
|
||||||
|
@hint = "This error was generated by old code — Try running your new code first."
|
||||||
|
else
|
||||||
|
@hint = undefined
|
||||||
|
@userInfo = undefined
|
||||||
|
@createdBy = 'web-dev-iframe'
|
||||||
|
# TODO: Include runtime/transpile error types depending on something?
|
||||||
|
|
||||||
# TODO: get ACE screen line, too, for positioning, since any multiline "lines" will mess up positioning
|
# TODO: get ACE screen line, too, for positioning, since any multiline "lines" will mess up positioning
|
||||||
Backbone.Mediator.publish("problem:problem-created", line: @annotation.row, text: @annotation.text) if application.isIPadApp
|
Backbone.Mediator.publish("problem:problem-created", line: @annotation.row, text: @annotation.text) if application.isIPadApp
|
||||||
|
|
||||||
|
isEqual: (problem) ->
|
||||||
|
_.all ['row', 'column', 'level', 'column', 'message', 'hint'], (attr) =>
|
||||||
|
@[attr] is problem[attr]
|
||||||
|
|
||||||
destroy: ->
|
destroy: ->
|
||||||
@removeMarkerRanges()
|
@removeMarkerRanges()
|
||||||
@userCodeProblem.off() if @userCodeProblem
|
@userCodeProblem.off() if @userCodeProblem
|
||||||
|
|
||||||
buildAnnotation: ->
|
buildAnnotationFromWebDevError: (error) ->
|
||||||
return unless @aetherProblem.range
|
{
|
||||||
text = @aetherProblem.message.replace /^Line \d+: /, ''
|
row: error.line
|
||||||
start = @aetherProblem.range[0]
|
column: error.column
|
||||||
@annotation =
|
raw: error.message
|
||||||
|
text: error.message
|
||||||
|
type: 'error'
|
||||||
|
createdBy: 'web-dev-iframe'
|
||||||
|
}
|
||||||
|
|
||||||
|
buildAnnotationFromAetherProblem: (aetherProblem) ->
|
||||||
|
return unless aetherProblem.range
|
||||||
|
text = aetherProblem.message.replace /^Line \d+: /, ''
|
||||||
|
start = aetherProblem.range[0]
|
||||||
|
{
|
||||||
row: start.row,
|
row: start.row,
|
||||||
column: start.col,
|
column: start.col,
|
||||||
raw: text,
|
raw: text,
|
||||||
text: text,
|
text: text,
|
||||||
type: @aetherProblem.level ? 'error'
|
type: @aetherProblem.level ? 'error'
|
||||||
createdBy: 'aether'
|
createdBy: 'aether'
|
||||||
|
}
|
||||||
|
|
||||||
buildMarkerRange: ->
|
buildMarkerRangesFromWebDevError: (error) ->
|
||||||
return unless @aetherProblem.range
|
lineMarkerRange = new Range error.line, 0, error.line, 1
|
||||||
[start, end] = @aetherProblem.range
|
lineMarkerRange.start = @ace.getSession().getDocument().createAnchor lineMarkerRange.start
|
||||||
textClazz = "problem-marker-#{@aetherProblem.level}"
|
lineMarkerRange.end = @ace.getSession().getDocument().createAnchor lineMarkerRange.end
|
||||||
@textMarkerRange = new Range start.row, start.col, end.row, end.col
|
lineMarkerRange.id = @ace.getSession().addMarker lineMarkerRange, 'problem-line', 'fullLine'
|
||||||
@textMarkerRange.start = @ace.getSession().getDocument().createAnchor @textMarkerRange.start
|
textMarkerRange = undefined # We don't get any per-character info from standard errors
|
||||||
@textMarkerRange.end = @ace.getSession().getDocument().createAnchor @textMarkerRange.end
|
{ lineMarkerRange, textMarkerRange }
|
||||||
@textMarkerRange.id = @ace.getSession().addMarker @textMarkerRange, textClazz, 'text'
|
|
||||||
|
buildMarkerRangesFromAetherProblem: (aetherProblem) ->
|
||||||
|
return {} unless aetherProblem.range
|
||||||
|
[start, end] = aetherProblem.range
|
||||||
|
textClazz = "problem-marker-#{aetherProblem.level}"
|
||||||
|
textMarkerRange = new Range start.row, start.col, end.row, end.col
|
||||||
|
textMarkerRange.start = @ace.getSession().getDocument().createAnchor textMarkerRange.start
|
||||||
|
textMarkerRange.end = @ace.getSession().getDocument().createAnchor textMarkerRange.end
|
||||||
|
textMarkerRange.id = @ace.getSession().addMarker textMarkerRange, textClazz, 'text'
|
||||||
lineClazz = "problem-line"
|
lineClazz = "problem-line"
|
||||||
@lineMarkerRange = new Range start.row, start.col, end.row, end.col
|
lineMarkerRange = new Range start.row, start.col, end.row, end.col
|
||||||
@lineMarkerRange.start = @ace.getSession().getDocument().createAnchor @lineMarkerRange.start
|
lineMarkerRange.start = @ace.getSession().getDocument().createAnchor lineMarkerRange.start
|
||||||
@lineMarkerRange.end = @ace.getSession().getDocument().createAnchor @lineMarkerRange.end
|
lineMarkerRange.end = @ace.getSession().getDocument().createAnchor lineMarkerRange.end
|
||||||
@lineMarkerRange.id = @ace.getSession().addMarker @lineMarkerRange, lineClazz, 'fullLine'
|
lineMarkerRange.id = @ace.getSession().addMarker lineMarkerRange, lineClazz, 'fullLine'
|
||||||
|
{ lineMarkerRange, textMarkerRange }
|
||||||
|
|
||||||
removeMarkerRanges: ->
|
removeMarkerRanges: ->
|
||||||
if @textMarkerRange
|
if @textMarkerRange
|
||||||
|
|
|
@ -20,10 +20,10 @@ module.exports = class ProblemAlertView extends CocoView
|
||||||
'click': -> Backbone.Mediator.publish 'tome:focus-editor', {}
|
'click': -> Backbone.Mediator.publish 'tome:focus-editor', {}
|
||||||
|
|
||||||
constructor: (options) ->
|
constructor: (options) ->
|
||||||
|
@supermodel = options.supermodel # Has to go before super so events are hooked up
|
||||||
super options
|
super options
|
||||||
@level = options.level
|
@level = options.level
|
||||||
@session = options.session
|
@session = options.session
|
||||||
@supermodel = options.supermodel
|
|
||||||
if options.problem?
|
if options.problem?
|
||||||
@problem = options.problem
|
@problem = options.problem
|
||||||
@onWindowResize()
|
@onWindowResize()
|
||||||
|
@ -38,31 +38,31 @@ module.exports = class ProblemAlertView extends CocoView
|
||||||
afterRender: ->
|
afterRender: ->
|
||||||
super()
|
super()
|
||||||
if @problem?
|
if @problem?
|
||||||
@$el.addClass('alert').addClass("alert-#{@problem.aetherProblem.level}").hide().fadeIn('slow')
|
@$el.addClass('alert').addClass("alert-#{@problem.level}").hide().fadeIn('slow')
|
||||||
@$el.addClass('no-hint') unless @problem.aetherProblem.hint
|
@$el.addClass('no-hint') unless @problem.hint
|
||||||
@playSound 'error_appear'
|
@playSound 'error_appear'
|
||||||
|
|
||||||
setProblemMessage: ->
|
setProblemMessage: ->
|
||||||
if @problem?
|
if @problem?
|
||||||
format = (s) -> marked(s.replace(/</g, '<').replace(/>/g, '>')) if s?
|
format = (s) -> marked(s.replace(/</g, '<').replace(/>/g, '>')) if s?
|
||||||
message = @problem.aetherProblem.message
|
message = @problem.message
|
||||||
# Add time to problem message if hint is for a missing null check
|
# Add time to problem message if hint is for a missing null check
|
||||||
# NOTE: This may need to be updated with Aether error hint changes
|
# NOTE: This may need to be updated with Aether error hint changes
|
||||||
if @problem.aetherProblem.hint? and /(?:null|undefined)/.test @problem.aetherProblem.hint
|
if @problem.hint? and /(?:null|undefined)/.test @problem.hint
|
||||||
age = @problem.aetherProblem.userInfo?.age
|
age = @problem.userInfo?.age
|
||||||
if age?
|
if age?
|
||||||
if /^Line \d+:/.test message
|
if /^Line \d+:/.test message
|
||||||
message = message.replace /^(Line \d+)/, "$1, time #{age.toFixed(1)}"
|
message = message.replace /^(Line \d+)/, "$1, time #{age.toFixed(1)}"
|
||||||
else
|
else
|
||||||
message = "Time #{age.toFixed(1)}: #{message}"
|
message = "Time #{age.toFixed(1)}: #{message}"
|
||||||
@message = format message
|
@message = format message
|
||||||
@hint = format @problem.aetherProblem.hint
|
@hint = format @problem.hint
|
||||||
|
|
||||||
onShowProblemAlert: (data) ->
|
onShowProblemAlert: (data) ->
|
||||||
return unless $('#code-area').is(":visible")
|
return unless $('#code-area').is(":visible")
|
||||||
if @problem?
|
if @problem?
|
||||||
if @$el.hasClass "alert-#{@problem.aetherProblem.level}"
|
if @$el.hasClass "alert-#{@problem.level}"
|
||||||
@$el.removeClass "alert-#{@problem.aetherProblem.level}"
|
@$el.removeClass "alert-#{@problem.level}"
|
||||||
if @$el.hasClass "no-hint"
|
if @$el.hasClass "no-hint"
|
||||||
@$el.removeClass "no-hint"
|
@$el.removeClass "no-hint"
|
||||||
@problem = data.problem
|
@problem = data.problem
|
||||||
|
|
|
@ -145,6 +145,7 @@ module.exports = class Spell
|
||||||
@thang?.aether.transpile source
|
@thang?.aether.transpile source
|
||||||
null
|
null
|
||||||
|
|
||||||
|
# NOTE: By default, I think this compares the current source code with the source *last saved to the server* (not the last time it was run)
|
||||||
hasChanged: (newSource=null, currentSource=null) ->
|
hasChanged: (newSource=null, currentSource=null) ->
|
||||||
(newSource ? @originalSource) isnt (currentSource ? @source)
|
(newSource ? @originalSource) isnt (currentSource ? @source)
|
||||||
|
|
||||||
|
|
|
@ -49,10 +49,12 @@ module.exports = class SpellView extends CocoView
|
||||||
'tome:insert-snippet': 'onInsertSnippet'
|
'tome:insert-snippet': 'onInsertSnippet'
|
||||||
'tome:spell-beautify': 'onSpellBeautify'
|
'tome:spell-beautify': 'onSpellBeautify'
|
||||||
'tome:maximize-toggled': 'onMaximizeToggled'
|
'tome:maximize-toggled': 'onMaximizeToggled'
|
||||||
|
'tome:problems-updated': 'onProblemsUpdated'
|
||||||
'script:state-changed': 'onScriptStateChange'
|
'script:state-changed': 'onScriptStateChange'
|
||||||
'playback:ended-changed': 'onPlaybackEndedChanged'
|
'playback:ended-changed': 'onPlaybackEndedChanged'
|
||||||
'level:contact-button-pressed': 'onContactButtonPressed'
|
'level:contact-button-pressed': 'onContactButtonPressed'
|
||||||
'level:show-victory': 'onShowVictory'
|
'level:show-victory': 'onShowVictory'
|
||||||
|
'web-dev:error': 'onWebDevError'
|
||||||
|
|
||||||
events:
|
events:
|
||||||
'mouseout': 'onMouseOut'
|
'mouseout': 'onMouseOut'
|
||||||
|
@ -87,6 +89,14 @@ module.exports = class SpellView extends CocoView
|
||||||
@destroyAceEditor(@ace)
|
@destroyAceEditor(@ace)
|
||||||
@ace = ace.edit @$el.find('.ace')[0]
|
@ace = ace.edit @$el.find('.ace')[0]
|
||||||
@aceSession = @ace.getSession()
|
@aceSession = @ace.getSession()
|
||||||
|
# Override setAnnotations so the Ace html worker doesn't clobber our annotations
|
||||||
|
@reallySetAnnotations = @aceSession.setAnnotations.bind(@aceSession)
|
||||||
|
@aceSession.setAnnotations = (annotations) =>
|
||||||
|
previousAnnotations = @aceSession.getAnnotations()
|
||||||
|
newAnnotations = _.filter previousAnnotations, (annotation) -> annotation.createdBy? # Keep the ones we generated
|
||||||
|
.concat _.reject annotations, (annotation) -> # Ignore this particular info-annotation the html worker generates
|
||||||
|
annotation.text is 'Start tag seen without seeing a doctype first. Expected e.g. <!DOCTYPE html>.'
|
||||||
|
@reallySetAnnotations newAnnotations
|
||||||
@aceDoc = @aceSession.getDocument()
|
@aceDoc = @aceSession.getDocument()
|
||||||
@aceSession.setUseWorker @spell.language in @languagesThatUseWorkers
|
@aceSession.setUseWorker @spell.language in @languagesThatUseWorkers
|
||||||
@aceSession.setMode utils.aceEditModes[@spell.language]
|
@aceSession.setMode utils.aceEditModes[@spell.language]
|
||||||
|
@ -94,7 +104,6 @@ module.exports = class SpellView extends CocoView
|
||||||
@aceSession.setUseWrapMode true
|
@aceSession.setUseWrapMode true
|
||||||
@aceSession.setNewLineMode 'unix'
|
@aceSession.setNewLineMode 'unix'
|
||||||
@aceSession.setUseSoftTabs true
|
@aceSession.setUseSoftTabs true
|
||||||
@aceSession.on 'changeAnnotation', @onChangeAnnotation
|
|
||||||
@ace.setTheme 'ace/theme/textmate'
|
@ace.setTheme 'ace/theme/textmate'
|
||||||
@ace.setDisplayIndentGuides false
|
@ace.setDisplayIndentGuides false
|
||||||
@ace.setShowPrintMargin false
|
@ace.setShowPrintMargin false
|
||||||
|
@ -670,7 +679,10 @@ module.exports = class SpellView extends CocoView
|
||||||
cast = @$el.parent().length
|
cast = @$el.parent().length
|
||||||
@recompile cast, e.realTime
|
@recompile cast, e.realTime
|
||||||
@focus() if cast
|
@focus() if cast
|
||||||
@updateHTML create: true if @options.level.isType('web-dev')
|
if @options.level.isType('web-dev')
|
||||||
|
@sourceAtLastCast = @getSource()
|
||||||
|
@ace.setStyle 'spell-cast'
|
||||||
|
@updateHTML create: true
|
||||||
|
|
||||||
onCodeReload: (e) ->
|
onCodeReload: (e) ->
|
||||||
return unless e.spell is @spell or not e.spell
|
return unless e.spell is @spell or not e.spell
|
||||||
|
@ -736,6 +748,11 @@ module.exports = class SpellView extends CocoView
|
||||||
onCursorActivity: => # Used to refresh autocast delay; doesn't do anything at the moment.
|
onCursorActivity: => # Used to refresh autocast delay; doesn't do anything at the moment.
|
||||||
|
|
||||||
updateHTML: (options={}) =>
|
updateHTML: (options={}) =>
|
||||||
|
# TODO: Merge with onSpellChanged
|
||||||
|
# NOTE: Consider what goes in onManualCast only
|
||||||
|
if @spell.hasChanged(@spell.getSource(), @sourceAtLastCast)
|
||||||
|
@ace.unsetStyle 'spell-cast' # NOTE: Doesn't do anything for web-dev as of this writing, including for consistency
|
||||||
|
@clearWebDevErrors()
|
||||||
Backbone.Mediator.publish 'tome:html-updated', html: @spell.constructHTML(@getSource()), create: Boolean(options.create)
|
Backbone.Mediator.publish 'tome:html-updated', html: @spell.constructHTML(@getSource()), create: Boolean(options.create)
|
||||||
|
|
||||||
# Design for a simpler system?
|
# Design for a simpler system?
|
||||||
|
@ -770,6 +787,7 @@ module.exports = class SpellView extends CocoView
|
||||||
# Now that that's figured out, perform the update.
|
# Now that that's figured out, perform the update.
|
||||||
# The web worker Aether won't track state, so don't have to worry about updating it
|
# The web worker Aether won't track state, so don't have to worry about updating it
|
||||||
finishUpdatingAether = (aether) =>
|
finishUpdatingAether = (aether) =>
|
||||||
|
@clearAetherDisplay() # In case problems were added since last clearing
|
||||||
@displayAether aether, codeIsAsCast
|
@displayAether aether, codeIsAsCast
|
||||||
@lastUpdatedAetherSpellThang = @spellThang
|
@lastUpdatedAetherSpellThang = @spellThang
|
||||||
@guessWhetherFinished aether if fromCodeChange
|
@guessWhetherFinished aether if fromCodeChange
|
||||||
|
@ -796,57 +814,92 @@ module.exports = class SpellView extends CocoView
|
||||||
else
|
else
|
||||||
finishUpdatingAether(aether)
|
finishUpdatingAether(aether)
|
||||||
|
|
||||||
# NOTE! Because this alone causes the doctype annotation to flicker,
|
# Each problem-generating piece (aether, web-dev, ace html worker) clears its own problems/annotations
|
||||||
# all info annotations have been hidden with CSS in spell.sass
|
|
||||||
# If we ever want info annotations back, we need to remove that.
|
|
||||||
#
|
|
||||||
# This function itself removes the unwanted annotations on a later tick.
|
|
||||||
onChangeAnnotation: (event, session) ->
|
|
||||||
unfilteredAnnotations = session.getAnnotations()
|
|
||||||
filteredAnnotations = _.reject unfilteredAnnotations, (annotation) ->
|
|
||||||
annotation.text is 'Start tag seen without seeing a doctype first. Expected e.g. <!DOCTYPE html>.'
|
|
||||||
if filteredAnnotations.length < unfilteredAnnotations.length
|
|
||||||
session.setAnnotations(filteredAnnotations)
|
|
||||||
|
|
||||||
# Clear annotations and highlights generated by Aether, but not by the ACE worker
|
|
||||||
clearAetherDisplay: ->
|
clearAetherDisplay: ->
|
||||||
problem.destroy() for problem in @problems
|
@clearProblemsCreatedBy 'aether'
|
||||||
@problems = []
|
|
||||||
nonAetherAnnotations = _.reject @aceSession.getAnnotations(), (annotation) -> annotation.createdBy is 'aether'
|
|
||||||
@aceSession.setAnnotations nonAetherAnnotations
|
|
||||||
@highlightCurrentLine {} # This'll remove all highlights
|
@highlightCurrentLine {} # This'll remove all highlights
|
||||||
|
|
||||||
|
clearWebDevErrors: ->
|
||||||
|
@clearProblemsCreatedBy 'web-dev-iframe'
|
||||||
|
|
||||||
|
clearProblemsCreatedBy: (createdBy) ->
|
||||||
|
nonAetherAnnotations = _.reject @aceSession.getAnnotations(), (annotation) -> annotation.createdBy is createdBy
|
||||||
|
@reallySetAnnotations nonAetherAnnotations
|
||||||
|
|
||||||
|
problemsToClear = _.filter @problems, (p) -> p.createdBy is createdBy
|
||||||
|
problemsToClear.forEach (problem) -> problem.destroy()
|
||||||
|
@problems = _.difference @problems, problemsToClear
|
||||||
|
Backbone.Mediator.publish 'tome:problems-updated', spell: @spell, problems: @problems, isCast: false
|
||||||
|
|
||||||
|
convertAetherProblems: (aether, aetherProblems, isCast) ->
|
||||||
|
# TODO: Functional-ify
|
||||||
|
_.unique(aetherProblems, (p) -> p.userInfo?.key).map (aetherProblem) =>
|
||||||
|
new Problem { aether, aetherProblem, @ace, isCast, levelID: @options.levelID }
|
||||||
|
|
||||||
displayAether: (aether, isCast=false) ->
|
displayAether: (aether, isCast=false) ->
|
||||||
@displayedAether = aether
|
@displayedAether = aether
|
||||||
isCast = isCast or not _.isEmpty(aether.metrics) or _.some aether.getAllProblems(), {type: 'runtime'}
|
isCast = isCast or not _.isEmpty(aether.metrics) or _.some aether.getAllProblems(), {type: 'runtime'}
|
||||||
problem.destroy() for problem in @problems # Just in case another problem was added since clearAetherDisplay() ran.
|
|
||||||
@problems = []
|
|
||||||
annotations = @aceSession.getAnnotations()
|
annotations = @aceSession.getAnnotations()
|
||||||
seenProblemKeys = {}
|
|
||||||
for aetherProblem, problemIndex in aether.getAllProblems()
|
newProblems = @convertAetherProblems(aether, aether.getAllProblems(), isCast)
|
||||||
continue if key = aetherProblem.userInfo?.key and key of seenProblemKeys
|
annotations.push problem.annotation for problem in newProblems when problem.annotation
|
||||||
seenProblemKeys[key] = true if key
|
if isCast
|
||||||
@problems.push problem = new Problem aether, aetherProblem, @ace, isCast, @options.levelID
|
@displayProblemBanner(newProblems[0]) if newProblems[0]
|
||||||
if isCast and problemIndex is 0
|
@saveUserCodeProblem(aether, problem.aetherProblem) for problem in newProblems
|
||||||
if problem.aetherProblem.range?
|
@problems = @problems.concat(newProblems)
|
||||||
lineOffsetPx = 0
|
|
||||||
for i in [0...problem.aetherProblem.range[0].row]
|
|
||||||
lineOffsetPx += @aceSession.getRowLength(i) * @ace.renderer.lineHeight
|
|
||||||
lineOffsetPx -= @ace.session.getScrollTop()
|
|
||||||
Backbone.Mediator.publish 'tome:show-problem-alert', problem: problem, lineOffsetPx: Math.max lineOffsetPx, 0
|
|
||||||
@saveUserCodeProblem(aether, aetherProblem) if isCast
|
|
||||||
annotations.push problem.annotation if problem.annotation
|
|
||||||
@aceSession.setAnnotations annotations
|
@aceSession.setAnnotations annotations
|
||||||
@highlightCurrentLine aether.flow unless _.isEmpty aether.flow
|
@highlightCurrentLine aether.flow unless _.isEmpty aether.flow
|
||||||
#console.log ' and we could do the metrics', aether.metrics unless _.isEmpty aether.metrics
|
#console.log ' and we could do the metrics', aether.metrics unless _.isEmpty aether.metrics
|
||||||
#console.log ' and we could do the style', aether.style unless _.isEmpty aether.style
|
#console.log ' and we could do the style', aether.style unless _.isEmpty aether.style
|
||||||
#console.log ' and we could do the visualization', aether.visualization unless _.isEmpty aether.visualization
|
#console.log ' and we could do the visualization', aether.visualization unless _.isEmpty aether.visualization
|
||||||
# Could use the user-code-problem style... or we could leave that to other places.
|
|
||||||
@ace[if @problems.length then 'setStyle' else 'unsetStyle'] 'user-code-problem'
|
|
||||||
@ace[if isCast then 'setStyle' else 'unsetStyle'] 'spell-cast'
|
|
||||||
Backbone.Mediator.publish 'tome:problems-updated', spell: @spell, problems: @problems, isCast: isCast
|
Backbone.Mediator.publish 'tome:problems-updated', spell: @spell, problems: @problems, isCast: isCast
|
||||||
@ace.resize()
|
@ace.resize()
|
||||||
|
|
||||||
|
# Tell ProblemAlertView to display this problem (only)
|
||||||
|
displayProblemBanner: (problem) ->
|
||||||
|
lineOffsetPx = 0
|
||||||
|
if problem.row?
|
||||||
|
for i in [0...problem.row]
|
||||||
|
lineOffsetPx += @aceSession.getRowLength(i) * @ace.renderer.lineHeight
|
||||||
|
lineOffsetPx -= @ace.session.getScrollTop()
|
||||||
|
Backbone.Mediator.publish 'tome:show-problem-alert', problem: problem, lineOffsetPx: Math.max lineOffsetPx, 0
|
||||||
|
|
||||||
|
# Gets the number of lines before the start of <script> content in the usercode
|
||||||
|
# Because Errors report their line number relative to the <script> tag
|
||||||
|
linesBeforeScript: (html) ->
|
||||||
|
# TODO: refactor, make it work with multiple scripts. What to do when error is in level-creator's code?
|
||||||
|
_.size(html.split('<script>')[0].match(/\n/g))
|
||||||
|
|
||||||
|
addAnnotation: (annotation) ->
|
||||||
|
annotations = @aceSession.getAnnotations()
|
||||||
|
annotations.push annotation
|
||||||
|
@reallySetAnnotations annotations
|
||||||
|
|
||||||
|
# Handle errors from the web-dev iframe asynchronously
|
||||||
|
onWebDevError: (error) ->
|
||||||
|
# TODO: Refactor this and the Aether problem flow to share as much as possible.
|
||||||
|
# TODO: Handle when the error is in our code, not theirs
|
||||||
|
# Compensate for line number being relative to <script> tag
|
||||||
|
offsetError = _.merge {}, error, { line: error.line + @linesBeforeScript(@getSource()) }
|
||||||
|
userCodeHasChangedSinceLastCast = @spell.hasChanged(@spell.getSource(), @sourceAtLastCast)
|
||||||
|
problem = new Problem({ error: offsetError, @ace, levelID: @options.levelID, userCodeHasChangedSinceLastCast })
|
||||||
|
# Ignore the Problem if we already know about it
|
||||||
|
if _.any(@problems, (preexistingProblem) -> problem.isEqual(preexistingProblem))
|
||||||
|
problem.destroy()
|
||||||
|
else # Ok, the problem is worth keeping
|
||||||
|
@problems.push problem
|
||||||
|
@displayProblemBanner(problem)
|
||||||
|
|
||||||
|
# @saveUserCodeProblem(aether, aetherProblem) # TODO: Enable saving of web-dev user code problems
|
||||||
|
@addAnnotation(problem.annotation) if problem.annotation
|
||||||
|
Backbone.Mediator.publish 'tome:problems-updated', spell: @spell, problems: @problems, isCast: false
|
||||||
|
|
||||||
|
onProblemsUpdated: ({ spell, problems, isCast }) ->
|
||||||
|
# This just handles some ace styles for now; other things handle @problems changes elsewhere
|
||||||
|
@ace[if problems.length then 'setStyle' else 'unsetStyle'] 'user-code-problem'
|
||||||
|
@ace[if isCast then 'setStyle' else 'unsetStyle'] 'spell-cast' # Does this still do anything?
|
||||||
|
|
||||||
saveUserCodeProblem: (aether, aetherProblem) ->
|
saveUserCodeProblem: (aether, aetherProblem) ->
|
||||||
# Skip duplicate problems
|
# Skip duplicate problems
|
||||||
hashValue = aether.raw + aetherProblem.message
|
hashValue = aether.raw + aetherProblem.message
|
||||||
|
@ -939,6 +992,7 @@ module.exports = class SpellView extends CocoView
|
||||||
@spell.thang.aether[key] = value
|
@spell.thang.aether[key] = value
|
||||||
|
|
||||||
onSpellChanged: (e) ->
|
onSpellChanged: (e) ->
|
||||||
|
# TODO: Merge with updateHTML
|
||||||
@spellHasChanged = true
|
@spellHasChanged = true
|
||||||
|
|
||||||
onSessionWillSave: (e) ->
|
onSessionWillSave: (e) ->
|
||||||
|
|
|
@ -206,4 +206,3 @@ module.exports =
|
||||||
return false if index is -1
|
return false if index is -1
|
||||||
return false if delta.deltaPath[index+1] in ['en', 'en-US', 'en-GB'] # English speakers are most likely just spamming, so always treat those as patches, not saves.
|
return false if delta.deltaPath[index+1] in ['en', 'en-US', 'en-GB'] # English speakers are most likely just spamming, so always treat those as patches, not saves.
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
|
@ -29,7 +29,7 @@ describe 'Problem', ->
|
||||||
|
|
||||||
# TODO: Problems are no longer saved when creating Problems; instead it's in SpellView. Update tests?
|
# TODO: Problems are no longer saved when creating Problems; instead it's in SpellView. Update tests?
|
||||||
xit 'save user code problem', ->
|
xit 'save user code problem', ->
|
||||||
new Problem aether, aetherProblem, ace, false, true, levelID
|
new Problem {aether, aetherProblem, ace, isCast: false, levelID}
|
||||||
expect(jasmine.Ajax.requests.count()).toBe(1)
|
expect(jasmine.Ajax.requests.count()).toBe(1)
|
||||||
|
|
||||||
request = jasmine.Ajax.requests.mostRecent()
|
request = jasmine.Ajax.requests.mostRecent()
|
||||||
|
@ -49,7 +49,7 @@ describe 'Problem', ->
|
||||||
|
|
||||||
xit 'save user code problem no range', ->
|
xit 'save user code problem no range', ->
|
||||||
aetherProblem.range = null
|
aetherProblem.range = null
|
||||||
new Problem aether, aetherProblem, ace, false, true, levelID
|
new Problem {aether, aetherProblem, ace, isCast: false, levelID}
|
||||||
expect(jasmine.Ajax.requests.count()).toBe(1)
|
expect(jasmine.Ajax.requests.count()).toBe(1)
|
||||||
|
|
||||||
request = jasmine.Ajax.requests.mostRecent()
|
request = jasmine.Ajax.requests.mostRecent()
|
||||||
|
@ -73,7 +73,7 @@ describe 'Problem', ->
|
||||||
aether.raw = "this.say('hi');\nthis.sad\n('bye');"
|
aether.raw = "this.say('hi');\nthis.sad\n('bye');"
|
||||||
aetherProblem.range = [ { row: 1 }, { row: 2 } ]
|
aetherProblem.range = [ { row: 1 }, { row: 2 } ]
|
||||||
|
|
||||||
new Problem aether, aetherProblem, ace, false, true, levelID
|
new Problem {aether, aetherProblem, ace, isCast: false, levelID}
|
||||||
expect(jasmine.Ajax.requests.count()).toBe(1)
|
expect(jasmine.Ajax.requests.count()).toBe(1)
|
||||||
|
|
||||||
request = jasmine.Ajax.requests.mostRecent()
|
request = jasmine.Ajax.requests.mostRecent()
|
||||||
|
|
Loading…
Reference in a new issue